第 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 章的收口节,讲清两件事。第一,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的注释用意大利语写,反映作者母语习惯。本节直接给中文翻译 + 关键原文对照,帮助读者理解设计动机。
omp_tune.h 头部 39 行注释的两个核心点:为什么只做 dimensionamento 不做 spin-wait。第 8 章 02 节结尾已经摆明:GPU 专家驻留的目标是"decode 期省磁盘 IO"。但 Colibrì 在文档里反复强调一个诚实假设:
重叠(overlap)隐藏的是"传输等待",不是"移动瓶颈"本身。
把这句话翻译成工程语言:
这条边界很重要,因为有一个反面情况:快 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。
正因为上面这点,Colibrì 把 GPU 调优做成受控实验:
这套纪律把"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-waitomp_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。给它加上是可测的恶化。
核心论点:
omp_tune.h 只给前者(Kimi K3 和 OLMoE 这两个 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 默认",绝不发明一个数。
第 8 章还要把两个相关配置串起来:
COLI_NUMA=1:NUMA 内存交错。多 socket 系统上,RAM 分属不同内存控制器,默认分配可能让某个 CPU 跨 socket 访问远端 RAM(慢)。COLI_NUMA=1 启用 NUMA 感知分配,让数据均匀分布在所有控制器的带宽上。第 4 章已展开,这里强调它和 OpenMP 团队规模是"同一组 CPU 侧优化"。CUDA_EXPERT_GB=auto + PIN_GB=all):所有专家权重驻留显存,decode 完全不走磁盘——但要求显存装得下;组合的盈利性正是本节开头那句"depends on compute/bandwidth/residency/workload":
把第 8 章三节拼起来,Colibrì 的异构执行哲学:
omp_tune.h 只做线程数,不做 spin-wait;COLI_NUMA=1 只在多 socket 上生效;失败优雅退回默认。这套组合让 Colibrì 在"5 万行 C / 5 个模型引擎 / 3 个 GPU 后端"的复杂度下,保持每条优化都可证伪、可关闭、可 A/B——这是 Colibrì 区别于"硬编码 GPU 加速"的工程姿态。
omp_tune.h 只做"线程数 = 物理核"(低风险,#718 +2.3×),不做 spin-wait(高风险,#707 -2.2×、#341 烧 3000% CPU);Kimi K3 / OLMoE 这种 disk-bound 引擎尤其不能加 spin-wait。#325 教训:静默回退到 1 钉死单核)。OMP_WAIT_POLICY 要 re-exec(被 libgomp 构造函数读),omp_set_num_threads() 运行时立即生效——"安全的那一半也是简单的那一半"。COLI_NUMA=1 多 socket 上交错内存;Colibrì 把异构执行做成 per-stage、one-variable、token-exact A/B 实验。下一章我们离开引擎内部,看 Colibrì 怎么把这套机制交付到用户手里——从
coliCLI 和resource_plan.py开始。