第 8 章 · 03 CPU/GPU 重叠与 OpenMP 调优


文档摘要

第 8 章 · 03 CPU/GPU 重叠与 OpenMP 调优 本节摘要:本节是第 8 章的收口节,讲清两件事。第一,CPU/GPU 重叠隐藏的是"传输等待",不是"移动瓶颈"本身——Colibrì 用诚实假设审视:快 CPU + 低专家驻留可能把 GPU 收益抹平,所以每个 stage 都要 per-stage profile + one-variable A/B。第二, 的取舍:只做"线程数 = 物理核"这种低风险调优,不做 spin-wait 改动——因为后者在 disk-bound 引擎上会偷 IO 池的核。本节还会把 partial/full expert residency 的组合、NUMA 内存交错一起讲清楚。

第 8 章 · 03 CPU/GPU 重叠与 OpenMP 调优

本节摘要:本节是第 8 章的收口节,讲清两件事。第一,CPU/GPU 重叠隐藏的是"传输等待",不是"移动瓶颈"本身——Colibrì 用诚实假设审视:快 CPU + 低专家驻留可能把 GPU 收益抹平,所以每个 stage 都要 per-stage profile + one-variable A/B。第二,omp_tune.h 的取舍:只做"线程数 = 物理核"这种低风险调优,做 spin-wait 改动——因为后者在 disk-bound 引擎上会偷 IO 池的核。本节还会把 partial/full expert residency 的组合、NUMA 内存交错一起讲清楚。

内容来源:原项目源码 c/omp_tune.h(头部 39 行注释 + 实现)、c/backend_*.c*SUMMARY.md

⚠️ 注意:omp_tune.h 的注释用意大利语写,反映作者母语习惯。本节直接给中文翻译 + 关键原文对照,帮助读者理解设计动机。

学习目标

  1. 解释 CPU/GPU 重叠"隐藏传输而非消除移动瓶颈"的含义。
  2. 读懂 omp_tune.h 头部 39 行注释的两个核心点:为什么只做 dimensionamento 不做 spin-wait。
  3. 理解"数物理核,数不出来就退回 OpenMP 默认"的失败安全规则。
  4. 区分 partial residency 与 full expert residency 的适用场景。
  5. 用 one-variable A/B 思路审视 NUMA、PCIe、unified memory、full-resident 四种硬件配置。

一、CPU/GPU 重叠:隐藏传输而非移动瓶颈

第 8 章 02 节结尾已经摆明:GPU 专家驻留的目标是"decode 期省磁盘 IO"。但 Colibrì 在文档里反复强调一个诚实假设:

重叠(overlap)隐藏的是"传输等待",不是"移动瓶颈"本身

把这句话翻译成工程语言:

  • 如果某个 token 的某层专家不在显存(部分驻留 partial residency),你需要把它从 RAM/磁盘搬到 GPU 才能算;
  • 搬运期间,如果你能让 CPU 在另一边干别的活(比如算另一层的 CPU 路径、prefetch 下一个 token 的路由),那"等待搬运完成"的时间就被隐藏;
  • 但搬运本身发生的字节数没变——它仍是带宽消耗,只是不和算力串行。

这条边界很重要,因为有一个反面情况:快 CPU + 低驻留可能抹平 GPU 收益。具体场景:CPU 算得很快,专家大部分不在显存,每次 GPU 调用都要等一次搬运;如果搬运时间 ≥ CPU 直接算的时间,GPU offload 就是负收益(还多了编程复杂度)。

💡 深潜要点:Colibrì 不假设"GPU 总比 CPU 快"。它在 SUMMARY.md 和 PR 笔记里反复写:the profitable combination depends on compute/bandwidth/residency/workload——盈利的组合取决于算力/带宽/驻留/workload 的具体数字。每个 stage 都要单独 profile。

二、one-variable A/B:Colibrì 的实验纪律

正因为上面这点,Colibrì 把 GPU 调优做成受控实验:

  • one-variable A/B:每次只改一个变量(比如只改"PCIe vs unified memory vs full-resident"),其他全固定,前后测端到端时间;
  • per-stage profiles:每个 stage(prefill、decode、attention、expert MLP)单独计时,而不是只看总时间;
  • token-exact correctness gate:每次 A/B 都跑金标准 token 序列,任何位置偏差 = 失败,不管快多少。

这套纪律把"GPU 是不是真省时"变成可证伪命题。PR 笔记里能看到的例子:

  • #718:只改 OMP_NUM_THREADS = 物理核,Zen3 5950X(16C/32T)+2.3×;
  • #707:加 spin-wait(active policy)在 M1 Max 32GB(~10% 专家驻留)上 decode -2.2×(变慢);
  • #116:Metal 上 -39%;#159:x86+CUDA 上 ~3×;#341:FreeBSD 空转团队烧 3000% CPU。

这些数字是 Colibrì 决定"omp_tune.h 只做哪些事"的直接依据。

三、omp_tune.h:只做 dimensionamento,不做 spin-wait

omp_tune.h 头部注释(译)把这件事讲得极清:

1 /* omp_tune.h — OpenMP 团队规模按物理核设定。 2 * 3 * 为什么只做"规模设定",不做 spin-wait。 4 * colibri.c 里的 tuning block 做两件风险相反的事,要分开: 5 * 6 * 规模设定 OMP_NUM_THREADS = 物理核(不要 SMT) 7 * #718: Zen3(5950X, 16C/32T)只改线程数就 +2.3×。 8 * 收益大到淹没大多数"这里提到的其他 delta"。 9 * 10 * spin-wait OMP_WAIT_POLICY=active, GOMP_SPINCOUNT, KMP_BLOCKTIME 11 * #707: 在低驻留 host(M1 Max 32GB,~10% 专家驻留)的 decode 上 -2.2× 12 * #116: Metal 上 -39% #159: x86+CUDA 上 ~3× 13 * #341: FreeBSD 团队空转烧 3000% CPU 14 * 机制:token 是磁盘字节凑出来的地方,空转团队偷 IO 池的核。 15 * 16 * Kimi K3 和 OLMoE 两个都没有。这里只给它们前者: 17 * Kimi 是项目里最 disk-bound 的引擎(测过:6.7% 命中,32 token 读 891GB), 18 * 即正好是后半段会闯祸的 regime。给它加上是可测的恶化。

核心论点:

  • **规模设定(线程数 = 物理核)**是低风险高收益——#718 实测 +2.3×,且收益"淹没其他 delta";
  • spin-wait 改动风险高——在 disk-bound 引擎上,空转的 OpenMP 团队会偷 IO 池的核,反而变慢(-2.2×、-39%)或烧 CPU(3000%);
  • 所以 omp_tune.h 只给前者(Kimi K3OLMoE 这两个 disk-bound 引擎尤其不能加 spin-wait)。

注释继续解释为什么前者不需要 re-exec:

29 * 为什么这里不需要 re-exec。 30 * colibri.c 之所以要 re-exec,是因为 OMP_WAIT_POLICY 等是被 libgomp 的 31 * 构造函数读的,早于 main():main() 里 setenv() 来不及。线程数则不同—— 32 * omp_set_num_threads() 是运行时 API,立即生效。 33 * 安全的那一半也是简单的那一半。

OMP_WAIT_POLICY 这种环境变量必须在 main() 之前设(被 libgomp 构造函数读),所以 colibri.c 要 re-exec 自己;而 omp_set_num_threads() 是运行时 API,直接生效——所以"安全的那一半"也"简单的那一半"。

四、失败规则:数不到物理核就别瞎猜

注释第 35-38 行(对照源码 35-38)给了一条 Colibrì 一以贯之的工程铁律:

如果物理核数数不出来,不猜——留给 OpenMP 默认。错的数比没有更糟。

源码实现 coli_count_windows_physical_cores()GetLogicalProcessorInformationEx,只数 RelationProcessorCore 记录,且严格校验 record 完整性——遇到零长度、截断、畸形 record 就 break 返回当前累计:

75 if (record_size < header_size || (size_t)(end - p) < record_size) 76 break; /* Reject a zero, truncated, or otherwise malformed record. */

注释明确点出 #325 的教训:一个静默回退到 1 的 fallback 把 decode 钉死在一个核上。所以正确做法是"数得到 → 用,数不到 → OpenMP 默认",绝不发明一个数

五、NUMA、partial/full residency 的组合

第 8 章还要把两个相关配置串起来:

  • COLI_NUMA=1:NUMA 内存交错。多 socket 系统上,RAM 分属不同内存控制器,默认分配可能让某个 CPU 跨 socket 访问远端 RAM(慢)。COLI_NUMA=1 启用 NUMA 感知分配,让数据均匀分布在所有控制器的带宽上。第 4 章已展开,这里强调它和 OpenMP 团队规模是"同一组 CPU 侧优化"。
  • partial residency vs full expert residency:
    • full residency(CUDA_EXPERT_GB=auto + PIN_GB=all):所有专家权重驻留显存,decode 完全不走磁盘——但要求显存装得下;
    • partial residency:只把热点专家驻留,冷专家仍走磁盘/DRAM——这是绝大多数消费硬件的现实配置。

组合的盈利性正是本节开头那句"depends on compute/bandwidth/residency/workload":

  • PCIe 离散显卡 + partial residency:GPU 收益被 PCIe 拷贝吃掉,需要 overlap 隐藏传输;
  • Unified memory(Apple / Strix Halo)+ partial residency:无 PCIe 拷贝,GPU offload 流式专家可盈利(第 8 章 02 节 Vulkan 路径);
  • full residency:decode 期磁盘退出主路径,GPU 收益最稳定,但要求显存预算。

六、heterogeneous execution:无银弹,只有 per-stage A/B

把第 8 章三节拼起来,Colibrì 的异构执行哲学:

  1. 统一接口,可选后端:CPU + CUDA + Metal + Vulkan 共享一份函数指针契约,编译开关决定哪些参与;
  2. 运行时加载,优雅退化:GPU 不在就纯 CPU,不崩;
  3. 诚实假设,per-stage A/B:不假设 GPU 必快,每个 stage 都单独测,token-exact 验证 + 端到端时间一起看;
  4. 低风险调优优先:omp_tune.h 只做线程数,不做 spin-wait;COLI_NUMA=1 只在多 socket 上生效;失败优雅退回默认。

这套组合让 Colibrì 在"5 万行 C / 5 个模型引擎 / 3 个 GPU 后端"的复杂度下,保持每条优化都可证伪、可关闭、可 A/B——这是 Colibrì 区别于"硬编码 GPU 加速"的工程姿态。

本节要点回顾

  • CPU/GPU 重叠隐藏的是"传输等待",不是移动瓶颈本身;快 CPU + 低驻留可能抹平 GPU 收益。
  • omp_tune.h 只做"线程数 = 物理核"(低风险,#718 +2.3×),不做 spin-wait(高风险,#707 -2.2×、#341 烧 3000% CPU);Kimi K3 / OLMoE 这种 disk-bound 引擎尤其不能加 spin-wait。
  • 失败规则:数不到物理核就退回 OpenMP 默认,绝不发明一个数(#325 教训:静默回退到 1 钉死单核)。
  • OMP_WAIT_POLICY 要 re-exec(被 libgomp 构造函数读),omp_set_num_threads() 运行时立即生效——"安全的那一半也是简单的那一半"。
  • partial/full residency 的盈利性取决于 compute/bandwidth/residency/workload;COLI_NUMA=1 多 socket 上交错内存;Colibrì 把异构执行做成 per-stage、one-variable、token-exact A/B 实验。

下一章我们离开引擎内部,看 Colibrì 怎么把这套机制交付到用户手里——从 coli CLI 和 resource_plan.py 开始。


作者与出处
原作者: 灏天文库
整理: 灏天文库整理
本站整理收录,版权归原作者/开源协议所有;欢迎通过原文链接访问源仓库。
发布者: 作者: 灏天文库 转发
评论区 (0)
U