Appearance
Part 4: 推理流程
RoPE、prefill/decode、线程池
1. RoPE 与推理循环
"我爱猫"和"猫爱我"的注意力分数一样——模型分不清词序。RoPE 通过旋转向量编码位置信息。今天还学习 prefill vs decode 两个推理阶段,以及自回归生成的本质:每步输出成为下步输入,无法并行。
RoPE 在 Self-Attention 的 Q/K 投影中注入位置信息,使模型感知 token 顺序。
设计决策推导:位置编码与推理阶段
问题 1:注意力分数只看向量内容,不知道 token 顺序
→ "我爱猫"和"猫爱我"的 Q/K 值完全相同,模型分不清
└─ 方案:RoPE — 按 token 位置旋转 Q 和 K
2D 旋转矩阵有性质 R(a)·R(b) = R(b-a)
→ Q·K 点积只依赖相对距离,不依赖绝对位置
问题 2:训练时只见过 64K 长度,推理时要处理 1M token?
└─ 方案:YaRN — 混合外推和内插
高频分量(精确区分相邻位置)→ 内插(缩小频率)
低频分量(感知远距离关系)→ 外推(直接延伸)
→ 支持 16× 上下文扩展(64K → 1M)
问题 3:用户发来 5000 token 的 prompt,怎么处理?
└─ Prefill 阶段 — 所有 prompt token 批量通过 43 层
→ 可以并行(矩阵乘法天然并行)
→ GPU 利用率高,速度 250+ t/s
问题 4:生成回复时,每步只产生 1 个 token,GPU 利用率极低?
└─ Decode 阶段 — 逐 token 串行生成(自回归)
每步要读取 81GB 权重但只算 1 个 token → 带宽瓶颈
→ 速度只有 5-10 t/s
→ 这就是 decode 比 prefill 慢 25-50 倍的根本原因
问题 5:decode 能不能加速?
└─ 方案:MTP 推测解码 — 用小模型猜 N 个 token,
大模型一次验证,猜对就"免费"获得 N 个 token
→ 接受率 70% 时理论加速 2×+C 知识点
1. 三角函数
RoPE 用 cos/sin 实现旋转变换:
c
const float c = cosf(theta) * mscale;
const float s = sin_sign * sinf(theta) * mscale;
const float x0 = tail[i + 0];
const float x1 = tail[i + 1];
tail[i + 0] = x0 * c - x1 * s; // 旋转后的第一个分量
tail[i + 1] = x0 * s + x1 * c; // 旋转后的第二个分量这是 2D 旋转矩阵的数值实现:
[x0'] [cos θ -sin θ] [x0]
[x1'] = [sin θ cos θ] [x1]2. 幂运算与频率
c
const float theta_scale = powf(freq_base, -2.0f / (float)n_rot);
// ...
theta_extrap *= theta_scale; // 每对维度乘以一个衰减因子RoPE 的频率按指数衰减:第 i 对维度使用 freq_base^(-2i/n_rot) 作为频率。低维度变化快(高频),高维度变化慢(低频)。
3. 推理循环的状态管理
生成循环维护一个位置计数器 pos:
c
for (int i = 0; i < n_predict && pos < ctx_size; i++) {
int token = sample_argmax(logits, DS4_N_VOCAB); // 选 token
if (token == vocab->eos_id) break; // 遇到 EOS 停止
if (emit) emit(emit_ud, token); // 回调输出
n_generated++;
forward_token(logits, model, weights, &cache, // 前向传播一个 token
token, (uint32_t)pos, &scratch);
pos++; // 位置 +1
}LLM 知识点
1. RoPE(Rotary Position Embedding)
RoPE 通过旋转向量来编码位置信息:
位置 0: 向量旋转 0°
位置 1: 向量旋转 θ°
位置 2: 向量旋转 2θ°
位置 n: 向量旋转 nθ°关键性质:两个位置的内积只依赖于它们的相对距离:
Q_pos_m · K_pos_n = f(m - n)这使得注意力自然地编码相对位置——不需要额外的位置参数。
2. YaRN 扩展
RoPE 在超出训练长度后效果下降。YaRN(Yet another RoPE extensioN)通过插值解决:
c
// 在训练长度内:正常 RoPE
// 超出训练长度:混合外推和内插
const float ramp_mix = rope_yarn_ramp(corr_dims[0], corr_dims[1], i) * ext_factor;
theta = theta_interp * (1.0f - ramp_mix) + theta_extrap * ramp_mix;低维度(高频):保持外推(因为它们已经学到了长距离关系) 高维度(低频):改为内插(缩放到更小的角度)
ds4.c 的配置:
DS4_ROPE_FREQ_BASE = 10000.0(标准 base)DS4_ROPE_SCALE_FACTOR = 16.0(扩展 16 倍)DS4_ROPE_ORIG_CTX = 65536(训练时上下文长度)- 扩展后支持 1M token 上下文
3. Prefill vs Decode
推理分两个阶段:
Prefill(预填充): 处理整个 prompt
输入: [token_0, token_1, ..., token_n] (整个 prompt)
处理: 所有 token 同时通过所有层
输出: 最后一个 token 的 logits
复杂度: O(n × L × d²) (n = prompt 长度)ds4.c 用层主序(layer-major)处理:
c
// 先把所有 token 通过第 0 层,再通过第 1 层...
for (uint32_t il = 0; il < DS4_N_LAYER; il++) {
// 批量注意力:所有 token 互相关注
layer_attention_raw_swa_batch(attn, ..., n_tok, il, 0);
// 批量 FFN:可以分组处理
layer_ffn_batch(next, ..., n_tok, il);
}Decode(解码): 逐个生成 token
输入: token_t (一个新 token)
处理: 通过所有层,用 KV cache 做注意力
输出: 下一个 token 的 logits
复杂度: O(L × d²) (常量,与历史长度无关... 不完全对,因为有压缩 KV)c
// 每个 token 独立通过所有层
for (uint32_t il = 0; il < DS4_N_LAYER; il++) {
layer_forward_raw_swa_one(next, model, &weights->layer[il],
&cache->layer[il], cur, il, pos, token, scratch);
swap(cur, next);
}性能对比:
- Prefill: 快速,因为可以批量处理(GPU 并行)
- Decode: 较慢,因为每次只处理一个 token(串行)
4. 自回归生成
LLM 的生成是自回归的——每一步的输出成为下一步的输入:
这个过程本质上是串行的——无法并行生成多个 token(因为每一步依赖上一步的结果)。这就是为什么 decode 速度(t/s)是 LLM 推理的关键指标。
5. 推测解码(Speculative Decoding / MTP)
ds4.c 支持 MTP(Multi-Token Prediction)推测解码来加速:
1. 主模型生成 token A
2. MTP 小模型快速推测: A → B → C → ...(多步草稿)
3. 主模型验证: 确认哪些草稿 token 正确
4. 接受正确的前缀,丢弃错误的
5. 重复
理想情况: 一次验证接受多个 token → 加速
实际情况: 接受率取决于 MTP 模型质量ds4.c 的实现:
- 草稿深度由
--mtp-draft N控制;深度 2 时走专用的 argmax 快速路径 - 有置信度门槛(
mtp_margin):低置信度时跳过 MTP - Metal 路径上用
decode2_exact等内核一次验证多个位置
推测验证的"逐行 argmax"不变量
推测解码的验证步必须保证一个不变量:每个被提交(commit)的草稿 token,必须是其位置上主模型 logits 的(近似)argmax——否则就把模型从没生成过的 token 当成它生成的,污染上下文。
这条不变量一度在 --mtp-draft > 2 时被破坏:metal_graph_verify_suffix_tops() 本该对 top_rows 个 logits 行各取 top-1,却误调成了"对单行取 top-top_rows"(n_tokens=1, top_k=top_rows 而非 n_tokens=top_rows, top_k=1)。于是 row_tops[i>0] 拿到的是第 0 行的亚军 token,验证器拿错误的行去匹配草稿,接受了模型根本没生成的 token。
为什么深度 ≤2 看不到这个 bug?因为 top_rows == 1 时代码走的是上方的专用 argmax 分支,根本到不了那段错误调用。所以这是一个"只在开深推测时才暴露"的隐藏缺陷:
c
// ds4_gpu_indexer_topk_tensor() 的签名是 (selected, scores, n_comp, n_tokens, top_k)
// 错误: (..., n_tokens=1, top_k=top_rows) // 单行的前 top_rows 名
// 正确: (..., n_tokens=top_rows, top_k=1) // 每行的 top-1修复后每个被提交 token 的 argmax 间距(worst gap)从 ~20.96 logits 降到 0.00。该调用点在后端共享的 ds4.c 里,所以 CUDA 同样受影响、同样修复。回归测试 tests/ds4_test.c --mtp-verify-depth:在深度 4 跑贪心推测解码一个逐字复制任务,再把提交的 token 教师强制喂回普通解码,断言每个都是其位置的近似 argmax——这恰是推测验证必须保持的不变量。
DSpark 推测解码(取代旧 MTP)
上面是 ds4 原有的单阶段 MTP路径(--mtp-draft)。DeepSeek 后来为 Flash 发布了辅助草稿模型 DSpark——它读取主模型的隐藏状态、一次提议最多 5 个 token,主模型验证后只提交被接受的前缀。详见 DSpark 术语表。
关键区别:
- DSpark 是独立的支持 GGUF(~5.6 GiB),不是独立模型;用
--mtp传文件、--dspark选运行时(不加--dspark时--mtp仍走旧单阶段 MTP) - 它取代旧 MTP 用于 Flash 而非叠加;PRO 暂不支持
- 不加速 prefill,草稿与验证也不是免费的:可预测续写(尤其代码)收益最大,低收益 prompt 可能更慢——故仍 opt-in
- 默认置信度阈值按后端区分(2026-08 从统一
0.9下调):Metal0.6,CUDA / ROCm0.7,剪掉不值回验证成本的后缀;--quality/--dspark-strict保持纯目标解码便于对比。调度默认确定性。采样模式(2026-09 起)默认走机会式温度采样,--mtp-exact-sampling可选精确随机推测——见下方专节 - 接受后直接提交验证器状态(
0e89a0e):full accept 直接采用批量验证器的状态、partial accept 用前缀快照槽位直接提交——不再回滚快照 + 逐 token 重放。两条路径跑同一张图,但浮点运算分组顺序不同,故长贪心运行与不开 DSpark 的输出不再保证字节相同(不是降精度);需要逐字节复现用--dspark-strict。TP 的 partial accept 仍走重放 - 三个 GPU 后端(Metal / CUDA / ROCm)都支持:
84cc882补齐了 ROCm 缺的草稿/验证内核(非因果批量注意力、markov argmax 等)
GLM 5.2 的推测走另一条路:MTP 块在主 GGUF 内部(无单独 Flash MTP 文件),用 --glm-mtp 启用实验性贪心推测。Qwen3.8 同款(--mtp,草稿块内嵌、无需第二文件,见下方专节)。注意原生会话批处理激活时 MTP/DSpark 被禁用——唯一的例外正是 Qwen3.8:它的批处理推测见 Part 5 §6。
采样模式:机会式温度采样(默认)与精确随机推测(2026-09)
DSpark 与采样(--temp > 0)的关系在 2026-09 经历了一次方向反转:769a8ba 先实现了分布精确的随机推测解码并设为默认,8d683d6 随即把它降级为 opt-in、换成更快的"机会式"方案——两条路径都保留,由一个开关选择。
精确随机推测解码(--mtp-exact-sampling,引擎选项 dspark_exact_sampling)是教科书式的 Leviathan/Chen speculative sampling,只是草稿分布 q 是点质量(DFlash 贪心 drafter 的 argmax 处 one-hot——点质量提议仍是合法的 q):
sample_build_probabilities()把用户请求的完整过滤链(temperature softmax → top-k 有界堆 → top-p 累积截断 → min-p)物化成过滤后重归一化的概率向量 p——不是裸 logits,是"用户实际采样时会用到的那个分布"speculative_point_accept():点质量 q 下接受概率就是min(1, p[token])- 拒绝时
speculative_point_replacement()把草稿 token 概率置零、按残差分布重采样——正是让输出分布与直接采样目标模型完全一致的修正项(入口ds4_session_eval_dspark_speculative_stochastic())
未显式设置时该模式把置信度门提到 0.8、要求草稿长度 ≥ 2。正确性由 tests/test_sampling.c 的 check_speculative_distribution() 锁定:过滤链的精确值断言 + 10 万次 Monte-Carlo 采样频率与目标分布差 < 0.006。
机会式温度采样(现默认):边界 token 按请求的温度/top-p/top-k/min-p 正常采样(精确),随后 DFlash 的贪心后缀只要与目标模型的贪心路径逐个一致就直接提交,首次失配处恢复普通采样。它比"采样时完全不用提议"快——可预测代码续写在 temp 1 下 M5 Max +8%;但收益依机器而定(Strix Halo 反而更慢,验证在那里太贵)。语义上它比普通采样更确定:采样的随机性只进入边界 token,中段是贪心匹配。
顺带三个语义细节:--temp 0 自动退回贪心 argmax 路径;分布式 / CPU / GLM 会话与 --quality / --dspark-strict 一律普通解码(不用任何提议);旗标最终定名 --mtp-exact-sampling(曾短暂叫 --dspark-exact-sampling,未发布即改名,旧拼写现在会被 CLI 拒绝并有测试锁定)。
种子批处理、六 token 验证与 EOS 契约(2026-09)
种子批处理(be76012,M5):置信度高的种子 token 直接融进验证器批量一起前向——注意语义:被批的种子是"正常选出的目标 token",不是草稿成功,所以不改变输出分布;接受率差的窗口暂停起草 32 个周期(省下白费的草稿算力)。DS4_DSPARK_SEED_BATCH=0 恢复旧调度。实测(256 token、4096 ctx):Q2 C 哈希表 55.71→62.95 t/s(temp 0)/ 50.95→61.58(temp 1);Vision Exp 混合 Q2/Q4 41.65→48.86。
六 token 验证与部分前沿(8d309ab):种子 + 5 草稿意味着前缀 1-4 各需要一份新鲜的压缩器/索引器状态才能在部分接受时直接提交。新增 spec_capture_prefixes 标志与 spec_frontier_commit_prefix(),复用 DSpark 的前缀快照槽位(DS4_SPEC_PREFIX_SLOTS);测试钩子 ds4_test_dspark_prefix_capture 比较的是压缩器/索引缓存行数而非只比文本。窗口管理器(metal_graph_dspark_cache_reset/window_valid/set_window/crop_to_prefix/merge_target_range/target_prefix 等)由 tests/test_dspark_window.py 不加载模型地全量覆盖。
EOS 契约(7a8c1b2):贪心草稿若包含内部 EOS,在验证/捕获/提交之前先截到首个 EOS——会话状态绝不能跑到"已返回 token"之外(否则一次拒绝回滚后,EOS 之后的草稿污染状态)。尊重 ignore_eos。契约由 tests/test_dspark_eos_contract.py 锁定:把真实辅助函数对着 mock 编译,7,217 个用例、240,503 条断言。
什么时候回本:单 M5 Max 常驻 Q2 纯 decode 46.5 t/s,开 DSpark 36-44 t/s(随 --dspark-confidence 变化)——只有可预测文本才赚;DGX Spark 上 C 哈希表 31.41 vs 散文 ~18.8 t/s(见 Part 6 §4b)是同一结论的最干净对照。而双 Mac TP 下 DSpark 反而比纯 decode 慢(30-40 vs 47-48 t/s,每个验证行都要重流它的专家,Part 6 §4j)。诊断用 DS4_DSPARK_CYCLE_TRACE / DS4_BENCH_SPEC_TRACE。
Qwen3.8 内建 MTP:链式二级草稿与自适应深度(2026-09)
Qwen3.8 的 MTP 块(nextn 层)内嵌在主 GGUF 里,--mtp 直接启用。循环默认只起草 1 个 token,但在"近期一级草稿接受完美"时链式启用二级草稿——预测器在自己的流上多走一步 nextn,用一次 3 行验证 pass(token + 一级草稿 + 二级草稿)一起验证;连续的二级拒绝会自动脱离。DS4_QWEN4_MTP_DEPTH=2/3 固定深度,0(默认)就是这个自适应策略(qwen4_spec_depth,ds4.c:74038——8-bit 滚动窗口 ≥8 位全 1 且无连败才升深度,<6/8 或两次连败降回)。动机写在注释里:二级草稿在散文上接受率只有 ~0.6(亏),在确定性续写(代码)上 ~0.95+(+10-20%)。
采样语义沿用全局框架:默认贪心接受(匹配即提交,含非零温度),--mtp-exact-sampling 保分布精确。一个 Qwen 特有的细节是工具采样模式边界的回卷:验证块跨过工具语法边界(采样模式切换处)时,服务器回卷到块起点重评边界 token;精确模式下这个回卷恢复的是 pre-verify 的递归状态快照(snap0_*)而不是重置重放图——长保留上下文不会在每个边界被重放一遍。拒绝草稿同样恢复行快照(GDN 递归状态 + 注意力缓存),DS4_QWEN4_SPEC_FORCE_ACCEPT 是测试钩子。
实测(M5 Max,n-gram 并发读之后):单会话纯 decode 54.5 t/s,MTP 代码续写 78.9、散文 66.0;DGX Spark Q2 散文 17.1 → 19.9 t/s。收益同样依赖文本可预测性——上面 spark 数字是 temp 0 散文,代码类 prompt 收益更大。
6. 完整推理流程图
2. 线程池
43 层的矩阵运算、MoE 的 256 个专家——单线程太慢。线程池把行范围分配给多个 CPU 核心并行计算,推理速度随核心数线性增长。今天学习 pthread API、worker 主循环和防嵌套并行的 TLS 技巧。
线程池为 HTTP 服务器 提供并发请求处理能力。
C 知识点
1. pthread 基础
POSIX 线程(pthread)是 C 语言的标准线程 API:
c
pthread_t thread;
pthread_create(&thread, NULL, worker_fn, arg); // 创建线程
pthread_join(thread, NULL); // 等待结束
pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER; // 互斥锁
pthread_cond_t cv = PTHREAD_COND_INITIALIZER; // 条件变量2. Mutex(互斥锁)
保护共享数据的访问:
c
pthread_mutex_lock(&g_pool.mutex); // 加锁
// ... 访问共享数据 ...
pthread_mutex_unlock(&g_pool.mutex); // 解锁同一时间只有一个线程可以持有锁。其他线程在 lock 处阻塞等待。
3. Condition Variable(条件变量)
用于线程间通知:
c
// 等待方(worker):
pthread_mutex_lock(&mu);
while (条件不满足)
pthread_cond_wait(&cv, &mu); // 释放锁 + 等待信号 + 重新获取锁
pthread_mutex_unlock(&mu);
// 通知方(主线程):
pthread_mutex_lock(&mu);
修改条件;
pthread_cond_broadcast(&cv); // 唤醒所有等待者
pthread_mutex_unlock(&mu);wait 必须在 while 循环中(不是 if),因为可能被虚假唤醒。
4. 线程池设计
ds4.c 的线程池:
主线程 Worker 线程 1..N-1
│ │
├─ 设置 fn, ctx, n_rows │ (阻塞在 cond_wait)
├─ generation++ │
├─ cond_broadcast(work) ─────────┼─→ 被唤醒
├─ 自己也执行一部分工作 ├─ 检查 generation
│ fn(ctx, 0, rows_per_thread) ├─ 执行 fn(ctx, my_start, my_end)
│ │
├─ cond_wait(done) ←─────────────┼─ done++ → cond_signal(done)
│ │
└─ 所有 worker 完成,继续 └─ 回到 cond_wait关键设计:
- 主线程也工作:不浪费一个核
- generation 计数器:防止旧的 broadcast 唤醒新的任务
- 行范围分配:
rows_per_thread = (n_rows + n_threads - 1) / n_threads __thread g_parallel_depth:防止嵌套并行(递归时退化为串行)
5. TLS(Thread-Local Storage)
c
static __thread int g_parallel_depth;每个线程有自己的副本。在 ds4_parallel_for 中:
c
g_parallel_depth++; // 标记"当前线程已在并行执行"
fn(ctx, 0, main_row1); // 如果 fn 内部又调用 parallel_for
g_parallel_depth--; // → g_parallel_depth > 0 → 退化为串行LLM 知识点
推理并行性
ds4.c 在 prefill 阶段利用多线程加速:
c
// 并行处理多个 token 的 SwiGLU
ds4_parallel_for(n_tok, swiglu_batch_worker, &ctx);
// 并行处理多个 token 的 FFN
ds4_parallel_for(n_tok, ffn_worker, &ctx);但 decode 阶段无法并行(每次只处理 1 个 token)。多线程只在 prefill 的批量操作中有效。
线程池的最小行数阈值(512)确保小任务不会因线程同步开销而变慢。
困惑度评分(Perplexity Scoring)
--perplexity-file 模式让 ds4 评估一段文本与模型预期的吻合程度:
- 将文件内容分词后送入模型
- 跳过前 32 个 token 作为"种子"(避免 BOS 附近的异常值)
- 对每个后续 token 累加负对数似然(NLL)
- 最终报告平均 NLL 和 perplexity(
exp(mean_nll))
困惑度越低,模型对这段文本越"不意外"——用于评估量化质量、比较模型变体、或验证模型行为是否正确。CLI 通过 ds4_engine_options.perplexity_file 字段传入文件路径。
GPU 功耗节流(Power Throttling)
推理引擎支持 power_percent(1-100)参数控制 GPU 使用率。低于 100 时,引擎在每个 prefill 层和 decode token 后注入 sleep:
实际 sleep 微秒 = (100 - power_percent) / power_percent × step_timeds4_engine_set_power() API 允许运行时动态调整(agent 模式下根据用户交互状态自动调节)。设为 100 时不插入任何 sleep,满速运行。