Appearance
Part 3: 模型架构
权重结构、量化、Attention、MoE
1. 公共 API 与权重结构
284B 参数是什么、存在哪里、怎么找到它们?今天通过 ds4.h 公共 API 和 weights_bind 绑定过程,理解 Transformer 每层的完整权重组成——后续所有计算都是对这些权重的矩阵运算。
C 知识点
1. opaque type(不透明类型)
ds4.h 只暴露类型名,不暴露内部定义:
c
// ds4.h(公共头文件)
typedef struct ds4_engine ds4_engine; // 前向声明
typedef struct ds4_session ds4_session;c
// ds4.c(内部定义)
struct ds4_engine {
ds4_model model;
ds4_model mtp_model;
ds4_vocab vocab;
ds4_weights weights;
ds4_mtp_weights mtp_weights;
ds4_backend backend; // DS4_BACKEND_METAL / CUDA / CPU
int mtp_draft_tokens;
float mtp_margin;
bool quality;
bool metal_ready; // GPU 后端已初始化
bool mtp_ready;
};调用者只能通过指针操作,不能访问字段。修改 ds4_engine 内部结构不需要重新编译 ds4_server.c。
2. 结构体嵌套设计
ds4 的类型层次:
3. Session Rewrite API — 检查点恢复
工具调用后,模型生成的 DSML 文本可能与"规范"提示不完全一致(格式等价但字节不同)。此时 session 的检查点需要"重写"来匹配下一轮的规范提示。
c
// 重写结果枚举
typedef enum {
DS4_SESSION_REWRITE_ERROR = -1, // 错误
DS4_SESSION_REWRITE_OK = 0, // 成功
DS4_SESSION_REWRITE_REBUILD_NEEDED = 1, // 需要从头重建
} ds4_session_rewrite_result;
// 判断是否需要完整重建(Metal 图中有不可就地替换的 KV 行)
bool ds4_session_rewrite_requires_rebuild(int live_len, int canonical_len, int common);
// 从公共前缀开始重写 session
ds4_session_rewrite_result ds4_session_rewrite_from_common(
ds4_session *s, const ds4_tokens *prompt, int common,
char *err, size_t errlen);关键洞察:session 检查点不仅是 token 向量,Metal 图中还有 raw SWA 行、压缩 KV 行、indexer 行等。替换 live tail 后面的内容需要恢复整个图前沿,因此不能简单就地修改。
渲染文本前缀匹配
KV cache 查找从基于 token ID 的 SHA1 改为渲染文本字节的 SHA1:
- 模型生成一个 token 的解码文本,客户端可能在下一轮把它作为两个 prompt token 发回(BPE 边界差异)
- 渲染字节前缀匹配可以处理这种情况:命中后用 checkpoint 的精确 token 作为前缀,只对新增后缀重新分词
- cache 文件名
<sha1>.kv直接是渲染文本的 SHA1
对齐前沿检查点
持续保存使用绝对对齐位置(如每 ~10k token),不相对于上一次保存点。这防止早期 cold checkpoint 偏移整个保存计划,确保长生成过程中有均匀分布的重启点。
Cold checkpoint 的裁剪锚定在 chat task boundary(最后一个 <User> token 之后、第一个 <Assistant> token 之前),保留稳定的用户角色脚手架,最大化跨会话复用。无 chat anchor 时回退到 trim + 对齐启发式。
4. Session 快照 API
增量基准测试(ds4-bench)需要在不同 context frontier 处保存和恢复 session 状态:
c
// 进度回调
typedef void (*ds4_session_progress_fn)(void *ud, int progress);
void ds4_session_set_progress(ds4_session *s, ds4_session_progress_fn fn, void *ud);
// 快照保存/加载/释放
void *ds4_session_save_snapshot(ds4_session *s);
int ds4_session_load_snapshot(ds4_session *s, void *snapshot);
void ds4_session_snapshot_free(void *snapshot);快照保存 session 在某个 token 位置的完整状态(KV cache 行、logits 等),恢复后可以继续推理。ds4-bench 利用这个 API 在每个 frontier 处保存快照、测 decode 速度、再恢复快照继续 prefill,无需从头重新加载。
5. 独立分词调试
c
// 不加载推理引擎,只打开模型文件的分词器部分
int ds4_dump_text_tokenization(const char *model_path, const char *text, FILE *fp);这个函数调用 model_open(..., false, false)——prefetch_cpu=false 意味着不预取 CPU 映射,因为只需要分词器数据,不需要遍历 81GB 的张量数据。
6. 函数指针回调
c
typedef void (*ds4_token_emit_fn)(void *ud, int token);
typedef void (*ds4_generation_done_fn)(void *ud);生成循环中,每产生一个 token 就调用 emit,结束时调用 done。void *ud 是用户数据指针,用于在回调中传递上下文。这是 C 语言实现"观察者模式"的常见方式。
7. required_tensor — 字符串匹配绑定
c
static void weights_bind(ds4_weights *w, const ds4_model *m) {
w->token_embd = required_tensor(m, "token_embd.weight");
w->output = required_tensor(m, "output.weight");
for (uint32_t il = 0; il < DS4_N_LAYER; il++) {
ds4_layer_weights *l = &w->layer[il];
l->attn_norm = required_tensorf(m, "blk.%u.attn_norm.weight", il);
l->attn_q_a = required_tensorf(m, "blk.%u.attn_q_a.weight", il);
// ... 30+ 个权重张量
}
}required_tensor 在张量目录中按名称查找。如果找不到就报错退出——再次体现"早失败"。required_tensorf 是格式化版本,用 sprintf 构造名称如 "blk.0.attn_norm.weight"。
sscanf %n 锚点防止部分匹配
deepseek4-quantize.c 中的 parse_expert_tensor 用 sscanf 匹配 MoE 专家张量名,但旧写法会误匹配:
c
// 旧:会匹配 blk.3.ffn_gate_exps.weight_bias 等不相关张量
sscanf(name, "blk.%d.ffn_%15[^_]_exps.weight", &layer, type)
// 新:用 %n 捕获解析位置,验证消耗了整个字符串
int rest = 0;
sscanf(name, "blk.%d.ffn_%15[^_]_exps.weight%n", &layer, type, &rest);
if (rest != strlen(name)) return false; // 不是精确匹配%n 是 sscanf 的特殊格式符,写入已消耗的字符数而不消耗输入。rest == strlen(name) 保证没有多余后缀。
LLM 知识点
1. Transformer 权重组成
一个 Transformer 层(ds4_layer_weights)的权重可以分组:
注意力子系统:
| 权重 | 形状 | 作用 |
|---|---|---|
attn_norm | [4096] | RMSNorm 缩放 |
attn_q_a | [4096, 1024] | Query LoRA 压缩 |
attn_q_a_norm | [1024] | 压缩后归一化 |
attn_q_b | [1024, 64×512] | Query LoRA 解压 |
attn_kv | [4096, 512] | Key-Value 投影 |
attn_sinks | [64] | 注意力汇聚点 |
attn_output_a/b | [64×512, 1024], [1024, 4096] | 输出投影(LoRA) |
MoE FFN 子系统:
| 权重 | 形状 | 作用 |
|---|---|---|
ffn_norm | [4096] | FFN RMSNorm |
ffn_gate_inp | [4096, 256] | 专家路由器 |
ffn_gate/up/down_exps | [256, 2048, 4096] | 256 个路由专家 |
ffn_gate/up/down_shexp | [2048, 4096] | 1 个共享专家 |
2. 超连接(Hyper-Connection)
ds4.c 的一个独特设计——用可学习的门控代替标准残差连接:
c
// 标准残差:out = x + sublayer(x)
// 超连接:out = HC_post(x, sublayer(HC_pre(x)))
// HC_pre: 计算 HC 维度 = DS4_N_EMBD × DS4_N_HC (4096 × 4 = 16384)
hc_from_plain_embedding(cur, plain, DS4_N_EMBD, DS4_N_HC);
// HC_post: 学习每个流的权重
// fn × scale × sigmoid(x) + base → 门控权重DS4_N_HC = 4 意味着每个位置有 4 个并行流(streams),通过可学习门控混合。
3. LoRA 低秩分解
Query 和 Output 投影使用 LoRA(Low-Rank Adaptation)分解:
秩 r = 1024(DS4_N_LORA_Q),大幅减少参数量。虽然 LoRA 通常用于微调,DeepSeek V4 把它作为架构的一部分来压缩 KV cache。
4. 重要性矩阵(imatrix)校准
量化时不同权重对模型输出影响不同。imatrix(importance matrix)通过在校准数据上运行推理,记录每个权重的贡献度,指导量化器为重要权重保留更高精度:
校准数据(prompts)→ 前向推理 → 记录每层激活 → 统计重要性 → 量化时参考ds4 的校准数据集位于 gguf-tools/imatrix/dataset/,已扩展覆盖多语言编程、工具调用、算法回忆、Bash 脚本、多语言翻译、语言/散文任务、代码合成、agent transcript 回放、trace 诊断、长上下文检索与比较等场景,并包含 ds4-eval 评测题(不含答案键)作为校准输入,确保量化后的模型在各种任务上保持质量。
2. 量化与矩阵运算
284B 参数的 FP16 模型需要 568GB 内存,消费级硬件根本装不下。量化把每个参数从 16 bit 压缩到 2-4 bit,让 81GB 的模型在 128GB MacBook 上可运行。今天学习位操作提取压缩值、block 布局和矩阵-向量乘法。
C 知识点
1. 位操作
量化核心是把 32 位浮点数压缩为 2-8 位整数。这需要大量位操作:
提取 2-bit 值:
c
// 从 1 字节中提取 4 个 2-bit 值
uint8_t byte = qs[i]; // 8 位 = 4 个 2-bit 值
uint8_t v0 = byte & 0x03; // 最低 2 位
uint8_t v1 = (byte >> 2) & 0x03; // 次低 2 位
uint8_t v2 = (byte >> 4) & 0x03; // 次高 2 位
uint8_t v3 = (byte >> 6) & 0x03; // 最高 2 位Q8_K 量化(8-bit):
c
// 把 float 数组量化为 int8
static void ds4_quantize_row_q8_K(const float *x, block_q8_K *y, int64_t n) {
for (int j = 0; j < n; j += QK_K) {
float amax = 0.0f;
for (int i = 0; i < QK_K; i++) {
float ax = fabsf(x[j + i]);
if (ax > amax) amax = ax;
}
float scale = amax / 127.0f;
y->d = scale;
for (int i = 0; i < QK_K; i++)
y->qs[i] = (int8_t)roundf(x[j + i] / scale);
}
}2. 查表法(Lookup Table)
IQ2_XXS 量化使用预计算表来解码 2-bit 编码:
c
// 预计算的 IQ2_XXS 解码表(来自 llama.cpp/GGML)
static const uint64_t iq2xxs_grid[256] = { ... };
// 从 2-bit 索引查表得到 8 个 int8 值
const uint64_t grid = iq2xxs_grid[qs_byte];查表法是经典的性能优化:用内存(表)换取计算(乘法/移位),特别适合量化解码这种简单但高频的操作。
3. Block 布局
量化数据以 block 为单位存储,每个 block 包含 256 个权重值:
Q2_K block (84 字节):
┌─────────────────────────────────────┐
│ scales[16] │ 16 组缩放因子 (1B) │ 16 字节
│ qs[64] │ 256 个 2-bit 值 │ 64 字节
│ d (uint16) │ 全局缩放 │ 2 字节
│ dmin (uint16) │ 全局最小值偏移 │ 2 字节
└─────────────────────────────────────┘
每权重: 84 × 8 / 256 = 2.625 bitQ4_K block (144 字节):
┌─────────────────────────────────────┐
│ d (uint16) │ 全局缩放 │ 2 字节
│ dmin (uint16) │ 全局最小值偏移 │ 2 字节
│ scales[12] │ 12 组缩放+偏移 │ 12 字节
│ qs[128] │ 256 个 4-bit 值 │ 128 字节
└─────────────────────────────────────┘
每权重: 144 × 8 / 256 = 4.5 bit4. 定点点积
量化推理的核心操作是"量化向量的点积":
c
// Q8_K × Q2_K 点积(简化版)
float dot_q2_k_q8_k(const block_q2_K *a, const block_q8_K *b) {
float sum = 0.0f;
// 解量化 a 的值: value = d * (scale_i * qs_value + dmin * offset_i)
// 乘以 b 的值: value_b = b->d * b->qs[i]
// 累加
return sum;
}实际实现考虑了分组缩放、偏移、以及 SIMD 优化(ARM NEON)。
LLM 知识点
1. 模型量化原理
训练时使用 FP32(32-bit 浮点),推理时可以用更少的位数:
FP32 → 每个权重 32 bit → 284B 模型 ≈ 1.1 TB
Q8 → 每个权重 8 bit → 284B 模型 ≈ 284 GB
Q4 → 每个权重 4 bit → 284B 模型 ≈ 142 GB
Q2 → 每个权重 ~2 bit → 284B 模型 ≈ 81 GB2. 非对称量化
DeepSeek V4 Flash 使用"非对称量化"——不同组件用不同精度:
为什么可以这样?路由专家占模型空间的绝大部分,但每个 token 只使用 6/256。即使单个专家的精度较低,6 个专家的混合效果仍然很好。
3. 量化对推理的影响
量化的好处:
- 内存减少:81GB(Q2)vs 284GB(FP32)
- 带宽减少:读写更快,推理速度提升
- 可运行:128GB MacBook 可以运行 Q2 模型
量化的代价:
- 精度损失:模型输出质量略有下降
- 计算开销:需要量化解码(查表、缩放)
- 但实际影响很小:2-bit 量化的 DeepSeek V4 Flash 仍能通过编码代理测试
4. 矩阵-向量乘法(matvec)
推理中最频繁的操作:一个向量乘以一个权重矩阵。
y = x @ W
输入向量 x: [4096] (1D)
权重矩阵 W: [4096, 2048] (2D,可能被量化)
输出向量 y: [2048] (1D)在 ds4.c 中有多个变体:
matvec_f16: FP16 权重matvec_q8_0: Q8_0 权重matvec_q2_k: Q2_K 权重matvec_iq2_xxs: IQ2_XXS 权重(用于路由专家)matvec_q8_0_pair: 同时计算 gate 和 up(共享输入)
5. GGUF 量化工具链与 imatrix
gguf-tools/ 目录提供独立的 HF-to-GGUF 量化器 deepseek4-quantize.c(1885 行),不依赖 GGML/llama.cpp:
- 支持 imatrix 感知的路由专家量化,内置 Q8_0/Q4_K/Q2_K/IQ2_XXS 量化器
- imatrix(重要性矩阵):通过校准数据集收集各张量的 token 频率,量化时对高频张量分配更高精度
- 新增
quality-testing/质量测试框架,含评分脚本(score_official)和校准 prompt 语料库 - q2-imatrix 版本:用 imatrix 调优的 q2 权重,logits 误差接近 q4——以极低 bit 宽获得接近高精度量化的质量
总结:量化与精度的权衡
ds4.c 的选择: 路由专家用 IQ2_XXS/Q2_K,其他用 Q4/Q8 → 在 128GB 内存上运行 284B 参数模型。
3. 注意力机制
注意力是 Transformer 的核心——它让模型决定"看哪里"。今天理解 Self-Attention 的分数计算、KV Cache 避免重复计算、MLA 压缩让百万 token 上下文成为可能。这是整个学习中最关键的一天。
设计决策推导:从问题到方案
今天的每个知识点都不是孤立的——它们是一条解决问题的链条:
问题 1:如何让模型"关注"不同位置的信息?
└─ 方案:Self-Attention(Q·K^T 点积算分数,softmax 加权)
问题 2:逐 token 生成时,之前的 K/V 已经算过了,为什么要重复算?
└─ 方案:KV Cache(缓存历史的 K/V,每步只算新 token 的)
问题 3:KV Cache 随序列线性增长,长上下文时内存爆了怎么办?
└─ 方案 1:滑动窗口(只保留最近 128 个 token 的精确 KV)
└─ 方案 2:MLA 压缩(历史 token 用低维压缩表示,64 倍压缩)
问题 4:滑动窗口丢弃了旧 token,模型怎么"记得"很久以前的内容?
└─ 方案:混合注意力(raw 最近 128 + comp 压缩历史,统一 softmax)
问题 5:标准注意力是 O(n²),压缩后行数仍然很多怎么办?
└─ 方案:Indexer(模型学习哪些压缩行值得看,跳过无关行)
问题 6:64 个注意力头的 KV Cache 是 1 个头的 64 倍,太大了?
└─ 方案:MQA(64 个 Q 头共享 1 组 K/V,KV 体积减少 64 倍)每个方案解决了一个具体问题,同时又引入了新问题,驱动下一个设计决策。理解这条链条比记住每个概念的定义更重要。
C 知识点
1. 多维数组索引
注意力涉及多维数据:[heads, seq_len, head_dim]。在 C 中用一维数组 + 手动索引:
c
// 第 h 个注意力头,第 r 个 KV 行,第 d 个维度
float val = kv_rows[(uint64_t)r * DS4_N_HEAD_DIM + d];
// 第 h 个头的 query 向量
const float *qh = q + (uint64_t)h * DS4_N_HEAD_DIM;
// 第 h 个头的输出
float *oh = out_heads + (uint64_t)h * DS4_N_HEAD_DIM;公式:array[i][j][k] = base[i * J * K + j * K + k]
2. 指针算术
c
const float *kv = raw_kv + (uint64_t)r * DS4_N_HEAD_DIM; // 指向第 r 行
float *head_out = out + (uint64_t)h * DS4_N_HEAD_DIM; // 指向第 h 个头的输出raw_kv 是 float *,+ n 实际偏移 n * sizeof(float) 字节。这是 C 指针算术的核心。
3. 向量操作(BLAS 风格)
ds4.c 实现了基本的 BLAS-1 操作:
c
// 点积: sum(a[i] * b[i])
static float dot_f32(const float *a, const float *b, uint64_t n);
// 缩放: a[i] *= s
static void scale_f32(float *a, float s, uint64_t n);
// AXPY: y[i] += alpha * x[i]
static void axpy_f32(float *y, const float *x, float alpha, uint64_t n);这些是线性代数的基本操作(DOT, SCAL, AXPY 来自 BLAS 标准)。
LLM 知识点
1. Self-Attention 机制
注意力是 Transformer 的核心:让每个位置"关注"所有相关位置。采样阶段使用 softmax(参见 采样 softmax),KV Cache 通过 mmap & KV Cache 的内存映射加载。
ds4.c 实现 layer_attention_rows_one()(约 4746-4786 行):
c
for (uint32_t h = 0; h < DS4_N_HEAD; h++) { // 64 个头
const float *qh = q + h * DS4_N_HEAD_DIM; // 当前头的 query
float max_score = sinks[h]; // sink 作为初始最大值
for (uint32_t r = 0; r < n_kv; r++) { // 遍历所有 KV 行
score[r] = dot_f32(qh, kv + r * HEAD_DIM, HEAD_DIM) * kq_scale;
if (score[r] > max_score) max_score = score[r];
}
float denom = expf(sinks[h] - max_score); // softmax 分母
for (uint32_t r = 0; r < n_kv; r++) {
float weight = expf(score[r] - max_score); // softmax 权重
axpy_f32(oh, kv + r * HEAD_DIM, weight, HEAD_DIM); // 加权累加
denom += weight;
}
scale_f32(oh, 1.0f / denom, HEAD_DIM); // 归一化
}2. KV Cache
KV Cache 存储历史 token 的 Key 和 Value,避免重复计算。
为什么需要 KV Cache?
text
没有 KV Cache:
生成 token 100 时,重新计算 token 0-99 的 K 和 V
→ O(n²) 计算text
有 KV Cache:
生成 token 100 时,只计算 token 100 的 K 和 V
→ O(n) 计算
用缓存的 K[0-99] 和 V[0-99] 做注意力ds4.c 的 KV Cache 结构:
c
typedef struct {
float *raw_kv; // 原始滑动窗口 KV 行
uint32_t n_raw; // 当前行数
uint32_t cap_raw; // 容量(滑动窗口大小)
float *attn_comp_kv; // 压缩后的 KV 行
uint32_t n_comp; // 压缩行数
float *attn_state_kv; // 压缩器状态
float *attn_state_score; // 压缩器注意力分数
} ds4_layer_cache;滑动窗口(Sliding Window Attention):
KV Cache 不保留所有历史,而是只保留最近 N 个 token(SWA = 128):
c
static void kv_cache_push_raw(ds4_layer_cache *cache, const float *kv) {
if (cache->n_raw < cache->cap_raw) {
// 还有空间,直接追加
memcpy(dst, kv, ...);
cache->n_raw++;
} else {
// 满了,滑动窗口:移除最旧的,添加最新的
memmove(raw_kv, raw_kv + HEAD_DIM, (cap-1) * HEAD_DIM);
memcpy(raw_kv + (cap-1) * HEAD_DIM, kv, ...);
}
}3. 注意力汇聚(Attention Sinks)
ds4.c 的独特设计:每个头有一个可学习的"sink"值:
c
const float *sinks = tensor_data(model, layer->attn_sinks);
float max_score = sinks[h]; // sink 作为 softmax 的基准分数
float denom = expf(sinks[h] - max_score); // sink 参与 softmax 分母Sink 不贡献 value 向量,但参与 softmax 归一化。它允许模型"丢弃"不重要的位置,而不是被迫分配注意力。
4. MLA(Multi-head Latent Attention)
DeepSeek V4 Flash 使用 MLA 压缩 KV cache:
text
标准注意力: KV cache 大小 = n_layer × seq_len × n_head × head_dim
= 43 × seq_len × 64 × 512 = 1,413,376 × seq_len
MLA 压缩后: KV cache 大小 = n_layer × seq_len × 1 × head_dim
= 43 × seq_len × 1 × 512 = 22,016 × seq_lenGQA 头分组比:64×(64 个 Q 头共享 1 个 KV 头)。 MLA 实际内存压缩比:128×(原始 KV 总维度 65536 ÷ 潜在表示 512)。详见 mla。
压缩器工作流程:
text
每 ratio 个 token:
1. 输入 token → KV 投影 → 压缩器状态更新
2. 压缩器状态 → 注意力加权池化 → 压缩 KV 行
3. 压缩 KV 行存入 attn_comp_kv
注意力时:
→ 同时关注 raw_kv(最近 128 个)和 attn_comp_kv(历史压缩行)
→ 由 indexer 选择哪些压缩行可以关注5. 混合注意力(Mixed Attention)
ds4.c 的 layer_attention_mixed_one 同时使用原始和压缩 KV:
c
// 原始行:最近 128 个 token
for (r = 0; r < n_raw; r++)
score[idx] = dot(q, raw_kv[r]);
// 压缩行:历史 token 的压缩表示
for (r = 0; r < n_comp; r++) {
if (!comp_allowed[r]) continue; // indexer 决定是否允许
score[idx] = dot(q, comp_kv[r]);
}
// 统一 softmax + 加权求和这就是为什么 DeepSeek V4 Flash 可以支持百万 token 上下文:大部分历史被压缩,但关键信息通过 indexer 选择性地保留。
5b. 逐层压缩缓存容量
Metal GPU graph 使用 comp_cap 控制每层压缩 KV 行的最大数量。之前的实现用全局 comp_cap(基于最小压缩比计算),所有层共用同一个悲观容量。对于 ratio-128 的层(128 个 token 才产生一行压缩行),实际只需要 ratio-4 层 1/32 的容量:
c
// 旧:全局容量,所有层用 ratio-4 的悲观值
g->comp_cap = ctx_size / min_ratio + 2; // min_ratio 通常为 4
// 新:逐层容量,按每层自己的压缩比计算
for (uint32_t il = 0; il < DS4_N_LAYER; il++) {
const uint32_t ratio = ds4_layer_compress_ratio(il);
g->layer_comp_cap[il] = ctx_size / ratio + 2;
}效果:ratio-128 层的持久缓存(layer_attn_comp_cache、layer_index_comp_cache)分配量大幅减少,节省 GPU 显存。工作缓冲区仍使用全局 comp_cap,因为 ratio-4 的 indexer 层可能达到该上限。
4. MoE 混合专家
284B 参数全部计算太慢了。MoE 的思路是"参数可以多,但每次只用一小部分"——256 个专家里只激活 6 个,实际计算量不到总参数的 0.1%。今天学习路由策略、SwiGLU 激活和非对称精度设计。
MoE 在注意力机制(同一主题前一章节)的基础上,用稀疏路由替换全连接 FFN。
设计决策推导:为什么用 MoE?
问题 1:284B 参数的模型,每次推理要读取所有权重——decode 瓶颈是内存带宽
└─ 方案:把大 FFN 拆成 256 个小专家,每次只用 6 个
→ 读取量从 256× 降到 6×,但参数总量不变
问题 2:如何决定每个 token 用哪些专家?
└─ 方案 A(前 3 层):Hash 路由——确定性分配,不同 token 分散到不同专家
└─ 方案 B(后续层):Top-K 路由——学习到的 Router 打分,选分数最高的 6 个
问题 3:为什么是 6 个而不是 3 个或 12 个?
└─ 实验验证的甜点:1→4 效果提升明显,4→6 仍有收益,6 之后边际递减
→ 更多专家 = 更多内存读取 = 更慢,收益不足以抵消成本
问题 4:6 个专家够吗?知识覆盖会不会不够?
└─ 方案:加 1 个共享专家(所有 token 必经)兜底通用知识
→ 43 层 × 6 专家 = 258 次选择,通过残差连接逐步积累
问题 5:256 个路由专家的参数量太大(~6.4B),怎么压缩模型文件?
└─ 方案:非对称量化——路由专家用 2-bit(不常被激活,误差被稀释),
共享专家和注意力投影用 4-8 bit(每次都用,需要精度)
→ 284B 参数压缩到 81GB,在 128GB MacBook 上可运行C 知识点
1. 指针数组
MoE 中每个专家有自己的权重矩阵,通过指针数组索引:
c
// 256 个专家,每个有独立的 gate/up/down 权重
ds4_tensor *ffn_gate_exps; // 指向 256 个专家的 gate 权重
ds4_tensor *ffn_up_exps; // 指向 256 个专家的 up 权重
ds4_tensor *ffn_down_exps; // 指向 256 个专家的 down 权重selected[6] 数组存储被选中的 6 个专家索引,计算时只加载这 6 个专家的权重。
2. 稀疏索引
top-k 选择只关心前 6 个最大的值,不关心其余 250 个:
c
static void topk_desc(const float *score, int n, int k, int *idx) {
for (int i = 0; i < k; i++) idx[i] = -1; // 初始化为无效
for (int i = 0; i < n; i++) { // 扫描所有 n=256 个
for (int j = 0; j < k; j++) { // 与当前 top-k 比较
if (idx[j] < 0 || score[i] > score[idx[j]]) {
for (int m = k - 1; m > j; m--) // 后移
idx[m] = idx[m - 1];
idx[j] = i; // 插入
break;
}
}
}
}这是"部分排序"——只需要前 k 个,不需要完全排序所有 256 个。
3. 数学辅助函数
c
// SiLU 激活: x × sigmoid(x)
static float silu(float x) {
return x * sigmoid_stable(x);
}
// 数值稳定的 softplus: log(1 + exp(x))
static float softplus_stable(float x) {
if (x > 20.0f) return x; // 避免 exp 溢出
if (x < -20.0f) return expf(x); // log(1+小量) ≈ 小量
return log1pf(expf(x)); // log1p 比 log(1+x) 更精确
}LLM 知识点
1. MoE(Mixture of Experts)
传统 Transformer 每个 token 经过同一个 FFN。MoE 把 FFN 替换为多个"专家":
标准 Transformer
MoE Transformer
DeepSeek V4 Flash 的 MoE 配置:
- 256 个路由专家(
DS4_N_EXPERT = 256) - 每个 token 只激活 6 个(
DS4_N_EXPERT_USED = 6) - 1 个共享专家(
DS4_N_EXPERT_SHARED = 1)
DeepSeek V4 PRO 是更大的模型变体(DS4_VARIANT_PRO = 1),MoE 规模远大于 Flash:
- 384 个路由专家(
DS4_SHAPE_PRO),每个 token 激活 6 个 - 61 层(Flash 为 43 层),7168 嵌入维度
- 路由专家全部使用 Q4_K 量化(gate、up、down 张量均为 Q4_K)
- 总参数 ~1.6T,模型文件 ~430GB(IQ2_XXS)或 ~838GB(Q4 分片)
- 单机推理需要 512GB 内存的 Mac Studio,或通过分布式推理跨两台机器运行
2. 路由(Gating)
ds4.c 有两种路由策略:
哈希路由(前 3 层,DS4_N_HASH_LAYER = 3):
c
// 直接通过 token ID 查表得到专家
const int32_t *row = table + (uint64_t)token * DS4_N_EXPERT_USED;
for (int i = 0; i < DS4_N_EXPERT_USED; i++)
selected[i] = row[i];固定映射:同一个 token ID 永远路由到同一组专家。简单、无计算开销。
Top-K 路由(后续层):
c
// 1. 计算路由分数
float logits[256];
matvec_any(logits, model, layer->ffn_gate_inp, x); // 输入 → 256 维
// 2. 特殊激活: √(softplus(logit))
for (int i = 0; i < 256; i++)
probs[i] = sqrtf(softplus_stable(logits[i]));
// 3. 选择 top-6
topk_desc(probs, 256, 6, selected);
// 4. 归一化权重(加偏置选,用无偏倚的权重)
// 选择时可以加 bias(ffn_exp_probs_b)
// 但权重从无偏倚的 probs 计算
float sum = Σ probs[selected[i]];
for (int i = 0; i < 6; i++)
expert_weight[i] = probs[selected[i]] / sum * DS4_EXPERT_WEIGHT_SCALE;为什么用 √(softplus(x)) 而不是标准 softmax?
- softplus 保证非负
- 平方根压缩分布,避免单一专家占主导
- 实验验证这个变体效果更好
3. SwiGLU 激活
每个专家的 FFN 使用 SwiGLU 激活函数:
标准 FFN: output = W_down × ReLU(W_gate × x)
SwiGLU: output = W_down × (SiLU(W_gate × x) ⊙ (W_up × x))c
static void swiglu(float *out, const float *gate, const float *up, uint64_t n) {
for (uint64_t i = 0; i < n; i++) {
out[i] = silu(gate[i]) * up[i]; // ⊙ 是逐元素乘法
}
}SwiGLU 比 ReLU 效果更好:
- 门控机制(gate)让网络学会"什么时候激活"
- SiLU 的平滑性避免了 ReLU 的"死神经元"问题
在 2-bit 量化中还有额外的 clamp:
c
const float limit = DS4_SWIGLU_CLAMP_EXP; // 10.0f
if (gate[j] > limit) gate[j] = limit;
if (up[j] > limit) up[j] = limit;
if (up[j] < -limit) up[j] = -limit;4. 完整的 MoE 前向传播
关键洞察:路由专家用 2-bit 量化(省空间),共享专家用 8-bit(保质量)。路由专家的权重占模型大部分空间,所以压缩它们收益最大。
PRO 模型的路由专家使用 Q4_K 量化(~4.5 bit),比 Flash 的 IQ2_XXS 精度更高但体积也更大。ds4 支持三种后端的 PRO Q4_K 推理路径:
- CPU:
matvec_q4_k_experts_mid_prequant()+ds4_vec_dot_q4_K_q8_K(),ARM NEON dot-product 加速 - Metal:
metal_graph_decode_pro_q4_expert_table_expected()优化的专家查找表 - CUDA:专用 Q4_K rowspan kernel(
moe_gate_up_mid_q4K_expert_tile8_rowspan_kernel等)
5. MoE 的效率优势
284B 参数模型(Flash),每个 token 只激活:
- 6/256 路由专家 × 3 矩阵 × 2048×4096 = 151M 参数
- 1 共享专家 × 3 矩阵 × 2048×4096 = 25M 参数
- 注意力参数: ~100M
- 总计激活: ~276M 参数 (不到总参数的 0.1%)这就是为什么 DeepSeek V4 Flash 虽然有 284B 参数,但推理速度接近 27B 的密集模型。
PRO 模型更大但激活参数仅 49B(总参数 1.6T),同样得益于 MoE 的稀疏激活。详见 分布式推理。
6. MoE 专家缓存与 SSD Streaming
MoE 的稀疏激活(每 token 只用 6/256 专家)天然适合缓存策略——只把"热"专家放 RAM,"冷"专家留磁盘按需加载:
这就是 SSD Streaming 的基础——把"模型能否运行"从"RAM 必须 ≥ 模型大小"的硬截止,变成"RAM 越多越快"的连续速度谱。启动时根据 RAM 预算计算 ds4_ssd_cache_plan,按流行度排名(ds4_streaming_hotlist.inc)预加载热门专家。Metal 后端(ds4_metal.m)实现了完整的异步加载和 LRU 淘汰。
详见 SSD Streaming 术语表和 Part 6 — GPU 加速。