Skip to content

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,结束时调用 donevoid *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_tensorsscanf 匹配 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;  // 不是精确匹配

%nsscanf 的特殊格式符,写入已消耗的字符数而不消耗输入。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 bit
Q4_K block (144 字节):
┌─────────────────────────────────────┐
│ d (uint16)    │ 全局缩放           │   2 字节
│ dmin (uint16) │ 全局最小值偏移      │   2 字节
│ scales[12]    │ 12 组缩放+偏移     │  12 字节
│ qs[128]       │ 256 个 4-bit 值    │ 128 字节
└─────────────────────────────────────┘
  每权重: 144 × 8 / 256 = 4.5 bit

4. 定点点积

量化推理的核心操作是"量化向量的点积":

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 GB

2. 非对称量化

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_kvfloat *+ 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_len

GQA 头分组比: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_cachelayer_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 推理路径:

  • CPUmatvec_q4_k_experts_mid_prequant() + ds4_vec_dot_q4_K_q8_K(),ARM NEON dot-product 加速
  • Metalmetal_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 加速