模型算法:Transformer 应该怎样从 token 得到 logits。
从一句话到下一个 token:本科生版模型原理
大语言模型不是先在脑中写出整段答案,再把答案读出来。它每次只做一件事:**根据已经看到的 token,给词表中每一个候选 token 打一个分,选择下一个 token,然后把这个新 token 放回输入末尾,重复这个过程。**
先用一句话说清楚
大语言模型不是先在脑中写出整段答案,再把答案读出来。它每次只做一件事:根据已经看到的 token,给词表中每一个候选 token 打一个分,选择下一个 token,然后把这个新 token 放回输入末尾,重复这个过程。
例如输入“北京是中国的”,模型可能给“首都”很高分、给“苹果”很低分。选出“首都”后,输入变为“北京是中国的首都”,接着继续预测下一个 token。
flowchart LR
Text[用户文字] --> Tok[tokenizer]
Tok --> IDs[token id 序列]
IDs --> Forward[Transformer forward]
Forward --> Scores[词表分数 logits]
Scores --> Pick[top-k / sampling]
Pick --> Next[下一个 token]
Next --> IDs
Next --> Decode[detokenize]
Decode --> TextOut[输出文字]1. token:模型看到的不是汉字,也不是 UTF-8 字节
人看到“你好”,会把它理解成两个汉字。模型处理的基本单位是 token。token 可能是一个字、一个子词、一个空格开头的英文片段,甚至是某些字节组合。
| 人的输入 | tokenizer 可能输出 | 含义 |
|---|---|---|
hello | [token_hello] 或多个子词 token | 高频英文片段通常会被合并。 |
Once upon a time | 多个 token id | 空格和单词边界都可能影响 token。 |
你好 | 一个或多个 token id | 不是按“两个 Unicode 字符”固定切分。 |
ZCU106 | 一个或多个 token id | 模型自己的词表决定切法。 |
项目中,真实词表和 merge 规则来自 GGUF 的 tokenizer.* metadata。export_gguf_tokenizer_assets.py 将它们导出;prepare_ps_tokenizer_bundle.py 再压成 A53 可读的二进制表;Q25TOKRT 把这些表放进一块可在 DDR 中打开的内存映像。
BPE 是什么
BPE 可以理解为“先从最小片段开始,再按词表规则把常一起出现的相邻片段合并”。它不是按空格简单切词。
flowchart LR
Bytes[UTF-8 bytes] --> Initial[初始 byte token]
Initial --> Pair[查相邻 token 对]
Pair --> Merge{有更高优先级 merge?}
Merge -->|有| Replace[合并为新 token]
Replace --> Pair
Merge -->|没有| IDs[token id 序列]在 nl_uart_app.c 中,TEXT_PROMPT 有两条路径:打开 Q25TOKRT 后调用 q25_nl_tokenizer_bundle_encode_bpe_bounded;没有 image 时调用逐字节 fallback。后者能做 UTF-8 字节往返,但它不是模型真实 BPE 分词。
2. embedding:把 token id 换成数字向量
token id 只是一个整数,例如 12522。整数本身没有“北京”“首都”的含义。模型先在 embedding 表中查找这一行,把 id 变成一串浮点数,例如 896 个或 576 个数。这串数叫 hidden vector 或 hidden state。
可以把 embedding 表想成一本极厚的索引册:token id 是页码,页上的一行数是这个 token 在模型内部的起点表示。
| 概念 | 数学形状 | 在项目中的对应 |
|---|---|---|
| token id | 一个整数 | NL_PROMPT_IDS、TEXT_PROMPT_IDS |
| embedding table | vocab_size x hidden_size 矩阵 | GGUF token_embd.weight payload |
| hidden vector | 长度 hidden_size 的浮点数组 | token_embd_f32_lookup、token_embd_q8_0_lookup 的输出 |
3. Transformer layer:让每个 token 看上下文,再做非线性变换
一个 Transformer layer 有两条主要计算支路:attention 和 MLP。两条支路都带 residual connection,也就是“把原输入加回来”。模型会重复许多层;Qwen2.5-0.5B 的 host 路径按 24 层运行,SmolLM2 路径有自己的层数和尺寸。
flowchart TD
H0[上一层 hidden] --> N1[RMSNorm]
N1 --> QKV[Q K V projections]
QKV --> R[RoPE on Q K]
R --> A[causal attention]
A --> OP[attention output projection]
OP --> Add1[加回 H0]
Add1 --> N2[RMSNorm]
N2 --> Gate[FFN gate]
N2 --> Up[FFN up]
Gate --> Act[SiLU gate * up]
Up --> Act
Act --> Down[FFN down]
Down --> Add2[加回 Add1]
Add2 --> H1[下一层 hidden]RMSNorm:先把数字尺度拉回稳定范围
hidden vector 的数可能越传越大或越小。RMSNorm 用向量各元素平方的平均值来计算尺度,再用一组可训练权重调整每一维。它不是“把数据变成 0 到 1”,而是让不同 token 的向量长度处在比较稳定的范围。
项目中的 smollm2_qkv_rope_projection 和 smollm2_mlp_full_layer 都先读取 residual 和 norm weight,再做 RMSNorm。
Q、K、V 和 attention:谁该关注谁
把一句话里的每个位置当作一个同学:
- Q(Query):这个位置想问“我该从前面哪些位置获取信息?”
- K(Key):每个前面位置提供“我能被怎样匹配?”的标签。
- V(Value):每个前面位置真正可被取走的信息。
对当前 token 的 Q 与每个历史 K 做点积,得到 score;score 经 softmax 后成为权重;再按权重把所有 V 加起来,得到 context vector。
因果 mask 保证位置 t 只能看 0..t,看不到未来 token。否则训练时模型会偷看标准答案。
| attention 步骤 | 简化公式 | 项目 kernel |
|---|---|---|
| 生成 Q/K/V | Q=XWq, K=XWk, V=XWv | smollm2_qkv_rope_projection |
| 加位置信息 | 对 Q/K 成对旋转 | 同一 kernel 的 RoPE 部分 |
| 打分 | score=QK^T/sqrt(d) | attention fixture 与核心实验 |
| 归一化 | p=softmax(mask(score)) | smollm2_attention_core |
| 取上下文 | context=pV | smollm2_attention_core |
RoPE:位置信息如何进入 Q/K
注意力的点积本身只会比较数值,不知道 token 位于第几个位置。RoPE 把 Q/K 向量中成对的元素按位置旋转不同角度。这样“第 3 个位置的词”和“第 30 个位置的词”即使内容相同,Q/K 也会不同。
MLP:每个位置单独做更复杂的特征变换
attention 负责跨位置收集信息;MLP 主要在每个位置自己的 hidden vector 上工作。项目中可见的形式是:
gate = normed_hidden x W_gate
up = normed_hidden x W_up
mid = SiLU(gate) * up
down = mid x W_down
output = residual + down
smollm2_mlp_full_layer 直接体现了这条链:Q5_0 的 gate/up,Q5_K 的 down,最后把 down 输出加回 residual。
4. KV cache:生成第二个 token 时不重复算历史 K/V
生成文本是逐 token 循环。第 100 次预测时,前 99 个 token 的 K/V 如果每次都重新计算,速度会很慢。KV cache 保存历史 token 的 K/V;新 token 只计算自己的 Q/K/V,然后用新 Q 读取历史 K/V。
sequenceDiagram
participant Step1 as token 1
participant Cache as KV cache
participant Step2 as token 2
Step1->>Cache: 写入 K1, V1
Step2->>Cache: 读取 K1, V1
Step2->>Cache: 写入 K2, V2
Note over Step2,Cache: token 3 会读取 K1/V1/K2/V2仓库存在 smollm2_kv_cache_handoff_20260629 等 HLS/fixture 工作,用来描述或试验缓存交接;PS UART 应用尚未实现完整的 KV cache 生命周期。
5. lmhead:从 hidden vector 回到“下一个 token 的候选分数”
Transformer 最后一层给出一个 hidden vector,但用户需要文字。lm_head 是一个很大的矩阵:用 final hidden vector 和词表中每一行做点积,得到每个 token 的 logit。
flowchart LR
H[final hidden vector] --> Dot[与每个 lm_head 行做 dot]
Dot --> L[每个 token 的 logit]
L --> Sort[top-k 或 sampling]
Sort --> ID[选定 next token id]
ID --> Text[tokenizer 解码]logit 不是概率。softmax 把一组 logits 变成总和为 1 的概率;greedy decoding 直接选最大 logit;top-k 只保留最高的 K 个候选再按策略选一个。smollm2_lm_head_streaming_topk 和 q25rt_i8_lm_head_paged 都是“少量行/页扫描并保留 top-k”的硬件算子,而不是完整对话系统。
6. 量化:用更少的位存权重
原始模型权重通常用 F32 浮点数,一个数占 4 字节。模型很大,放进 DDR、读取到 PL、做矩阵乘法都会受内存容量和带宽限制。量化把一组权重存成较少的整数位和少量尺度参数。
| 格式 | 一个 block 覆盖 | block 字节 | 直觉 |
|---|---|---|---|
| F32 | 1 个数 | 4 | 最直接、体积最大。 |
| Q8_0 | 32 个数 | 34 | 32 个 int8 加一个尺度。 |
| Q5_0 | 32 个数 | 22 | 每个数约 5 bit,加尺度和高位打包。 |
| Q5_K | 256 个数 | 176 | K-quant,分块尺度/最小值更复杂。 |
| Q4_K | 256 个数 | 144 | 更紧凑,精度与解码复杂度需要同时考虑。 |
| Q6_K | 256 个数 | 210 | 约 6 bit 的 K-quant。 |
量化不是简单的“把浮点数强行取整”。正确计算必须按 GGML block 布局读取 scale、min、低位和高位,再反量化或直接进行量化点积。项目的 layer0_q6k_down_projection 读取 Q6_K block;smollm2_mlp_full_layer 解码 Q5_0/Q5_K;Host golden 负责提供同一输入下的参考数值。
7. 本项目的完整映射
| 模型概念 | 你可以先看哪个模块 | 该模块给你的答案 |
|---|---|---|
| token 和 BPE | modules/001_model_assets_and_tokenizer | 词表、merge、Q25TOKRT 和 C API 如何工作。 |
| GGUF 与量化 tensor | modules/002_host_reference_pipeline | 原始权重如何导出、反量化和生成 golden。 |
| Transformer 的硬件子算子 | modules/003_hls_pl_kernels | QKV、attention、MLP、lm_head 各自的 DDR/AXI 接口。 |
| 板上文字命令 | modules/004_ps_uart_runtime | UART 命令怎样进入 tokenizer 或 P4 stub。 |
| 真正上板的流程 | modules/005_board_execution | ELF、串口、transcript、归档如何连起来。 |
| 如何判断一个文件表示什么 | modules/006_artifact_validation | manifest、hash、fixture 和日志如何阅读。 |
8. 读这个项目时最重要的区分
Host 参考:PC 上用同一份量化数据算出参考数值。
HLS 单算子:硬件只实现或验证算法中的一个片段。
PS/UART 命令:板上应用目前负责命令和 tokenizer,不等于完整模型 forward。
板级运行:ELF 和串口能运行,说明板上软件链路存在;是否运行完整模型要看它实际调用了哪些模块。
把这五件事分开,就不会把“某个 kernel 有 CSim”或“UART 能回复文本”误读为“Qwen2.5 完整模型已经在板上生成答案”。