从总共 1M 到 15+ 路 × 1M:GLM-5.3 KV Cache 换出的容量突破
让每台 1T 主机内存承接长历史,把同时解码的上下文容量推向 15+ 倍;50K 单路实测近乎无损。完整记录每次决策、失败与转向。
**这次优化最大的收益,是把原来总共约 1M token 的上下文容量,推向 15+ 路、每路 1M token 历史同时 decode 的规模。**同一套 GPU,能够同时承接的长历史容量朝着 15+ 倍扩展。
原来,一份百万 token 的长对话就可能吃掉整套系统的上下文预算。现在,每台机器的 1 TiB 主机内存开始承接 MLA 历史,GPU 留下 32K 热缓存供当前计算使用。我们要支持的是多位用户各自带着百万 token 历史持续生成,而不是只把结束的会话存下来。
**15+ 路 × 1M 是本轮的容量估算规模,尚不是已经完成的并发性能实测。**下文会展开它的内存账;近乎无损的速度结论来自已经完成的 50K 对照。这两项成果分别回答“能承接多少历史”和“换出会损失多少速度”。
我们做到了一个有明确验收范围的结果:固定 50,023 token 输入、1,024 token 输出,保留 32K GPU 热缓存,MLA 历史换到主机内存后,MTP=3 三组对照的吞吐损失为 0.15%–1.30%;不开 MTP 的三组对照略快约 2%。所有完整输出都与各自基线一致。
这个结果来得并不直接。最初我们想把索引计算也交给 CPU,后来退回;热缓存一度损失约三成速度;几次局部优化看起来很有道理,完整模型却没有变快;最后那一点不稳定,又把我们从 attention kernel 带到了 Linux 内存管理和 GPU 驱动。
这篇文章按决策的形成过程展开:为什么走一条路,什么证据让我们保留它,又是什么证据迫使我们转向。
起点:把容量和当前计算的工作集分开
硬件沿用上一篇 GLM-5.3 优化文章的双机系统:每台 8 张 AMD Radeon PRO W7900D,单卡 48 GiB,EPYC 9334 平台,每台 1 TiB RAM。节点内使用 HIP P2P,节点之间通过 TCP 传递层段边界的 activation;没有 XGMI。
这次改变的是 KV 的放置。权重和每层计算仍留在负责它的设备,历史数据也留在对应节点的主机内存中。
机会来自 GLM-5.3 的稀疏注意力路径:DSA 索引器从历史中选出 Top-2048 位置,MLA attention 消费选中的 KV。部分后续层复用 selection,但各层仍有自己的 KV 内容。
因此,全历史需要保存的规模,与一轮 attention 真正消费的规模,可以分开管理。
本次 Q8G64 MLA 每行包含 512 B latent、16 B scale 和 128 B RoPE 数据,共 656 B。原始工程记录的容量账如下;它只计算 MLA 数据,不含 DSA、MTP 层、元数据、对齐或 scratch。
| 对象 | MLA 数据大小 |
|---|---|
| 50,000 token,单层单副本 | 31.281 MiB |
| 50,000 token,78 个主模型层、两份副本合计 | 4.765 GiB |
| 一层选中 2,048 行,单副本 | 1.28125 MiB |
| 32,768 行热缓存,单层单副本 | 20.5 MiB |
单份 50K 历史还不惊人,但它会随会话数量和长度累积。我们的目标是让 GPU 上主要的 MLA 历史存储被热缓存容量约束,增长部分由 RAM 承担。
这里的“热缓存”也不是只保留最近 32K token 的滑动窗口。**旧位置仍保存在 RAM,DSA 仍可以选中它们;缓存未命中时再读取。**它改变物理位置,不主动截断模型可访问的历史。
第一次决策:CPU 既存历史,也做 Indexer?
最初的方案相当自然:主机有大量内存和 CPU 核心,既然历史准备放在 RAM,就让 CPU 完成全历史打分和 Top-K,再把选中的 KV 送给 GPU。
这样还有一个调度上的诱惑:得到 selection 后,后续复用选集的层可以提前搬运历史 KV,与 GPU 的独立工作重叠。
我们已有 CPU Q8 打分、blocked 布局和向量化实现,于是补上固定 worker team、局部直方图、Top-K 工作区复用、增量 mirror、回滚和预取。这条路值得验证,不能只靠“CPU 比 GPU 慢”就否定。
真机结果却给了两个独立的否定理由。
第一是速度。50K 测试中,GPU 基线约 14.03 token/s,CPU DSA 候选只有 10.92–11.32 token/s。CPU 算术之外,query 回传、线程协调、选集上传和 GPU 等待也进入关键路径。历史上 131K 的 CPU 测量不能简单乘以 50/131,因为活跃 worker 数也随上下文改变。
第二是正确性。CPU Q8 query 的最终选集与 GPU BF16 query 的精确路径存在 cutoff 差异,固定贪心输出发生分叉。CPU 内部计算自洽,并不等于它与原 GPU 路径等价。
决定:保留 GPU exact DSA,关闭 CPU selection;继续推进 MLA 历史的 RAM 驻留。
这次退回缩小了问题。存储容量扩展可以独立成立,不必等 CPU 索引路线成功。提前预取仍是有价值的重叠机会,但不能预先记成零开销。
第二次决策:先让 CPU 管理 GPU 热缓存
保留 GPU selection 后,早期热缓存路径仍需要把选集交回主机:CPU 查映射、判断命中、整理缺失行,再提交上传和 GPU attention。
这条路首先验证了容量方向,但性能很差。2026 年 9 月 7 日的早期战役中,真实 32K 热缓存配置经历 packed 复制和批量 mirror 修正后,三轮中位仍约 10.41 token/s。该阶段报告相对其参考值约慢 29%;这些早期数字包含不同诊断条件,不能与后文最终验收直接拼成严格 A/B。
这里先暴露了两个具体问题。
**一个是换出分支丢掉了原来的压缩复制路径。**此前 owner 量化一次,再把每行 656 B 的 packed KV 复制给 peer。进入热缓存后,fallback 变成两端各自 append、重新量化,也损失了原有通道重叠。于是先把 packed 复制接回热缓存。
**另一个是小传输的次数。**逐层、逐 token 更新主机镜像,78 层的 owner 与 peer 合计可以产生 156 次流操作。尝试把每行分成三段并增加在途传输后,操作数变为 468,性能反而退到约 8.8 token/s。
这个实验否定了“增加异步并发就能解决传输成本”的直觉。数据本来就很小,更多提交可能比更多字节更贵。
改成每 64 行批量回传后,操作数量大幅下降,全量缓存阶段确实改善。但进入真正热缓存后,仍有明显差距。
随后我们尝试把 peer 镜像维护移入 worker 锁段,又让 append kernel 双写连续日志。三轮中位分别约 10.39 和 10.06 token/s,没有得到预期的量级收益。
**决定:保留批量日志和必要的生命周期修复;撤回“锁迁移、日志双写足以解决主要差距”的判断。**实现完成与性能假设成立,需要分别验收。
一次必须承认的误判:占比最高,不等于回退来自那里
我们曾给热缓存 attention 加同步 trace。测得的总时间里,kernel 占 77.4%,主机锁与 remap 约占 22%。当时据此判断:剩余问题主要在窗口 attention kernel。
后续复核推翻了这个归因方法。
这份 profile 测的是候选自身的绝对时间构成,没有证明 GPU 常驻与换出之间的差额发生在同一位置。常驻路径本来也要执行 attention。另外,同步与 trace 把完整运行压到约 4.21 token/s,已经明显改变了原有提交和重叠关系。
据此提出的 selected gather 方向,检查源码时又发现已有相关实现。需要验证的是实际热路径和依赖,不能把已有机制重新当作待实现方案。
我们调整测量方法:诊断只用来定位,正式性能关闭 profile、trace 和额外同步;每个候选配同版本 GPU 基线,完整输出一致后再比较吞吐。需要归因的始终是两条路径的差额。
第三次决策:让 GPU 直接管理热缓存
新的证据把注意力带回提交链:GPU 已经算出 selection,却要让 CPU 等待回读,再做映射和打包,才能继续提交 attention。一次选集回传的主机等待曾达到约 0.7–1.2 ms;其中包含此前 GPU 排队,不能把它全算成复制时间,但它确实暴露了控制往返。
于是改变缓存管理的位置。选集留在 GPU,GPU 直接查热槽;命中读显存,未命中读已注册的 RAM mirror,形成当前 attention 使用的紧凑选集。
GPU exact DSA ──→ 历史位置 selection
│
▼
GPU hot gather
├─ 命中:读显存热槽
本节点 RAM 历史 ────└─ 未命中:读注册的主机内存
│
▼
紧凑 KV → GPU attention
新增 KV → GPU recent 环 → 分批回传 → RAM 历史
主机内存因此承担历史容量,GPU 保持索引、缓存查找和 attention 的连续执行。CPU 仍负责资源与生命周期管理,但不再为每次 selection 主持一轮查表和搬运。
它也没有让 PCIe 读取免费。早期某些采样窗口 miss 不到 1%,说明这个负载具有缓存复用机会;不能由此保证所有输入都有相同命中率。
最初 GPU 管理版本约 11.72 token/s,仍有首次注册停顿。提前注册并补齐多行路径后,甚至有一轮退到 5.13 token/s。我们继续保留方向,但不把“稳态事件间隔接近常驻”当作完整吞吐通过。
GPU 管缓存也会犯错:跨 block 可见性与置换长扫描
MTP 把单行路径里不明显的问题放大了。一次 verify 会处理多行,多个 query 可能选到同一个缺失位置。
早期实现允许同一次 gather 内,一个 block 读另一个 block 刚填的热槽。真实 MTP 测试出现异常 scale,随后没有有限的可选 token。
我们把可见性边界收紧:**本轮新填的槽,到下一轮才作为命中来源;同轮重复 miss 直接读取 RAM。**本轮正在使用的槽先 pin,新增但尚未回传到 RAM 的 token 则由 recent 环保护。
之后完整 1,024-token MTP 输出与同版本常驻一致,但吞吐只有 16.175 对 27.530 token/s,仍慢约 41.2%。两边接受的 draft 都是 665 个,差距不能归因于 MTP 接受率。
接下来把小批 verify 也接回 owner→peer packed KV 复制,候选升到 20.035 token/s。这是值得保留的完整执行路径修复。
缓存置换又带来一个反直觉结果。使用带引用位的时钟置换时,在“热窗已满、引用位全部置位、只有 1% miss”的合成压力形态下,少数 miss block 要负责长距离清扫,gather 达到约 1.03–1.07 ms。miss 更多时,反而可能因参与清扫的 block 增多而更快。
改成跳过当前 pin 槽的环形替换后,同一探针降到约 0.032 ms(2,048 行)。但是完整 MTP 运行只有 19.767 token/s,没有体现探针的巨大收益。
决定:保留更简单的置换实现,消除已证实的压力形态;否定“这个局部热点就是完整差距”的推断。
第四次决策:离开 attention,检查提交线程在做什么
缩小到活跃 decode 线程的 CPU 采样,发现约 21.1% CPU cycles 落在主机 memcpy。调用链最终落到只读 MoE 权重对象的 clone:每次向 worker 提交工作,都可能深拷贝 Dense router。
这与 KV 的语义无关,却真实地影响了同一条执行链。修复让只读权重组通过 Arc 共享,保留算子和权重数值。
我们也给 GPU 常驻基线加入同一修复。v15 同版本 MTP 对照达到 25.425 对 26.832 token/s,损失收窄到 5.24%。这是显著改善,但仍略超 5% 门槛;21.1% 的 CPU 采样占比也不能换算成等比例端到端收益。
后续继续复用 recent 环承载 peer 日志,省掉重复复制;移交 prefill 已有的缓冲,减少热缓存切换时的分配与释放;小日志用 compute 拷贝和打包,减少 DMA 队列操作。
这些改变没有一次性解决问题。某轮 no-MTP 进入 4.80%,下一轮 MTP 又损失 9.87%。日志里不断出现 0.6–1.2 秒的长停顿,即使大多数事件间隔已经接近常驻。
我们没有删掉前 128 个 token,也没有只取 p50 来宣布完成。用户实际等待的这些停顿必须计入原有完整 decode 口径。
第五次决策:从慢 kernel 追到操作系统页迁移
设备时间线显示,长停顿未必落在 hot gather。一次采集中 gather 最大只有约 0.054–0.056 ms,却有约 792 ms 的同步等待,整条流水线几乎没有设备工作。
这时,把 profiler 中一个被拖长的 kernel 名称当作根因,会再次走错路。我们开始对齐两台机器的系统设置与驱动行为。
关闭 NUMA 自动平衡:方向正确,证据还不够
发现 Amd-1 的 kernel.numa_balancing=0,Amd-2 为 1,长停顿集中在后者。统一关闭后,第一组 MTP 损失只有 0.51%,看起来已经成功。
但第三组交错复测损失 5.49%,门禁停止。决定保留系统设置一致性,继续寻找剩余长尾,不用平均值掩盖失败组。
预先写入注册范围的尾页:一个被实验否定的解释
另一个假设是:RAM mirror 预留容量中的尾页尚未实际写入,后续追加首次触页影响了 GPU 映射。于是注册前把 spare capacity 写零,保持有效 KV 字节和逻辑长度不变。
该候选仍出现 7.35% 损失和秒级间隔。预触页没有解决稳定性,不能把它写成最终根因。
主动内存整理:终于取得调用链证据
进一步使用 BPF 对齐实际推理进程、失效地址和恢复 worker,捕获到这样的路径:
kcompactd
→ proactive_compact_node
→ migrate_pages
→ try_to_migrate_one
→ amdgpu_hmm_invalidate_hsa
→ 相关队列恢复工作
这次证据确认了主动页整理、HMM 失效与队列恢复路径。失效地址采样中,大量事件并不与显式注册的 KV mirror 相交,所以也不能再把它们解释为“KV 尾页首次写入”。这次采样没有复现之前完整的一秒停顿,因而我们没有声称每次长停顿都已逐一归因。
两台机器随后将 vm.compaction_proactiveness 从 20 调为 0,NUMA 自动平衡保持关闭。Linux 官方文档说明,0 会关闭主动整理,页迁移可能造成应用延迟尖峰;本次因果判断还要靠同设置下的完整对照。Linux 内核文档
这些是实验机的运行时调整,原值和恢复命令均留档,没有修改开机配置。它们属于这次验收条件,不能省略,也不能当作所有 GPU 机器通用的调优配方。
六组交错对照:到这里,才肯定“近乎无损”
最终使用同一 v23 二进制,固定 50,023 输入、1,024 输出、greedy seed01。换出组启用 32,768 行热缓存,对照组保持 GPU 常驻;双方关闭 NUMA 自动平衡与主动 compaction,关闭 profile、trace、attach 和 CPU DSA。
每次重新启动实验运行时,交错安排常驻与换出顺序。吞吐沿用首个文本事件至 SSE DONE 的原有口径,没有删除停顿。
| 模式 | 组 | RAM 历史 + GPU 热缓存(token/s) | GPU 常驻(token/s) | 换出吞吐损失 |
|---|---|---|---|---|
| MTP=3 | 1 | 27.839 | 27.883 | 0.157% |
| MTP=3 | 2 | 27.923 | 27.966 | 0.150% |
| MTP=3 | 3 | 27.738 | 28.102 | 1.297% |
| no-MTP | 1 | 14.042 | 13.788 | −1.846% |
| no-MTP | 2 | 14.029 | 13.753 | −2.007% |
| no-MTP | 3 | 14.029 | 13.747 | −2.056% |
全部 12 个请求完成,完整输出文件 hash 与各自模式的参考一致。负损失表示换出略快;我们把它理解为这套负载下基本打平,不把它外推成换出必然加速。
这个结果支持的是单路长历史 decode。初始 prefill、追加输入、多会话容量和并发吞吐还需要各自验证。
容量优化必须通过会话生命周期
一次连续生成跑通,还不足以支撑真实对话。用户会继续追加输入,MTP 会拒绝草稿并回滚,会话还会保存和恢复。
成功路径的完整性边界是:新增 KV 可以先在 GPU recent 环和在途回传中;**导出或恢复全量历史之前,必须补交日志、等待回传,并确认 CPU 行数追上逻辑行数。**不能把已提交的异步复制当作已经落入 RAM。
大段 append prefill 又与 decode 不同。一次要处理大量新行,当前实现会先从 RAM 恢复所需全历史 GPU MLA KV,完成追加,随后 decode 再转回热缓存。小尾块不能被误判成 verify,owner 和 peer 也必须保持一致。
第一次真实大段 append 没通过,暴露的却是 MTP terminal hidden:收尾保存了原始 BF16 残差,后续路径期待 final norm 后的 F32 hidden。修复统一了归一化边界,并处理旧缓存恢复。
v25 随后完成同版本对照:命中 50,085 行历史,追加 9,854 token,最终 prompt 为 59,939 token,生成 1,024 token。完整输出一致。
| 大段 append 对照 | 换出 | GPU 常驻 |
|---|---|---|
| 首 token 时间 | 17.905 s | 18.350 s |
| Decode | 26.850 token/s | 27.004 token/s |
单组 decode 损失 0.57%。这说明换出后的长对话可以继续追加,但它仍是单组追加验收,不替代前面的六组单路测试。
十路实验:逻辑上省了显存,实际 allocation 仍能把卡撑满
准备十份独立 50K 历史时,第六份 prefill 在尾节点 OOM。1 Hz 采样最高约 47.98 GiB,还没有进入十路共同 decode。
问题在通用 GPU 内存池。小的长期 KV、热缓存和会话工作区,可以 best-fit 借到 prefill 释放的大块 allocation,并长期占住。逻辑上只用几十 KiB 或 MiB,物理上却可能扣留远大于此的空间;统计又只报逻辑字节,进一步低估占用。
因此我们给长期缓存分配加上精确容量限制,主要 KV/DSA 统计改用实际 allocation 大小,短期 activation 保留原池策略。测试先用大块污染内存池,再验证小热缓存不会占住那些大块。
修复后,v28 完成十份长历史及后续十路 append,整组实际吞吐 98.56 token/s,采样观察到的显存最高约 45.12 GiB。
接着调整并发调度:MTP 深度改为 1 后,完整请求吞吐到 125.26 token/s;每段 decode 合批上限从 4 改为 1,达到 145.67 token/s。后一组与前一组输入、完整输出及 usage 一致。多路工作可以持续推进流水线,单路有效的深推测和较大合批,不一定适合十路。
再单独放宽 A0 的已就绪工作合批上限,得到 145.28 token/s,基本持平,没有证据支持保留,因而撤去该改动。
这些并发数字按实际 completion token 总数除以整个 append 请求墙钟计算,包含请求首尾;没有把 SSE 文本事件数直接当成 token 数。十路结果也没有 GPU 全常驻的对应性能对照,不能用它宣称并发换出无损。最新并发候选尚需回归单路门禁。
容量收益:从总共约 1M,走向 15+ 路各自 1M
把收益落到使用场景上:原来的总预算约 1M token,如果一位用户带着百万 token 历史,这份预算就基本被占满。换出后,我们希望让 15+ 位这样的用户同时处于 decode,每一路都保有自己的百万 token 历史。
| 维度 | 原来的容量口径 | 换出后的容量估算目标 |
|---|---|---|
| 所有活跃会话的历史总量 | 约 1M token | 15M+ token |
| 每路携带 1M 历史时的规模 | 约 1 路 | 15+ 路同时 decode |
| MLA 全历史的主要存储位置 | GPU 显存 | 每节点 1 TiB 主机内存 |
| GPU 上的 MLA 历史数据 | 随历史长度增长 | 每路保留 32K 热缓存,按需读取旧位置 |
这就是“容量几乎放开了”的工程含义:在相同 GPU 硬件上,把可承接的活跃长历史规模推向 15+ 倍。它扩展的是多会话的历史容量;15+ 路同时生成时的单路速度,仍由计算、索引、传输和调度共同决定。
15 路百万历史,RAM 装得下吗?
按本文相同的主模型 MLA 格式,取 1M = 1,048,576 token,78 层、owner/peer 两份副本:
单路 1M 历史 = 1,048,576 × 656 B × 78 × 2
≈ 99.94 GiB
15 路合计 ≈ 1,499.06 GiB(约 1.464 TiB) 主模型 MLA 历史
两台主机 RAM 合计 = 2 TiB
这里必须按两台机器合计 2 TiB 计算,不能把全部历史算到一台 1 TiB 主机上。MLA 历史跟随所属层分散驻留;owner/peer 双副本已经包含在上述数字中,也不意味着每台都保存完整模型的全部历史。
以 15 路为例,主模型 MLA 历史合计约 1.464 TiB;按两节点近似均分,每台约 0.732 TiB。实际按各节点负责的层数分配,仍需逐节点核算余量。**从主模型 MLA 历史载荷看,15 路并没有用尽双机内存,容量可以继续向 15+ 路扩展。**这是编码尺寸推导的容量依据,尚不是整机内存峰值或最大并发路数实测。
与此同时,15 路的 32K 热缓存,主模型两份副本跨全部 GPU 的载荷合计约 46.85 GiB。这一项不随每路历史从 50K 增长到 1M 而同比增长,正是容量扩展的关键。
完整服务预算还要加上 GPU DSA 历史、token 映射、MTP 层、各会话工作区、分配与注册开销,以及 prefill/大段 append 恢复全历史时的瞬态显存。因此,RAM 能容纳这些 MLA 字节,是 15+ 路目标的容量基础,还不是整套系统在这个规模下的验收结论。
目前完成的速度与生命周期实测是:50K 单路近乎无损、约 60K 大段追加,以及十份 50K 长历史的完整并发请求。**下一步最有价值的容量验收,就是把每路历史推到 1M、把活跃路数推到 15+ 路,同时记录真实内存占用、首 token 延迟和持续 decode 吞吐。**当前的 1M admission 配置也要与新的总容量目标匹配。
最后留下的决策
| 路线或判断 | 处理结果 | 决定性证据 |
|---|---|---|
| CPU 完成最终 DSA selection | 本轮退回 | 速度下降,固定贪心输出分叉 |
| MLA 历史放 RAM,GPU 保留热工作集 | 保留 | 六组单路对照通过,完整输出一致 |
| CPU 主持每次 selection 的热槽管理 | 主路径转向 GPU 管理 | 回读等待打断提交链 |
| 增加更多逐行异步拷贝 | 否定 | 操作数增多,吞吐反而下降 |
| 批量日志、packed owner/peer 复制 | 保留 | 减少重复处理,补齐单行与 MTP 路径 |
| 锁迁移或日志双写足以填平差距 | 否定 | 实现后完整吞吐没有量级改善 |
| kernel 占比高,所以回退一定在 kernel | 撤回 | 绝对占比不能解释常驻/换出差额 |
| 简化 GPU 置换 | 保留,但不夸大收益 | 压力探针明显改善,完整模型未同步提速 |
| 只读权重深拷贝改为共享 | 保留 | 同版本完整对照差距收窄 |
| 只关 NUMA 自动平衡就足够 | 否定“足够” | 重复门禁仍失败 |
| 预触注册尾页就是长尾解法 | 否定 | 秒级停顿与 7.35% 失败组仍在 |
| 统一 NUMA、关闭主动整理后验收 | 本机保留 | 内核路径证据与六组交错对照支持 |
| 小逻辑 buffer 就等于小显存占用 | 否定 | 十路准备 OOM,实际 allocation 超额 |
| 并发时继续增加 A0 合批 | 本次撤去 | 完整吞吐基本持平 |
这轮工程最值得复用的,是把历史容量、当前工作集和控制依赖分别处理。RAM 提供容量,GPU 热缓存维持局部访问,GPU 自己消费 selection,批量日志维护新增历史;主机和驱动的内存行为,也必须进入端到端测量。
每一次转向都有一个共同标准:完整请求有没有变好,输出是否一致,失败能否复现。沿着这个标准,我们才把“旁边还有 1T 内存”的硬件余量,变成了可用的长历史容量,并把目标从总共约 1M 推向 15+ 路各自 1M 的同时解码。
资料与口径:依据 zLLM 工程记录 docs/glm53-cpu-kv-decode.md(2026-09-05 至 09-08)、双机基线记录 docs/glm53-rocm-amd12-baseline-20260906.md,并核对本地 GPU 热缓存实现。正式单路数据来自记录中的 cp0-v23 六组门禁;追加为 append-v25,并发为 concurrency-v28 与 concurrency-tuning。历史探索、诊断探针、正式配对和后续并发候选在文中分别标注,不能跨版本合并成同一条严格性能曲线。本文没有重新运行远端 benchmark。
