第 6 章 引擎启动与内存计划
MoE 大模型低成本部署框架第 6 章 引擎启动与内存计划
CHAPTER 06

第 6 章 引擎启动与内存计划

18 分钟阅读 · 4,031 字

第 4 部第一章。从 ~/Strata/src/program/generate.cpp(实测 7741 行)出发,
讲引擎从命令行到能出第一个 token 之间发生了什么:参数、几何契约、权重表、显存预算、启动顺序。

行号口径:本章行号取自 2026-10-19 的 ~/Strata(legacy 引擎)。
~/SSEStraBed 是同一引擎的改造副本,generate.cpp 为 8091 行,同一处代码行号不同
(例如 serve 强制 MTP 的检查在 Strata 是 :4182、在 SSEStraBed 是 :4501)。
引用时以「文件:函数」为准,行号只作定位辅助。


6.1 生产命令行:逐参数读

从 ps 里抓到的真实命令行(2026-10-19,B lane):

strata --serve
  --pack            /home/cloud/Strata-data/packs/iq3_xxs
  --native          .../Qwen3.8-Flash-Next-GSQ-RCO-IQ3_XXS-00001-of-00002.gguf
  --ple-gguf        .../Qwen3.8-Flash-Next-GSQ-RCO-IQ3_XXS-00002-of-00002.gguf
  --expert-profile  /home/cloud/Strata/data/expert-profile.bin
  --expert-cache    auto
  --prefill         auto
  --spec 4  --spec-min-p 0.5
  --mtp             /home/cloud/Strata-data/mtp/rt
  --max-context     262144
  --kv int8  --kv-resident 32768
  --vision
  --conversation-cache-slots 20  --conversation-cache-mib 8192
  --vram-reserve-mib 1600
  --ple-io ram
  --pool-workers 13
  --batch 4  --batch-mtp  --batch-groups 1

每个参数的取值理由,按「它解决哪个问题」组织(完整旋钮表见附录 B):

参数 值 为什么是这个值
--max-context 262144 原生窗口硬固定(约束 1)
--kv int8 — 24G 显存装不下 fp16 KV(约束 2)
--kv-resident 32768 常驻窗口的上限,其余走流式
--ple-io ram mlock 不可用,改成页缓存友好的访问模式(约束 4)
--pool-workers 13 两个引擎共享 32 核配额,必须显式限死(约束 2)
--vram-reserve-mib 1600 默认 700 不够,见 §6.5 的事故
--expert-cache auto 按启动时真实剩余显存算槽数,见第 07 章
--expert-profile expert-profile.bin 决定哪些专家常驻,见第 07 章
--spec 4 4 草稿链最长 4 token,见第 10 章
--spec-min-p 0.5 草稿接受概率阈值
--batch 4 4 每卡 4 个批槽,见第 10 章
--batch-mtp — 批槽也走投机链
--batch-groups 1 槽组流水级数(多卡时才 >1)
--conversation-cache-slots 20 最多 20 个会话停车,见第 12 章
--conversation-cache-mib 8192 停车总预算 8 GiB,见第 12 章
--vision — 启用多模态,配套 strata-vision 子进程

serve 模式强制 MTP(generate.cpp:4182):缺 --spec T、--mtp DIR、--prefill CHUNK
直接 exit 2。这条硬检查意味着「关掉 MTP 跑基线」这条路不存在(约束 11)。


6.2 几何契约:ModelGeometry

include/strata/core/layout.hpp 头部注释是全书最值得读的一段之一:

WeightTable answers “where are the bytes”. This answers “is this the tensor I think it is”,
and it is the layer where a pack and a kernel disagree - which is the failure this project keeps paying for.

它举了两个真实例子:

round 194 sized the indexer key store from indexer.head_count = 4, which counts QUERY heads.
The cached key is ONE shared head of 128 (indexer.k_proj is [2560, 128]), so the term was 4x too big
and the error survived a round because it moved in the direction that TIGHTENS the budget.

这是全书最锋利的一条经验:一个让预算「变紧」的错误不会被预算检查发现,
因为预算变紧看起来像是「更保守、更安全」。只有让预算变松的错误才会被抓住。
所以校验必须校验语义,光校验总量不够。

docs/semantics.md and the tensor manifest agree on every dimension here,
but nothing CHECKED that a named tensor had the shape the kernel reading it assumes.

于是这个 header 做两件事:给每层类型命名张量,并断言内核依赖的每个形状。注释写明它想要的失败方式:

A mismatch is reported with the tensor name, the shape found and the shape required,
at LOAD time - not as a wrong number inside a GEMV at token 4000.

结构体本身(每个字段都是「某个内核依赖的数字」):

int64_t n_embd = 2560;  n_layers = 48;  qsa_interval = 4;   // 每 4 层一次全注意力:3,7,...,47
// GDN(36 层)
ssm_state_size = 128;  ssm_k_heads = 16;  ssm_v_heads = 48;  ssm_d_conv = 4;
ssm_conv_channels = 10240;   // 2*128*16 + 128*48
ssm_value_dim = 6144;        // 128 * 48
// QSA(12 层)
n_head = 24;  n_head_kv = 2;  head_dim = 256;  idx_q_heads = 4;  idx_key_dim = 128;
// 门控残差(每层)
hc = 4;  hc_lr = 320;
// MoE(每层)
n_expert = 512;  n_ff = 640;

注释里两处细节值得注意:

Every field here is a number a kernel depends on, so a change is a change to a kernel contract
and not a tuning knob.

以及派生而非重复:

layer % qsa_interval == qsa_interval - 1 is full attention. Derived, not a second list.

docs/semantics.md 里这件事写了两遍(full_attention_interval = 4 和一个显式的
attention.compress_ratios 数组),代码只取第一种并派生另一种,避免两处口径漂移。


6.3 权重表:pack 形态与 engine 形态的差别

include/strata/core/weights.hpp 头部把「打包」与「加载」的差别写得很清楚,而且方向是双向的:

收缩:

A tensor whose source type is BF16 is stored in the pack promoted to 32 bits, because that is lossless
and it keeps the canonical decoder simple, but a running engine re-rounds it to 16 - which costs nothing at all,
because the value CAME from 16 bits. Per tools/pack_budget.py that saving is the difference between
the dense weights fitting in VRAM beside the expert cache and not fitting at all.

扩张:

90 of the 303 quantized tensors carry fp16 SCALES in the pack (all 58 Q2_0 expert tensors, plus Q4_0, Q5_0,
Q8_0 and IQ4_NL) because that is what the source block holds. Every kernel here takes const float* scales,
so this loader WIDENS them - exactly, changing no value - for 11,407,360 B.
… assuming 4 bytes per scale instead of reading scales_fp16 is what made the plane arithmetic disagree
with the pack for a whole round.

给出的实际数字:

pack     5.305 GiB dense + 0.444 GiB embd
engine   4.067 GiB dense + 0.444 GiB embd + 10.88 MiB widened scales = 4.522 GiB

「一个 scale 是 2 字节还是 4 字节」这种细节,错了会让预算算错一整轮。

为什么用索引文件而不用 JSON

这段注释值得单独抄出来:

WHY AN INDEX FILE AND NOT A JSON PARSER: a JSON parser in the engine would be a new, unaudited component
whose failure mode is a wrong byte offset - which decodes to a plausible weight and produces plausible logits.
Python wrote the manifest and already parses it correctly.

判断依据是失败模式的性质:JSON 解析出错不会崩,它会解出一个「看起来合理的权重」,
然后产出「看起来合理的 logits」。这种错误比崩溃危险得多。
所以引擎刻意不引入新的解析组件——解析由 Python 完成,引擎只读一个扁平索引。

索引文件本身(~/Strata-data/packs/iq3_xxs/index.txt):

# strata pack index v3 -- generated by tools/iq_pack.py (native experts)
#   from Qwen3.8-Flash-Next-GSQ-RCO-IQ3_XXS-00001-of-00002.gguf
# align 64  pool 1538035200  tensors 1079
output.weight 0 0 0 0 0 0 2560 248320 8 0 32 0 0 0 0 0 0 0
blk.0.ffn_gate_inp.weight 0 4 13148160 2621440 ... 2560 512 ...

native_experts.txt 记录每个专家的量化种类与偏移:

# strata native experts v3: layer gu_type d_type offset blob_bytes gate_off up_off down_off [shard]
#   (n_expert 512, total 42912972800; absolute offsets in ...gguf, or in the named shard beside it)
0 17 20 0 1868800 1227833216 1473813376 755052416
1 16 42 956825600 1305600 2016152832 2235918592 1779301632

专家全量 42,912,972,800 字节(约 42.9 GB),而显存只放其中一小部分(第 07 章)。
注意第 0 层与第 1 层的 gu_type / d_type 不同、blob 大小也不同(1,868,800 与 1,305,600)——
「一个专家多大」随层变化,任何按常数算的预算都会错。


6.4 启动顺序与 mem_mark 检查点

generate.cpp 里有一串 mem_mark("...") 调用,把启动过程切成可观测的阶段:

行 检查点
:2913 mem_mark("the weights, the session and the drafter")
:3142 mem_mark("opening the expert cache")
:3208 mem_mark("the profile fill")
:3447 mem_mark("the R4 hit path")
:3569 mem_mark("the expert cache and the graphs")
:4497 mem_mark("the head and the prompt path")
:4667 mem_mark("the verifier and the drafter's binding")

这些检查点不是日志装饰:--expert-cache auto 的槽数就是靠「在此刻 cudaMemGetInfo 还剩多少」算出来的,
所以顺序直接决定结果。


6.5 两处启动顺序事故

事故一:head 加载在 cache 之后,请求永久卡死

generate.cpp:2916 起的注释:

THE HEAD BEFORE THE CACHE. The expert cache takes what is free minus the reserve, so everything allocated
after it comes out of the reserve. The native head (~0.5 GB with IQ3_S) was loaded after it and ate most of
the 700 MiB: 128K IQ3_S ended with 30 MiB free, the driver paged, and a request stalled for good at its first
verify window.
Loaded first, the cache is sized around it.

这条链条值得逐环看:head 晚加载 → 吃掉预留 → 剩余 30 MiB → 驱动开始分页 →
请求在第一个 verify window 处永久卡住。

症状(卡住)与原因(分配顺序)之间隔了三层,而且卡住的位置(verify window)
完全指不回原因。这类 bug 只能靠「把因果写进注释」来防止复发。

事故二:R4 槽存储必须在权重之后分配

generate.cpp:2910:

R4’s slot storage. Allocated AFTER the weights and the session, so cudaMemGetInfo inside open
sees the memory this process actually has left rather than the card’s idle figure -
and refuses with both numbers if the slots do not fit, instead of handing back a cache smaller than it was asked for.

两个要点:

  1. 测量必须在真实占用之后,否则读到的是「卡的空闲值」而不是「进程还剩多少」。
  2. 装不下时要报错并给出两个数字,不能默默返回一个更小的缓存。
    默默缩小会让后面所有性能数字都失去可比性,而且没人知道发生了缩小。

事故三:预留本身也要自适应

generate.cpp:2972 起的 #496 注释:

the verify window cannot start without a cache (#174), and a cache too small to lend the prompt path
a 256-token chunk’s buffers … makes it allocate its own on top - more than the reserve.
When the default reserve leaves less than that (a 6 GB card), the reserve shrinks to what leaves exactly
that cache … below ~550 MiB a card ends with less than the 256 MiB the serve check calls LOW
(IQ3_XXS, 32K, a 300 MiB reserve: 5 MiB left), so the cache gets no more than it needs,
and the serve check says so plainly when it …

生产用 --vram-reserve-mib 1600(默认 700)就是这条链条的结果。


6.6 --expert-cache auto 怎么算槽数

generate.cpp:2941-2960:

cudaMemGetInfo(&free_b, &total_b);
const int64_t prefill_mib = (o.prefill_chunk > 0 && !pf_borrow) ? 160 + (o.prefill_chunk * 680) / 1024 : 0;
const int64_t mtp_bind    = (!o.mtp.empty() && native_head.loaded())
                                ? mtp.bind_bytes(native_head.row_bytes(), n_vocab) : 0;
const int64_t reserve = (((int64_t) o.vram_reserve_mib + prefill_mib) << 20) + mtp_bind;
const int64_t blob  = strata::kernels::cpu::expert_layout().max_blob;
int64_t slots = ((int64_t) free_b - reserve) / blob;
if (!profile.empty()) slots = std::min<int64_t>(slots, (int64_t) profile.size());
o.expert_cache = (int) std::max<int64_t>(slots, 0);

它打印的数字(生产日志里能看到):

expert cache auto: <X> GiB free, 1600 MiB reserved (+<Y> MiB for the draft head) -> <N> slots

注释解释了每一项为什么必须在场:

Plan v0.3 P5: the batched prompt path’s chunk buffers are allocated later, so they are reserved here -
under WDDM an over-subscribed allocation does not fail, it pages to system memory and crawls.

以及 draft head 的账:

the draft layer’s head and logits are allocated when it binds, after this: 0.1.27’s CJK subset made them
~110-180 MiB larger, and out of the reserve they left 16 GB cards below the stall line (#199)

「一次版本升级让某个组件大了 110–180 MiB,就把 16G 卡推到了卡死线以下」——
这种「上游变更改变下游余量」的连锁,是显存预算最难防的一类。

slots = min(slots, profile.size()) 这一行也很关键:槽数不会超过 profile 里有用的专家对数。
多开的槽位没有候选可填,纯属浪费显存。


6.7 profile 填充:计划只在这里被执行一次

generate.cpp:3165:

R4.2e: fill the tier from the profile. This is the only place the plan is applied, and it runs ONCE:
with slots pairs and slots slots the cache is full when this returns, so the decode-time admission
finds no room and every non-profiled expert stays a CPU miss.
That is what makes the profile the policy rather than a hint.

「计划只执行一次,之后缓存已满、解码期准入无处可放」——这句话定义了 profile 的语义强度:
它是策略,不是提示。理解这一点,才能理解第 07 章为什么命中率完全由 profile 质量决定。

同一区域还记了一个修复(#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)

「一个层填满就结束整个填充」是很典型的循环终止条件错误:在均匀数据上不明显,
在 48 层 × 512 专家这种结构上会让大部分层拿到 0 个槽。

日志里会打印填充策略:

PROFILE, ranked by routing frequency, no eviction.

或者(没有 profile 时):

compulsory-miss (fills with whatever the run routes first).

6.8 命中路径是全有或全无

generate.cpp:3401:

R4.2c: THE HIT PATH. Every one of these is required for hits_ready(), which is all-or-nothing on purpose:
a half-configured hit path would compute some experts twice and others not at all,
and a token built on that is wrong rather than refused.

紧接着的 #R4.2h 注释给了一个非常具体的数值教训:

R4.2h: the fp32 activation scales. Without this the GPU’s hits use the block’s fp16 d
while the CPU’s misses use ActQ::scale, which is fp32 - a 4.761e-04 relative disagreement on
every chunk, and the reason enabling the cache changed the tokens.

4.761e-04 的相对差异,足以让生成的 token 序列改变。
在混合执行路径(一部分专家在 GPU 算、一部分在 CPU 算)里,两条路径的激活量化必须逐位一致,
否则输出会变。这是「数值一致性」在真实系统里的具体代价。

所以这里宁可全有或全无:半配置的路径会产出错误 token,而错误 token 不会报错。


6.9 prefill 就是解码路径逐 token 跑

generate.cpp:3679 是全书最重要的一处澄清:

prefill is the DECODE PATH ONE TOKEN AT A TIME, which phase-2-correct-engine.md:12-13 says is
fine here: "process the prompt through the decode-style graphs in small batches;
a 19K-token prompt will take minutes. A real batched prefill is P2.S6’s other half and is not this.

这个引擎没有真正的批量 prefill。 读 254K token 的提示词,就是逐 token 跑 254K 次解码路径。
第 1 章那个「254K ≈ 81–91 秒」的数字,根源就在这一行注释所描述的实现里。

同一段还记了一个边界 bug:

THE LOOP IS feed -> 48 layers -> head -> sample -> feed, AND THE FIRST GENERATED TOKEN COMES FROM THE
LAST PROMPT POSITION.
The first version sampled only on the decode positions, so produced was
still empty when the first generated position asked for produced.back() -
an out-of-bounds read on an empty vector. Teacher forcing below is what makes the distinction
unnecessary to special-case.

修复方式值得学:不是加一个特例分支,而是用 teacher forcing 让「prompt 位置」与「生成位置」
在同一个循环里统一处理,从而消除需要特例的那个区分。


6.10 token 路径不分配任何内存

generate.cpp:3934:

THE TOKEN PATH ALLOCATES NOTHING (P2.T10, review finding H3).
session_loop used to allocate its pinned staging buffer, its probe event and its host pin
ON EVERY TOKEN, and cudaFreeHost at the end of each call implicitly synchronises the device -
so every token finished with a device-wide sync nobody asked for.
The scratch is created once here and reused; it also owns the host pin for the whole session
rather than taking and releasing it per token.

这条的价值在最后一句的因果:cudaFreeHost 会隐式同步设备。
所以「每 token 分配再释放」的代价不只是分配本身,而是每 token 一次全设备同步。
这类「API 的隐藏副作用」是性能问题里最难自己发现的一类,注释把它写下来等于替后来的人省一次调查。

同文件里还有一处同类判断(--stage-timing 的守卫):

--no-capture only: an event recorded inside a stream capture is silently dropped, so a captured graph
cannot carry these events and the numbers would be zeros that read as “every stage is free”.
Refusing is the fix.

「会产出看起来合理的零」的测量工具,应该直接拒绝运行,而不是产出那个零。
这条原则在本项目里反复出现(权重索引、命中路径、形状断言),可以当成贯穿全书的一条方法论。


6.11 纯 GPU 下限的测量方式

generate.cpp:3697:

THE PURE-GPU MEASUREMENT. session_replay launches all 48 pre graphs back to back on one stream
with NO host work between them - no doorbell poll, no pool, no parts copy -
so what it times is the GPU executing the layer sequence and nothing else.
It had been declared, defined and never called since the day it was written.

输出格式(:3925):

GPU floor pre+post+head  XX.XX ms per token  ->  YY.YY tok/s of GPU work
                     AAA.BBB ms layers (48 x pre+post)
                     CCC.CCC ms per layer
                     DDD.DDD ms LM head

「声明了、定义了、从写下那天起从没被调用过」——这句话值得每个写代码的人读一遍。
测量工具没接上,等于没有测量工具。


6.12 本章小结

  • 生产命令行 20 余个参数,每个都对应一条硬约束或一次事故;完整表见附录 B。
  • serve 模式强制 MTP,缺 --spec/--mtp/--prefill 直接 exit 2。
  • ModelGeometry 把「内核依赖的数字」集中成契约并断言形状;
    让预算变紧的错误不会被预算检查发现(round 194 的 4 倍错误活了一整轮)。
  • pack 与 engine 的形态差别是双向的(BF16 收缩、fp16 scale 扩张到 f32);
    一个 scale 记成 4 字节会让预算算错一整轮。
  • 引擎不解析 JSON:解析出错的失败模式是「解出看起来合理的权重」,比崩溃更危险。
  • 启动顺序决定 --expert-cache auto 的结果;head 必须在 cache 之前;
    槽存储必须在权重与会话之后;装不下要报错给两个数字。
  • profile 填充只执行一次,因此 profile 是策略而非提示。
  • 命中路径全有或全无:GPU 与 CPU 路径的激活量化必须逐位一致(4.761e-04 就足以改变 token)。
  • prefill 就是解码路径逐 token 跑,没有真正的批量 prefill。
  • token 路径不分配内存:cudaFreeHost 会隐式同步设备。
  • 「会产出看起来合理的零」的工具应当拒绝运行。

下一章:07 专家:三级存储与命中率 —— 这台机器上最大的单项成本。

本章出处

内容 出处
生产命令行 ps 实测 2026-10-19;~/Strata/strata-iq3_xxs-a.json / -b.json
serve 强制 MTP generate.cpp:4182(SSEStraBed :4501)
几何契约与 round 194 include/strata/core/layout.hpp 头部注释
pack/engine 形态差别与数字 include/strata/core/weights.hpp 头部注释;tools/pack_budget.py
不用 JSON 的理由 weights.hpp 头部注释
专家全量 42.9 GB ~/Strata-data/packs/iq3_xxs/native_experts.txt 头部
mem_mark 检查点 generate.cpp:2913,3142,3208,3447,3569,4497,4667
head 在 cache 之前 generate.cpp:2916 注释
槽存储在权重之后 generate.cpp:2910 注释
预留自适应 #496 generate.cpp:2972 注释
auto 槽数计算 generate.cpp:2941-2960
profile 只执行一次 generate.cpp:3165 注释;#369 修复同处
命中路径全有或全无、4.761e-04 generate.cpp:3401 注释
prefill = 逐 token 解码 generate.cpp:3679 注释
token 路径不分配 generate.cpp:3934 注释
纯 GPU 下限 generate.cpp:3697,3925
全部章节