第 7 章 专家:三级存储与命中率
这一章讲这台机器上最大的单项成本,以及围绕它建立的一整套测量与决策。
核心文件:include/strata/core/expert_cache.hpp、include/strata/core/expert_source.hpp、
src/kernels/cpu/pool.hpp、src/kernels/cuda/s2_expert_grouped.cu。
7.1 问题有多大:引擎自己算的账
expert_cache.hpp 开头有一段标题就叫 WHY THIS EXISTS, IN ONE PARAGRAPH 的注释,
这是全书最重要的一段量化论证:
The CPU expert pool is the engine’s largest single cost: measured,
663.6 MB of expert bytes per token at ~40 GB/s = 16.2 ms, on a token of ~53 ms, and
test_expert_poolsays the pipeline hides 1.055 ms of 19.035 -
the CPU work is 96% exposed because the residual chain is strictly serial.
The only way past it is to stop reading those bytes: put some of the 48 × 512 experts in VRAM
and let the GPU compute them, so the CPU reads only the misses.
拆开看:
| 量 | 值 | 含义 |
|---|---|---|
| 每 token 要读的专家字节 | 663.6 MB | 48 层 × 10 专家 × 每专家 blob |
| CPU 内存带宽 | ~40 GB/s | 实际能达到的读速 |
| 由此得到的时间 | 16.2 ms | 纯读专家 |
| 一个 token 总耗时 | ~53 ms | 端到端 |
| 流水线隐藏掉的时间 | 1.055 ms / 19.035 ms | 96% 暴露 |
「96% 暴露」是这一章的关键数字。 直觉上多线程 CPU 池可以和 GPU 重叠,
但门控残差链是严格串行的:下一层要等这一层的专家结果。所以重叠几乎没有发生。
「唯一的出路是停止读这些字节」——这句话决定了后面所有的设计。
7.2 三级存储
第 1 级 VRAM 热缓存 4105 槽(auto 算出) 命中 → GPU 直接算
第 2 级 RAM 全量 arena 42.9 GB 专家 未命中 → CPU 内核读
第 3 级 磁盘 只读 /shared 内存不足时回落
第 2 级的总量来自 native_experts.txt 头部:n_expert 512, total 42912972800(约 42.9 GB)。
第 3 级在本 Pod 上基本不可用(约束 4:机械盘随机读是性能命门),
所以引擎提供了 --resident-experts / --resident-cpu-experts 把 GPU 没装下的专家一次性读进 RAM
(generate.cpp:4118):
the experts the GPU cache does not hold are copied from
experts.bininto RAM once, so no decode or prompt
step reads the file (the plain mmap mode reads them through the OS file cache, which a small-RAM PC keeps
giving back to the SSD).
括号里那句是本项目的通用教训:依赖 OS 页缓存的前提是内存真的够。
内存紧张时 OS 会把缓存换出去,于是「mmap 应该够快」这个假设悄悄失效。
7.3 命中率:一个被测量方法救过的数字
expert_cache.hpp 接着写:
THE DESIGN IS SIZED AGAINST MEASUREMENTS, NOT AGAINST THE PLAN’S ASSUMPTIONS.
h_expertat 4,105 slots, ten-fold leave-one-out: 0.6447 [0.6110, 0.6750] (tools/routing_kfold.py).
The 0.6573 the README published is in-sample and 0.4720 measured a single-prompt CORPUS, not the metric.
三个数字,三种测量方法,结论差别很大:
| 数字 | 方法 | 问题 |
|---|---|---|
| 0.6573 | 样本内(in-sample) | 用训练数据评估自己,偏高 |
| 0.4720 | 单一 prompt 语料 | 测的是「语料」不是「指标」 |
| 0.6447 [0.6110, 0.6750] | 十折留一交叉验证 | 可信,带置信区间 |
同一件事的三个数字,只有带置信区间的那个能用来做设计决策。
README 里那个 0.6573 曾经被发表过——它没有错到离谱,但它不足以支撑「开 4105 个槽」这种决定。
显存够不够:实测优先于计划
VRAM, measured on a live run rather than at startup: 6414 MiB free with the engine at steady state,
against 5413 MiB for 4,105 slots. It fits, with ~1 GB spare.
strata-device’s planner agrees and says FITS, but the planner runs before the dense arena exists,
so the live figure is the one that counts.
规划器算出的结论与实测一致,但它算的时机不对(在 dense arena 存在之前)。
所以本书把这条写成规则:任何显存预算都要有一次「稳态实测」作为最终依据。
为什么必须三路切分
h_layerheld-out is 0.0456 - only 4.6% of (layer, token) pairs have all ten experts resident -
so this cannot be a per-layer grouped kernel and the three-way split is not an optimisation.
这是「用测量否决一个更简单的方案」的干净例子。按层做分组内核看起来更整齐,
但只有 4.6% 的层能凑齐全部 10 个常驻专家,剩下 95.4% 都要走混合路径。
所以「把命中的和未命中的分开处理」不是优化,是必需。
7.4 注释腐烂:一处已核实的过时说明
expert_cache.hpp 里有一段 WHAT THIS FILE IS AND IS NOT, TODAY:
It is the SLOT STORAGE and the RESIDENCY TABLE … It does not yet compute anything -
moe_hit_grouped_s2does not exist and the hit/miss split is not wired into the graph -
so with the cache on and nothing consuming it, the engine is slower by the fill cost and faster by nothing.
That is stated here rather than discovered from a benchmark, and it is why--expert-cachedefaults to 0.
这段话今天已经不成立了。 本书核实:
moe_hit_grouped_s2存在,并有专门的对照测试src/kernels/s2_expert_grouped_parity.cpp:10-12
(三个变体:host count / device count / verify window 多 token)。- 命中路径在
generate.cpp:3401-3447被完整接线,mem_mark("the R4 hit path")在:3447。 - 启动时会打印
R4 hit path ON - resident experts are computed on the GPU。 --expert-cache的帮助文本给出了实测收益(见 §7.5)。
这段注释本身写得非常好——它明确说了「今天它还不计算任何东西」,
并解释了为什么默认值是 0。它的问题只是没有随代码更新。
本书把这一处保留下来,作为一条纪律:
带时间状态的注释(“today”、“not yet”、“currently”)在代码变更时必须同步改,否则它会变成误导。
读代码时看到这类词,先去验证它今天还成立不成立。
生产配置用的是 --expert-cache auto(不是默认的 0),这也印证了状态已经变化。
7.5 槽位分配策略:2.97% 与 70.4% 的差别
--expert-cache 帮助文本里记了一个非常值得学的失败:
--expert-cache-per-layerR4.2g: give each layer its OWN slots instead of letting the first position
take all of them. The default policy fills in arrival order from one shared counter, so
256 slots went to ~26 layers of position 0 and measured 2.97%.
Per-layer, the same routing gives 21.4% at 8 slots/layer and 70.4% at 64.
同一个缓存、同样的路由,只是分配策略不同:
| 策略 | 槽数 | 命中率 |
|---|---|---|
| 全局共享计数器、按到达顺序填 | 256 | 2.97% |
| 每层独立槽位 | 8/层 | 21.4% |
| 每层独立槽位 | 64/层 | 70.4% |
失败原因一句话:「按到达顺序填」会让最先到达的位置吃掉全部容量。
48 层 × 512 专家这种结构下,「到达顺序」几乎等价于「层号顺序」,
于是 256 个槽集中给了约 26 层的 position 0,其余层一个都没有。
任何「按到达顺序分配有限资源」的策略,都要先问一句:到达顺序会不会系统性偏向某一类。
配套的 profile 填充也修过同类问题(generate.cpp:3169,issue #369):
per layer, a full layer skips only its own pairs - each layer takes its hottest experts until its range is full
(one full layer used to end the whole fill, leaving most layers empty)
实测收益
--expert-cache 帮助文本给出的数字:
--expert-cache NR4: keep N expert blobs resident in VRAM and compute their rows on the GPU via
moe_hit_grouped_s2. DEFAULT 0. Measured at 4096 slots with--expert-cache-per-layer:
54.4% hits, CPU pool drain 19.1 → 10.3 ms/token, -2.7 ms/token end to end.
对照 §7.1 的账:CPU 池从 19.1 ms/token 降到 10.3 ms/token,端到端省 2.7 ms/token。
注意端到端省的比 CPU 池省的少(2.7 对 8.8)——因为省下来的 CPU 时间有一部分本来就被 GPU 掩盖了。
「某个组件快了 8.8 ms」不等于「整体快 8.8 ms」,这条换算关系值得记住。
7.6 契约:expert_source.hpp 的三条「错法」
expert_source.hpp 头部把它的职责写得很克制:
Nothing here is clever, and that is the point: the pool, the kernel and the loop are each already verified,
so this file has exactly one job - get the CONTRACT between them right.
THE CONTRACT, and each clause is a way to be wrong:
条款一:x_f 每层是同一个 pinned 缓冲区
Its ADDRESS never changes, so the
ActQcannot be cached by pointer -
a cache keyed onx_fwould quantize layer 0 and reuse it for all 47 remaining layers,
which is a finite, plausible, completely wrong token. There is no cache here at all:
the conversion is one 2560-element pass against a 0.3 ms/layer budget,
and a correct answer is worth more than the microseconds.
「finite, plausible, completely wrong」这个描述非常精准:结果不是 NaN、不是崩溃,
是一个完全合理但错误的 token。这类错误最难被发现。
而且注释明确给出了取舍:省这点微秒不值得,正确更重要。
条款二:池不乘 router 权重
THE POOL DOES NOT APPLY THE ROUTER WEIGHT.
moe_combine(src/core/layer.cpp:479) sums
w[i] * parts[i]on the device.ExpertJob::weightis a diagnostic field; setting it here would
apply the weight twice, which is invisible in a single layer and compounds over 48.
「单层不可见、48 层累积」——这是分布式责任系统里最典型的 bug 形态。
字段名看起来该填,填了就错。注释把「这个字段只用于诊断」写死,是防止复发的唯一办法。
条款三:内核几何由 artifact 固定
THE KERNEL’S GEOMETRY IS FIXED BY THE ARTIFACT (
H = 2560,FF = 640,BLOB = 1,382,400).
A differentn_embdor a different expert width is REFUSED rather than mis-indexed,
because the blob’s internal offsets are compile-time constants and
reading a 640-wide expert as a 2560-wide one walks off the end into the next expert’s bytes without faulting.
「不报错、直接读进下一个专家的字节里」——所以必须显式拒绝。
这与第 06 章「形状在加载时断言」是同一原则的两处落点。
口径提醒:
BLOB = 1,382,400是 CPU 内核布局里的常量,而native_experts.txt里第 0 层 blob 是
1,868,800、第 1 层是 1,305,600。两套数字口径不同(内核布局 vs 文件里的原生量化 blob),
读代码时不要把它们当成同一个数。
7.7 CPU 池:线程数与「宿主线程也下场」
--pool-workers 帮助文本:
R2.2: CPU expert pool worker count. Default 0 = every physical core except the one the host loop spins on
(with--pool-affinity autoor p-cores on a hybrid CPU: P-cores minus 1).
以及一个 A/B 开关:
--no-host-workerR2.2: the A/B arm. By default the HOST THREAD joins the drain, so the pool is
six threads on six cores instead of five plus an idle core; this flag restores the five-worker form
for comparison onpool phases.
「宿主线程也下场干活」听起来显然更好,但代码保留了 A/B 对照臂,
让它可以被测量而不是被默认接受。每个「显然更好」的优化都留一个开关来验证它确实更好。
生产用 --pool-workers 13(理由见第 2 章约束 2 与第 18 章的 9 tok/s 事故)。
7.8 PLE 表:页缓存的访问模式
PLE(逐层位置嵌入表,约 28.8 GB)走 --ple-io ram。相关的 A/B 开关:
--no-ple-prefetchA/B arm: read the PLE table’s sixteen rows one at a time, instead of
issuing them in onePrefetchVirtualMemorycall.
一次 PrefetchVirtualMemory 把 16 行一起声明,比逐行随机读对页缓存友好得多。
这条是约束 4(mlock 不可用)逼出来的具体手法:改变访问模式,而不是改变存储位置。
7.9 命中率怎么被测量:用引擎自己的路由
generate.cpp:7682 的注释给出了测量原则:
R4’s DISPATCH MEASUREMENT: h, ON THE ENGINE’S OWN ROUTING.
No offline trace, no corpus question, no k-fold - these are the ids the router actually produced on this run.
Reported as hits/lookups so it can be read directly as the h the cache would deliver,
and alongsiderefusedso a full cache is visible rather than silently capping the rate.
输出形态:
R4 overlap <n> of <m> layers the hit work was DONE when the pool returned
R4 expert-cache hits <hits> of <lookups> = 0.xxxx (<admitted> admitted, <refused> refused, cache xx.xx% full)
两个细节值得学:
- 离线 trace 与在线路由的分布不同,所以设计期用 k-fold 估、运行期必须用引擎自己的路由复核。
- 把
refused一起报出来。缓存满了会压低命中率,如果不报refused,
读数字的人会以为「命中率就是 0.4」,而实际是「缓存满了、被截断了」。
让「容量限制」在指标里可见,是指标设计的基本要求。
R4 overlap 那一行直接量化了 §7.1 说的「96% 暴露」到底还剩多少暴露。
7.10 跨卡专家层
--peer-device N 把第二张卡当成一个自适应的专家缓存层:
a second GPU as an adaptive expert-cache tier (rows over P2P; it also computes its experts’ prompt rows).
Not with--layer-splitor--expert-cache-device1..3. Default output unchanged without it.
配套旋钮:--peer-reserve-mib(默认 600)、--peer-slots(默认「装得下多少」)、
--peer-adapt-swaps、--peer-prefill-rows(默认一半 chunk × top-k,0 = prompt 路径留在主卡)。
generate.cpp:3346:
Multi-GPU: the second GPU’s expert tier, filled with the ranked pairs the primary does not hold
——第二张卡填的是主卡没装下的那部分,两级缓存互补而不是重复。
还有 --expert-cache-device1..3 与 --expert-cache-remote-placement stripe|layer
(按 rank 条纹分布 vs 按整层分布)。
注意 --peer-device 与 --layer-split 互斥,这条限制是第 11 章「对称共享专家池走不通」的直接原因。
7.11 本章小结
- 最大单项成本:每 token 读 663.6 MB 专家字节 ≈ 16.2 ms,而流水线只掩盖了 1.055/19.035 ms,96% 暴露。
- 三级存储:VRAM 热缓存(4105 槽)→ RAM 全量 arena(42.9 GB)→ 磁盘(本 Pod 上基本不可用)。
- 命中率必须用留一交叉验证测:0.6447 [0.6110, 0.6750];样本内的 0.6573 与单语料的 0.4720 都不能用于设计。
- 显存预算要有稳态实测(6414 MiB 空闲 vs 5413 MiB 需要);规划器算的时机不对。
h_layer只有 0.0456,所以命中/未命中三路切分是必需而非优化。expert_cache.hpp的「moe_hit_grouped_s2还不存在」已经过时:内核存在、已接线、已实测。带时间状态的注释要随代码更新。- 槽位分配策略决定一切:全局到达顺序 2.97% → 每层独立 21.4%(8/层)→ 70.4%(64/层)。
- 实测收益:4096 槽 + per-layer → 54.4% 命中,CPU 池 19.1→10.3 ms/token,端到端 −2.7 ms/token。
组件省 8.8 ms ≠ 整体省 8.8 ms。 - 三条契约条款各自对应一种「错法」:按指针缓存激活、重复乘 router 权重、几何不匹配时不报错。
--no-host-worker/--no-ple-prefetch这类 A/B 开关的作用是把「显然更好」变成可验证。- 命中率要用引擎自己的路由测,并且必须把
refused一起报出来。 --peer-device与--layer-split互斥,这条限制决定了第 11 章的结论。
下一章:08 前向通路:内核、量化与图 —— 48 层到底在算什么。
本章出处
| 内容 | 出处 |
|---|---|
| 663.6 MB / 16.2 ms / 96% 暴露 | include/strata/core/expert_cache.hpp 头部;test_expert_pool |
| 0.6447 [0.6110, 0.6750] | tools/routing_kfold.py,十折留一 |
| 6414 MiB vs 5413 MiB | expert_cache.hpp 头部(稳态实测) |
| h_layer 0.0456 | 同上 |
| 注释已过时 | 本次核实:src/kernels/s2_expert_grouped_parity.cpp:10-12、generate.cpp:3401-3447 |
| 2.97% / 21.4% / 70.4% | generate.cpp 的 --expert-cache-per-layer 帮助文本 |
| 54.4% / 19.1→10.3 / −2.7 | generate.cpp 的 --expert-cache 帮助文本 |
| 三条契约条款 | include/strata/core/expert_source.hpp 头部;src/core/layer.cpp:479 |
| 池线程与宿主下场 | generate.cpp 的 --pool-workers / --no-host-worker 帮助文本 |
| PLE 预取 A/B | generate.cpp 的 --no-ple-prefetch 帮助文本 |
| 在线命中率测量与 refused | generate.cpp:7682 |
| 跨卡专家层 | generate.cpp 的 --peer-device 帮助文本;generate.cpp:3346 |
7.12 2026-10-21 复核增补:三级调度在代码里的真实形态
本章前半写的是设计意图与测量方法(来自头文件注释与帮助文本)。换到 SSEStraBed 逐行复核后,
补上四件只有读实现才知道的事,其中第三件直接决定了并发天花板。
① 三级决策每层做一次,是一行除法
src/core/expert_source.cpp:3376-3413:
const bool pcie_ok = d.pcie_num > 0 && d.src->pcie_layer(d.layers); // 本层能不能走 PCIe
const int m = pcie_ok ? (nmiss * d.pcie_num) >> 8 : 0; // 分给 PCIe 的未命中数
const uint8_t* dma_src[64]; // PCIe 取数的源指针,最多 64 个
...
if (miss_rank >= nmiss - m && fetches < P.staging_cap && fetches < 64) { ... }
d.pcie_num 是 --pcie-frac × 256(>> 8 就是除以 256)。也就是:
"给 PCIe 55%"不是策略,是一次整数乘法。
--pcie-frac 的默认值不是一个数而是一条链:基值 0.55(原生包),
启动时先实测 PCIe 带宽再修正它(generate.cpp:413/2064-2077)。
lane A 实测:PCIe probe: 22.7 GB/s host->device (best of 22.6 22.7 22.6 22.6) -> pcie_frac 0.55 (default 0.55)。
本机带宽足够,所以修正后仍是 0.55。
注意与 §7.1 的口径差:那里用"每 token 漏读量 × 30 GB/s"反解斜率,
这里引擎自报的是 22.7 GB/s 的裸拷贝。两者不是同一个量(前者含分组/staging 开销的等效值)。
引用 PCIe 带宽时请说明是哪一个,否则 30 vs 22.7 会变成一场没有意义的争论。
② 硬上限 kStagingBlobs = 16
// include/strata/core/verify.hpp:357
static constexpr int64_t kStagingBlobs = 16; // 暂存区可容纳的专家块数
它决定一帧最多有几个未命中专家能走 PCIe 快档。分配在 Verifier::init:
staging_ = b.take<uint8_t>((uint64_t) kStagingBlobs * strata::kernels::cpu::expert_layout().max_blob); // verify.cpp:336
sink_.staging_cap = kStagingBlobs; // verify.cpp:365
两个推论:
- 只分配一次、单卡一份(不是每会话一份)⇒ 16 → 32 的显存代价是 +30 MB,不是 ×并发数。
- 一帧 4 行 × 10 专家 = 40 个未命中,
--pcie-frac 0.55想送 22 个过去,实际只能送 16 个;
第 17 个起全部落回最慢的 CPU 档。这就是"并发一高就慢"的机械原因(20 章 20.6)。
16 这个数字不在任何运维文档里,只在源码里。 这是本章最值得抄走的一条经验。
③ 实测的三级配比(那行 strata batch: 的后半段)
引擎汇总行末尾的三个数(per layer-window: CPU experts / VRAM hits / PCIe)
是计数,不是毫秒——每个(窗口 × 层)各有多少个专家。两个真实样本:
| 负载 | CPU experts | VRAM hits | PCIe | 读法 |
|---|---|---|---|---|
| 热态(4 行、短上下文) | 2.20 | 36.30 | 1.83 | 几乎全命中,CPU 段不是瓶颈 |
| 重载(2.84 行、cache 冷) | 5.38 | 18.36 | 5.41 | 命中掉一半,CPU 段顶上来 |
在 batch_step 打出的行上,三者之和 ≈ 行数 × top-10(2.20+36.30+1.83 = 40.33 ≈ 4 行 × 10)。
它告诉你命中率,不告诉你时间——把它当毫秒读会得出完全错误的账(20.4 记了这次踩坑)。
但在
chain_step打出的行上这条校验不成立:那类行的avg rows字段其实是
每窗口提交的 token 数(generate.cpp:5894每提交一个就++bt_rows),不是行数。
见过一条avg 6.62的行,CPU+VRAM+PCIe = 146,用 6.62 去对账会得出"命中 220%"。
正确做法:chain行的分母用S × L(≤16),batch行才用avg rows。
④ 命中率的斜率,以及"每 +1pp"该怎么用
--expert-cache 的帮助文本给的是 4096 槽 → 54.4% 命中、CPU 池 19.1→10.3 ms/token、端到端 −2.7 ms/token
(§7.5)。本机现役配置更高(6434 槽 / 10.42 GiB),日志当日命中 88.6%
(decode expert cache hit rate: 88.6% (92150 hits / 103951 lookups))。
按 22.7 GB/s 折算,每 +1pp 命中 ≈ 省 0.32 ms 的漏读,即 ≈ +3% 吞吐。
但别把这 3% 直接乘到"提到 95% 就能 +20%"上——分母会变(命中率高到一定程度后,
剩下的都是最冷的专家,且 CPU 段已经从关键路径上退下去)。斜率是局部的,不是全局的。
增补后的结论:三级存储的设计意图没变(§7.1–7.11 仍然有效),
但真正的并发天花板不在"显存能装多少专家",而在kStagingBlobs = 16这个没人写进文档的常量。