Skip to content

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 参数。计算:

  1. FP32 需要多少 GB?
  2. Q4(平均 4.5 bit/weight)需要多少 GB?
  3. 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/weight

2.28 bit/weight 是"平均"值——实际上非对称量化使得路由专家约 2 bit,其他组件更高。


练习 3:Block 布局分析

题目block_q4_Kqs[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)。为什么不统一用同一精度?

参考答案(先自己尝试,再展开查看)
  1. 路由专家占空间最大:256 个专家 × (gate + up + down) 是模型参数的主体。降低它们的精度可以大幅减少总大小
  2. 每个 token 只用 6/256 专家:即使单个专家有量化误差,6 个专家加权平均后误差被稀释
  3. 共享专家每次都激活:它对每个 token 都有贡献,量化误差会累积,所以需要更高精度
  4. 注意力投影决定模型质量:query/key/value 的精度直接影响注意力计算,不能太低
  5. 实际测试验证:这种非对称方案在编码代理测试中表现良好

今日学习检查清单

  • 能手动量化/反量化一组浮点数
  • 理解 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 上下文下:

  1. 原始 KV cache 的内存(所有层)
  2. 压缩 KV 的内存(ratio=2 和 ratio=4)
  3. 总 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 共享专家。计算:

  1. 总共有多少路由专家参数?(假设每个专家 3 个 2048×4096 矩阵)
  2. 每个 token 激活多少路由专家参数?
  3. 激活比例是多少?
参考答案(先自己尝试,再展开查看)
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 更适合推理而非训练?