用 ~3000 行 C 从零实现 Qwen3-0.6B 推理,不依赖 PyTorch 或任何深度学习框架, 附带 OpenAI 兼容的 HTTP 服务。
文本 → BPE 分词 → 词向量 → 28 层 Transformer → 采样 → 文本
分词器、注意力、KV Cache、RoPE、采样、HTTP 服务全部手写
学习性质的项目,目标是彻底搞清一次 LLM 推理里到底发生了什么, 因此优先追求可读与可验证:每一步都以 PyTorch 为标准答案做数值对拍, 完整前向预测出的 token id 与 PyTorch 完全一致。
# 1. 下载模型(约 1.4 GB,默认路径是仓库同级目录)
pip install huggingface_hub
hf download Qwen/Qwen3-0.6B --local-dir ../models/Qwen3-0.6B
# 2. 编译(无外部依赖,只需 C 编译器)
make
# 3. 跑
./qwen3 gen "用一句话解释什么是KV Cache"
./qwen3 chat # 交互式多轮对话
./qwen3 serve # HTTP 服务,默认 127.0.0.1:8080模型放别处用 -m <目录> 指定。其他选项见 ./qwen3 无参数输出。
./qwen3 serve 后,任何 OpenAI 客户端都能直接连:
from openai import OpenAI
c = OpenAI(base_url="http://127.0.0.1:8080/v1", api_key="none")
print(c.chat.completions.create(
model="qwen3-0.6b",
messages=[{"role": "user", "content": "你好"}]
).choices[0].message.content)Cherry Studio / ChatBox / LobeChat:添加 OpenAI 类型提供商,
API 地址 http://127.0.0.1:8080,密钥填任意非空值,模型 ID qwen3-0.6b。
| 端点 | |
|---|---|
POST /v1/chat/completions |
支持 stream=true 的 SSE 流式 |
GET /v1/models |
|
GET /health |
支持 temperature top_p top_k max_tokens stream seed,
返回 usage 与 finish_reason,带 CORS 头。
Apple M4 / 16 GB,fp32:
| matmul 后端 | Prefill | Decode |
|---|---|---|
make BACKEND=naive 朴素三重循环 |
7 tok/s | 4.1 tok/s |
make BACKEND=gcd GCD 多线程 |
16 tok/s | 14.6 tok/s |
make Accelerate BLAS(默认) |
17~25 tok/s | 16~24 tok/s |
| 参照:PyTorch + MPS (GPU) | 521 tok/s | ~20 tok/s |
两个现象值得琢磨:
纯 CPU 的 C 在 decode 上打平 GPU。 每生成一个 token 都要把全部 2.2 GB 权重读一遍,瓶颈是内存带宽而非算力,GPU 的算力优势用不上。 M4 带宽约 120 GB/s,2.2 GB 读一遍 ≈ 18 ms,理论上限 ~55 tok/s——实测正是这个量级。
Prefill 差 20 倍。 本实现逐 token 处理,矩阵退化成向量; PyTorch 把整个提示词打包成大矩阵,GPU 才吃得饱。这正是 batching 存在的理由。
make ref # 用 PyTorch 导出参考数值(需 torch + transformers)
make test # 四组对拍| 标准 | |
|---|---|
| 五个算子 | 与 PyTorch 误差 ≤ 3e-6 |
| 单层前向(KV Cache / 因果掩码 / GQA) | 2 层 × 10 token,误差 ≤ 1.1e-5 |
| 完整 28 层 + lm_head | 预测的 token id 与 PyTorch 完全相同 |
| 字节级 BPE | 76 条用例编码/解码全部一致 |
测试里包含反证:故意用错误实现(相邻配对的 RoPE、取模的 GQA 映射、 不减最大值的 softmax),断言它们必须产生不同结果。 只验证"我写的是对的"不够,还要确认测试本身有区分能力。
这个模型有若干不符合"教科书 Transformer"直觉的地方。写错其中任何一条, 模型都不会崩溃,只会输出通顺但内容错乱的文本——所以才需要上面的逐层对拍。
| 坑 | |
|---|---|
n_heads × head_dim = 2048 ≠ hidden_size = 1024 |
q_proj 是升维 |
RoPE 用 rotate_half |
前后对半分 (i, i+64),不是相邻 (0,1) |
| QK-Norm 在 RoPE 之前 | 且长度是 head_dim(128),要逐头做 |
| GQA 映射是整除 | h / kv_mul,不是取模 |
| softmax 必须减最大值 | fp32 下 e^89 溢出成 inf |
| bf16→fp32 是左移 16 位 | 不是数值转换 |
| 特殊 token 要先于预切分处理 | 否则 `.< |
完整清单与推导见 DESIGN.md。
src/
json.c 极简 JSON(arena 分配 + 转义还原)
config.c 超参数与派生量
safetensors.c mmap + 张量目录解析
weights.c bf16 → fp32,加载进单块连续内存
ops.c rmsnorm / matmul / rope / softmax / silu
model.c RunState + 层前向 + 完整前向
tokenizer.c 字节级 BPE(预切分 + 字节映射 + 合并)
sample.c 贪心 / temperature / top-k / top-p
chat.c 对话模板 + 生成循环
server.c OpenAI 兼容 HTTP 服务
main.c CLI
tests/
dump_ref.py 从 PyTorch 导出参考数值
test_*.c 四组对拍
全程 -Wall -Wextra -Wshadow -Wconversion 零警告。
- 单请求串行。只有一份 KV Cache,请求排队。真实引擎靠 continuous batching, 需要多份 cache 与动态调度,是另一个量级的工程。
- 权重 fp32,2.2 GB。int8 量化可降到 ~600 MB,且因 decode 是带宽瓶颈, 速度还会明显提升——这是最值得做的下一步。
- Prefill 逐 token,没做批量 GEMM。
- 未实现 NFC 归一化;不支持工具调用、多模态、批量推理。
- 模型只有 0.6B,专业细节容易出错、多轮指代有时会乱。这是模型能力边界, 不是推理实现的问题——输出与官方 PyTorch 逐 token 一致。