Appearance
Part 3: 练习
1. 公共 API 与权重结构
练习 1:opaque type 设计
题目:假设你要设计一个 "文件读取器" API,要求:
- 用户可以打开文件、读取一行、关闭
- 不暴露内部缓冲区细节
请写出头文件声明。
参考答案(先自己尝试,再展开查看)
c
// filereader.h
typedef struct filereader filereader;
filereader *filereader_open(const char *path);
const char *filereader_readline(filereader *fr); // 返回 NULL 表示 EOF
void filereader_close(filereader *fr);内部实现可以自由使用任何缓冲策略(mmap、stdio、自定义缓冲),调用者完全不关心。
练习 2:权重绑定追踪
题目:weights_bind 中,blk.3.attn_q_a.weight 这个张量名如何被找到?追踪查找过程。
参考答案(先自己尝试,再展开查看)
1. required_tensorf(m, "blk.%u.attn_q_a.weight", 3)
→ 格式化为 "blk.3.attn_q_a.weight"
2. required_tensor(m, "blk.3.attn_q_a.weight")
→ 在 m->tensors[0..n_tensors-1] 中线性搜索
→ 比较 ds4_str_eq(tensor.name, "blk.3.attn_q_a.weight")
3. 找到后返回 ds4_tensor 指针
→ 该指针的 abs_offset 指向 mmap 内的数据位置
4. 使用时:tensor_data(model, tensor) → m->map + tensor->abs_offset线性搜索意味着 O(n) 查找,但只在加载时执行一次,不影响推理性能。
练习 3:理解结构体大小
题目:估算 ds4_engine 结构体占多少内存(不算指向的数据)。
参考答案(先自己尝试,再展开查看)
ds4_model: fd(4) + padding(4) + map(8) + size(8) + version(4) + ...
≈ ~100 字节
ds4_vocab: token 指针(8) + n_vocab(4) + 7 个 int(28) + 2 个 hash table(48)
≈ ~100 字节
ds4_weights: token_embd 指针(8) + 5 个全局指针(40) + 43 层 × 37 指针(12716)
≈ ~12,800 字节
ds4_engine 总计约 ~13 KB(不含指向的实际数据)实际数据(模型权重 81GB)全在 mmap 中,结构体本身很小。
练习 4:LoRA 参数计算
题目:标准 Q 投影需要 4096 × (64 × 512) = 4096 × 32768 个参数。LoRA 分解为 A: [4096, 1024] 和 B: [1024, 32768]。计算参数量节省了多少?
参考答案(先自己尝试,再展开查看)
标准:4096 × 32768 = 134,217,728 (128M) 参数
LoRA:4096 × 1024 + 1024 × 32768 = 4,194,304 + 33,554,432 = 37,748,736 (36M) 参数
节省:(128M - 36M) / 128M = 71.9%LoRA 把参数量减少了约 72%。代价是多一次矩阵乘法,但对于带宽受限的推理(模型大、内存慢),减少内存访问通常更划算。
今日学习检查清单
- 能解释 opaque type 的目的和实现方式
- 能画出 ds4_engine / ds4_session 的类型层次
- 理解 weights_bind 的工作原理(按名称查找张量)
- 能列出 Transformer 层的主要权重及其作用
- 理解超连接(Hyper-Connection)与标准残差的区别
- 能计算 LoRA 分解的参数量节省
- 理解函数指针回调模式(emit/done)
延伸挑战
挑战 1(中级):画出完整的权重绑定图
weights_bind 按名称查找张量并绑定到对应字段。列出 DeepSeek V4 Flash 单层所有需要绑定的张量名称(如 blk.0.attn_q.weight),画出名称 → 字段的完整映射表。
挑战 2(高级):估算模型参数量
根据 ds4.h 中的架构常量和本主题学到的权重结构,手动计算 DeepSeek V4 Flash 的总参数量。与官方公布的 284B 对比,解释差异来源。提示:注意 LoRA 的降维和 MoE 的稀疏结构。
2. 量化与矩阵运算
练习 1:手动量化
题目:把以下浮点数量化为 8-bit 整数(对称量化):
值: [-0.5, 0.3, -1.0, 0.8]步骤:找最大绝对值 → 计算缩放因子 → 量化为 int8
参考答案(先自己尝试,再展开查看)
最大绝对值: 1.0
缩放因子: 1.0 / 127 = 0.00787
量化:
-0.5 / 0.00787 = -63.5 → -64
0.3 / 0.00787 = 38.1 → 38
-1.0 / 0.00787 = -127.0 → -127
0.8 / 0.00787 = 101.7 → 102
反量化验证:
-64 × 0.00787 = -0.504
38 × 0.00787 = 0.299
-127 × 0.00787 = -0.999
102 × 0.00787 = 0.803
最大误差: |0.8 - 0.803| = 0.003(0.375%)练习 2:计算模型大小
题目:DeepSeek V4 Flash 有 284B 参数。计算:
- FP32 需要多少 GB?
- Q4(平均 4.5 bit/weight)需要多少 GB?
- ds4.c 的 Q2 量化(81GB 文件),平均每权重多少 bit?
参考答案(先自己尝试,再展开查看)
1. FP32: 284B × 4 bytes = 1,136 GB ≈ 1.1 TB
2. Q4: 284B × 4.5/8 bytes = 159.75 GB ≈ 160 GB
3. Q2: 81 GB / 284B = 0.285 bytes = 2.28 bit/weight2.28 bit/weight 是"平均"值——实际上非对称量化使得路由专家约 2 bit,其他组件更高。
练习 3:Block 布局分析
题目:block_q4_K 的 qs[128] 数组存储 256 个 4-bit 值。请解释如何从一个字节中提取两个 4-bit 值。
参考答案(先自己尝试,再展开查看)
c
uint8_t byte = qs[i];
uint8_t low4 = byte & 0x0F; // 低 4 位: 值 0-15
uint8_t high4 = (byte >> 4) & 0x0F; // 高 4 位: 值 0-15
// 反量化:
// value = d * (scale * qs_value - dmin * offset)128 字节 × 8 bit = 1024 bit,256 个值 × 4 bit = 1024 bit ✓
练习 4:理解非对称量化策略
题目:ds4.c 对路由专家用 IQ2_XXS(~2 bit),对共享专家用 Q4_K(~4.5 bit)。为什么不统一用同一精度?
参考答案(先自己尝试,再展开查看)
- 路由专家占空间最大:256 个专家 × (gate + up + down) 是模型参数的主体。降低它们的精度可以大幅减少总大小
- 每个 token 只用 6/256 专家:即使单个专家有量化误差,6 个专家加权平均后误差被稀释
- 共享专家每次都激活:它对每个 token 都有贡献,量化误差会累积,所以需要更高精度
- 注意力投影决定模型质量:query/key/value 的精度直接影响注意力计算,不能太低
- 实际测试验证:这种非对称方案在编码代理测试中表现良好
今日学习检查清单
- 能手动量化/反量化一组浮点数
- 理解 Q2_K 和 Q4_K block 的字节布局
- 能从字节中提取 2-bit 和 4-bit 值
- 理解查表法在量化解码中的作用
- 能解释非对称量化策略(为什么不同组件用不同精度)
- 理解 matvec 操作在推理中的核心地位
延伸挑战
挑战 1(中级):量化误差实验
随机生成 1000 个 [-1, 1] 的 float 值。分别用 Q4_K 和 Q8_K 格式量化再反量化,计算 MAE(平均绝对误差)和最大误差。对比两种格式的精度差异。
挑战 2(高级):优化 matvec 的内存访问
阅读 matvec_f16 的实现,分析它的缓存访问模式。提出一个优化方案:如何利用分块(tiling)提高缓存命中率?不需要实现,画出分块后的访问模式图即可。
3. 注意力机制
练习 1:手动计算注意力
题目:简化模型:2 个头,2 个 KV 行,head_dim = 2。
Q[0] = [1.0, 0.0] Q[1] = [0.0, 1.0]
KV[0] = [1.0, 0.0] KV[1] = [0.0, 1.0]计算 kq_scale = 1/√2 ≈ 0.707。手动计算头 0 的注意力输出。
参考答案(先自己尝试,再展开查看)
头 0: qh = [1.0, 0.0]
scores:
score[0] = dot([1,0], [1,0]) × 0.707 = 1.0 × 0.707 = 0.707
score[1] = dot([1,0], [0,1]) × 0.707 = 0.0 × 0.707 = 0.0
max_score = 0.707
exp(0.707 - 0.707) = 1.0
exp(0.0 - 0.707) = 0.493
denom = 1.0 + 0.493 = 1.493
weights: [1.0/1.493, 0.493/1.493] = [0.670, 0.330]
out[0] = 0.670 × [1,0] + 0.330 × [0,1] = [0.670, 0.330]头 0 主要关注 KV[0](相似度最高的行)。
练习 2:KV Cache 大小计算
题目:DeepSeek V4 Flash 的配置:43 层,head_dim = 512,SWA = 128,压缩比 ratio=2 的层有 30 个,ratio=4 的层有 10 个,其余无压缩。
计算在 32K 上下文下:
- 原始 KV cache 的内存(所有层)
- 压缩 KV 的内存(ratio=2 和 ratio=4)
- 总 KV cache 大小
参考答案(先自己尝试,再展开查看)
1. 原始 KV(每层最多 128 行):
每层: 128 × 512 × 4 bytes = 256 KB
43 层: 43 × 256 KB = 11 MB
2. 压缩 KV:
ratio=2 层: 32000/2 + 2 = 16002 行 × 512 × 4 = 32 MB/层
30 层 × 32 MB = 960 MB
ratio=4 层: 32000/4 + 2 = 8002 行 × 512 × 4 = 16 MB/层
10 层 × 16 MB = 160 MB
3. 总计: 11 MB + 960 MB + 160 MB ≈ 1.1 GB
对比: 如果不压缩 (43 × 32K × 512 × 4 = 2.7 GB per head × 64 heads...)
→ 压缩后的 KV cache 极其紧凑练习 3:滑动窗口模拟
题目:kv_cache_push_raw 在窗口满时用 memmove 滑动。假设 cap_raw=3, head_dim=2:
初始: [A, B, C] n_raw=3 (满)
push D: memmove → [B, C, ?] 然后 → [B, C, D]
push E: memmove → [C, D, ?] 然后 → [C, D, E]为什么用 memmove 而不是 memcpy?
参考答案(先自己尝试,再展开查看)
memmove 保证源和目标区域重叠时也能正确工作。这里 raw_kv 既是源也是目标(向前移动一行),内存区域重叠。memcpy 在重叠情况下是未定义行为——可能正确,也可能产生错误结果。
练习 4:Sink 的作用
题目:attn_sinks 是一个可学习的 logit 值,参与 softmax 但不贡献 value。请解释:如果没有 sink,当所有 score 都很低时会发生什么?
参考答案(先自己尝试,再展开查看)
没有 sink 时:
所有 score 都很小(比如 -10, -10, -10)
exp(-10) ≈ 0.0000454
denom = 3 × 0.0000454 = 0.000136
weight[0] = 0.0000454 / 0.000136 ≈ 0.333
→ 均匀分布,模型被迫平均关注所有位置有 sink 时:
sink = 5.0 (可学习,较高)
scores = [-10, -10, -10]
max = 5.0
exp(5.0-5.0) = 1.0 (sink)
exp(-10-5.0) = 3.06e-7 (其他位置)
denom ≈ 1.0 + 3 × 3.06e-7 ≈ 1.0
→ sink 占了几乎 100% 的权重,其他位置被忽略
→ out ≈ 0(sink 不贡献 value)Sink 让模型学会"不关注任何位置"的能力。
今日学习检查清单
- 能手动计算简单的注意力分数和输出
- 理解 softmax 数值稳定性的实现
- 能解释 KV Cache 的必要性和工作方式
- 理解滑动窗口注意力的滑动机制
- 能解释 Attention Sink 的作用
- 理解 MLA 压缩如何减少 KV cache 大小
- 能描述混合注意力(raw + compressed)的工作方式
- 理解多维数组在一维内存中的索引方式
延伸挑战
挑战 1(中级):计算不同上下文长度的 KV Cache 开销
在 32K、128K、512K、1M 四种上下文长度下,分别计算原始 KV + 压缩 KV 的总内存(参考练习 2 的方法)。画出"上下文长度 vs 内存"的曲线,找出消费级硬件(128GB 统一内存)能支持的最大上下文长度。
挑战 2(高级):对比 MQA 和 MHA 的 tradeoff
DeepSeek 用 64:1 的 Multi-Query Attention(64 个 Q 头,1 组 KV)。如果改为 Grouped-Query Attention(64 个 Q 头,8 组 KV),KV Cache 增加多少?注意力质量理论上如何变化?在 ds4.c 中找到需要修改的常量。
4. MoE 混合专家
练习 1:Top-K 选择模拟
题目:8 个专家的分数:[0.3, 0.8, 0.1, 0.5, 0.9, 0.2, 0.7, 0.4]。手动模拟 topk_desc 选择 top-3。
参考答案(先自己尝试,再展开查看)
扫描 0.3: idx = [0, -1, -1]
扫描 0.8: idx = [1, 0, -1] (0.8 > 0.3)
扫描 0.1: idx = [1, 0, -1] (0.1 < 0.3,跳过)
扫描 0.5: idx = [1, 0, 3] (0.5 > 0.3)
扫描 0.9: idx = [4, 1, 0] (0.9 > 0.8 > 0.5 > 0.3,把 0.3 挤出去)
扫描 0.2: idx = [4, 1, 0] (0.2 < 0.3,跳过)
扫描 0.7: idx = [4, 1, 6] (0.7 > 0.5 > 0.3)
扫描 0.4: idx = [4, 1, 6] (0.4 < 0.5,跳过)
结果: selected = [4, 1, 6](分数 0.9, 0.8, 0.7)练习 2:专家权重计算
题目:基于练习 1 的结果,计算专家权重:
- probs = [0.3, 0.8, 0.1, 0.5, 0.9, 0.2, 0.7, 0.4]
- selected = [4, 1, 6]
- DS4_EXPERT_WEIGHT_SCALE = 1.5
参考答案(先自己尝试,再展开查看)
未归一化权重:
w[0] = probs[4] = 0.9
w[1] = probs[1] = 0.8
w[2] = probs[6] = 0.7
sum = 0.9 + 0.8 + 0.7 = 2.4
归一化 × scale:
w[0] = (0.9 / 2.4) × 1.5 = 0.5625
w[1] = (0.8 / 2.4) × 1.5 = 0.5000
w[2] = (0.7 / 2.4) × 1.5 = 0.4375
总和 = 1.5(等于 DS4_EXPERT_WEIGHT_SCALE)专家 4 获得最高权重(0.5625),因为它的路由分数最高。
练习 3:SwiGLU 计算
题目:假设 gate = [2.0, -1.0, 0.5], up = [1.0, 1.0, 1.0]。计算 SwiGLU 输出。
提示:sigmoid(x) = 1/(1+exp(-x)), silu(x) = x × sigmoid(x)
参考答案(先自己尝试,再展开查看)
gate[0] = 2.0: silu(2.0) = 2.0 × sigmoid(2.0) = 2.0 × 0.881 = 1.762
out[0] = 1.762 × 1.0 = 1.762
gate[1] = -1.0: silu(-1.0) = -1.0 × sigmoid(-1.0) = -1.0 × 0.269 = -0.269
out[1] = -0.269 × 1.0 = -0.269
gate[2] = 0.5: silu(0.5) = 0.5 × sigmoid(0.5) = 0.5 × 0.622 = 0.311
out[2] = 0.311 × 1.0 = 0.311
输出: [1.762, -0.269, 0.311]SiLU 对负值产生接近零的输出(自门控),对正值接近线性。
练习 4:MoE 效率计算
题目:284B 参数模型,每个 token 激活 6/256 路由专家 + 1 共享专家。计算:
- 总共有多少路由专家参数?(假设每个专家 3 个 2048×4096 矩阵)
- 每个 token 激活多少路由专家参数?
- 激活比例是多少?
参考答案(先自己尝试,再展开查看)
1. 每个路由专家参数: 3 × 2048 × 4096 = 25,165,824 (25M)
256 个路由专家: 256 × 25M = 6.4B
2. 每次激活: 6 × 25M = 150M + 1 × 25M(共享) = 175M
3. 路由专家激活比: 150M / 6.4B = 2.34%
如果模型剩余 ~277.6B 参数是注意力/嵌入/其他,
总激活: ~175M + ~277.6B = ~277.8B
实际节省不多(因为非专家参数每次都激活)
但关键区别:6.4B 路由专家权重是推理的带宽瓶颈
→ 只需从内存读取 150M 而非 6.4B → 大幅减少内存带宽今日学习检查清单
- 能手动模拟 top-k 选择过程
- 理解哈希路由和 top-k 路由的区别
- 能计算专家权重(归一化 × scale)
- 能手动计算 SwiGLU 输出
- 理解为什么用 √(softplus(x)) 而不是 softmax 做路由
- 能计算 MoE 的激活参数比例
- 理解共享专家和路由专家的分工
延伸挑战
挑战 1(中级):追踪 token 的专家选择
在 ds4.c 的 MoE 路由代码中加日志,记录一个真实 token 在 43 层中分别选择了哪些专家。观察:是否有某些专家被频繁选择?前 3 层(Hash 路由)和后续层(Top-K 路由)的专家分布有何不同?
挑战 2(高级):MoE vs Dense 的计算量对比
假设把 256 个 MoE 专家合并成一个等价的 Dense FFN(隐藏维度 = 256 × 2048),计算单层前向的计算量(FLOPs)对比。为什么 MoE 更适合推理而非训练?