第 6 章 引擎启动与内存计划
第 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 头部注释是全书最值得读的一段之一:
WeightTableanswers “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_projis [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.mdand 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 - 1is 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. Pertools/pack_budget.pythat 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 takesconst float* scales,
so this loader WIDENS them - exactly, changing no value - for 11,407,360 B.
… assuming 4 bytes per scale instead of readingscales_fp16is 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
cudaMemGetInfoinsideopen
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.
两个要点:
- 测量必须在真实占用之后,否则读到的是「卡的空闲值」而不是「进程还剩多少」。
- 装不下时要报错并给出两个数字,不能默默返回一个更小的缓存。
默默缩小会让后面所有性能数字都失去可比性,而且没人知道发生了缩小。
事故三:预留本身也要自适应
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:
withslotspairs andslotsslots 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 useActQ::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-13says 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, soproducedwas
still empty when the first generated position asked forproduced.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_loopused to allocate its pinned staging buffer, its probe event and its host pin
ON EVERY TOKEN, andcudaFreeHostat 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-captureonly: 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_replaylaunches all 48pregraphs 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 |