如何用爬山算法让 CPU/GPU 推理并行度自动收敛到当前机器的最优点
引子
去年我们做了一款本地 RAG 工具,向量索引是核心功能之一。用户的数据量从几百份到几十万份不等,硬件环境更是五花八门------有人用 8 核轻薄本,有人在 40 核服务器上跑,还有人插着 NVIDIA 显卡。
早期版本里,推理参数是静态公式推导的:
go
threads = min(cores, 8) // 最多 8 个线程
workers = 4 // 固定 4 路并发
结果就是:8 核机器和 40 核机器跑出完全相同的参数。 用户插着 3080 显卡,和纯 CPU 模式跑得一样慢。我们内部管这叫 "假智能"------看似根据硬件推导了参数,实则只是把硬编码换了个马甲。
用户给了我们一句非常直白的反馈:"我不要你们拍脑袋算出来的参数,我要机器自己跑出来告诉我。"
于是我们重新设计了向量推理的调度层,核心目标只有一条:
所有并行参数必须是运行时实测吞吐驱动的闭环产物,不能有任何离线公式。
最终我们实现了这套零硬编码的闭环调优系统。这篇文章会把实现思路、踩过的坑、以及验证方法完整分享出来。
一、问题拆解:需要动态调优什么?
向量推理主要涉及三个维度的参数:
| 参数 | 含义 | 为什么需要动态调 |
|---|---|---|
| EP(执行引擎) | CPU vs DirectML(GPU) | GPU 下的最优 Workers/Threads 与 CPU 完全不同 |
| Workers | 并发提交推理任务的协程数 | 受 CPU 核数、内存带宽、GPU 队列深度影响 |
| Threads | ONNX Runtime 的 intra-op 线程池大小 | GPU 推理下该参数基本无效,纯 CPU 下影响巨大 |
这三个参数之间存在耦合:GPU 下 Workers 可以更大,但 Threads 反而应该设小;CPU 下 Workers 受限于核数,Threads 却直接影响矩阵运算速度。
没有任何离线公式能提前算对。
原因很简单:
- 每台机器的散热/功耗墙不同(笔记本跑几分钟就降频)
- 有无 GPU,参数空间形状完全不同
- 同一台机器,插电和用电池,最优值也不一样
所以我们需要的不是一个公式,而是一个运行时观测闭环。
二、算法选型:为什么是"爬山"?
我们需要一个满足以下条件的调优算法:
- 零先验假设(不能用贝叶斯这种需要先验的)
- 参数空间小(3 维,每维 ~10 个档位)
- 不需要知道"全局最优在哪里",只需要"比现在好"
- 单次试探成本可控(几秒钟就能评估一轮)
最终我们选了 模式搜索(Pattern Search) ,也就是常说的爬山算法。
为什么不是网格搜索?
3 维 × 10 档 = 1000 组组合,每组跑几秒钟,就是几十分钟起。用户不可能等这么久。
为什么不是贝叶斯优化?
贝叶斯需要先验假设------高斯过程需要预设核函数和噪声模型,本质上还是"拍脑袋"。而且我们没法提前知道 CPU 模式和 GPU 模式下参数空间的平滑程度是否一致。
爬山为什么合适?
爬山只需要一条判据:"比当前最好提升 ≥ 5% 就接受"。
它不关心全局最优在哪里,只要每步都在变好就行。对于推理调优这种场景,足够好,远比"理论最优"更有价值。
三、闭环调优的核心实现
3.1 整体架构
scss
┌─────────────────────────────────────────────────────────────┐
│ Inference Tuner │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Profile │ │ Pattern │ │ Observer │ │
│ │ (持久化) │───▶│ Search │◀───│ (吞吐采样) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 跨进程继承(infer_profile.json) │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
3.2 核心数据流
- 起点:优先读取上次收敛点(跨进程继承),首次启动使用生产已验证的保守起点。
- 观测 :每批推理上报
Observe(texts, duration),攒满窗口(≥8 批且 ≥5s)结算一次档位吞吐,用 EMA 平滑。 - 爬山:按确定性顺序做邻域试探,接受提升 ≥5% 的新最优点,否则保持原状并标记该方向已试。
- 收敛 :当所有方向试探完毕,当前最优点即为收敛解,持久化到
infer_profile.json。 - 监控:收敛后持续监控吞吐,若跌破最优值 80%,自动触发重新爬山。
3.3 确定性邻域遍历(核心代码逻辑)
下面是爬山引擎最核心的试探逻辑------按固定顺序成对试探,保证可复现:
go
// 每维按 [+step, -step] 成对试探
// 双侧试尽后 step 倍增,直到双侧均越界才收敛
if !t.triedPos {
cand, ok := t.candidateAt(+1)
if !ok {
t.triedPos = true
return t.probeNextLocked() // 越界记已试
}
t.applyLocked(cand, "probe")
return
}
if !t.triedNeg {
cand, ok := t.candidateAt(-1)
if !ok {
t.triedNeg = true
return t.probeNextLocked()
}
t.applyLocked(cand, "probe")
return
}
// 双侧试尽 → 步长倍增,继续探索更远处
t.step *= 2
return t.probeNextLocked()
为什么强调"确定性"?
单测需要可复现。如果试探顺序是随机的,单测就没法写死断言。成对试探 + 固定顺序(先 + 后 -)让爬山轨迹可预测,调试成本大幅降低。
3.4 关键决策:EP 变更必须重开 Workers/Threads 轴
这是我们在单测里踩过的大坑。
早期实现里有一个全局 axisDone 标记------某维度一旦收敛,就标记为"已完成",不再试探。这在单 EP(纯 CPU)场景下没问题,但一旦引入 EP 切换,就出事了:
- CPU 模式下收敛到
{Workers=3, Threads=3} axisDone标记了 Workers 和 Threads 都已完成- 切换到 GPU(DML)后,系统判断"所有轴都已完成" → 直接全局收敛
- GPU 模式下最优的
{Workers=4}从未被试探
单测暴露了这个问题:TestInferTunerDMLPath 期望收敛到 {dml, 4, 3},实际收敛到了 {dml, 3, 3}。
修复方案:在 EP 切换时,强制重置 Workers 和 Threads 轴的试探状态,并把步长复位。关键代码逻辑如下:
go
// 接受新最优点时检测 EP 是否变更
if epChanged {
// 重置 workers/threads 轴的试探状态
t.axisState[AxisWorkers].reset()
t.axisState[AxisThreads].reset()
// 复位步长到初始值
t.step = t.initialStep
}
教训:环境维度的变更,必须审计所有已收敛维度的结论是否仍然有效。"全局已完成"的状态在引入新维度后,就是一颗定时炸弹。
3.5 退让样本剔除
系统里有一个"Governor"模块,会在系统忙(Busy)、内存低(MemLow)、暂停(Paused)时主动退让。
关键是:退让期间的吞吐数据不能用来评估并行度质量。
kotlin
if governor.IsThrottled() {
// 这段数据作废,不计入观测窗口
return
}
这避免了"因为系统忙导致推理慢 → 爬山误判为参数差 → 回退到更保守参数"的错误链。
四、跨进程继承:收敛点不该丢失
向量索引可能跑几十分钟甚至几个小时。如果每次重启都要重新爬山,用户会疯掉。
我们引入了 infer_profile.json 持久化机制:
json
{
"ep": "cpu",
"workers": 4,
"threads": 8,
"throughput": 92.5,
"fingerprint": "go1.22_onnx1.17.1_avx2"
}
关键设计 :fingerprint 字段包含了 Go 版本、ONNX Runtime 版本、CPU 指令集(AVX2/AVX512)。如果环境变了(比如升级了 ONNX 版本),指纹不匹配,系统会丢弃旧配置,重新爬山,避免"拿着旧地图找新大陆"。
为什么不用固定版本号?
因为用户可能手动升级 ONNX Runtime DLL,或者换了不同指令集的 CPU。用固定版本号无法感知这些变化,用编译时确定的指纹(指令集 + 运行时版本)更可靠。
五、验证:单测覆盖的核心场景
我们写了 10 个单测覆盖闭环调优的核心路径:
| 测试场景 | 验证内容 |
|---|---|
TestInferTunerPureCPU |
纯 CPU 环境下爬山能否收敛到最优 |
TestInferTunerDMLPath |
EP 切换后 Workers 轴是否重新试探 |
TestInferProfileSaveLoad |
收敛点持久化和跨进程继承 |
TestInferProfileMismatch |
指纹不匹配时丢弃旧配置 |
TestInferTunerReject |
试探失败后回退旧最优点 |
TestInferTunerBoundary |
边界值处理(不会越界死循环) |
TestInferTunerStepDoubling |
步长倍增逻辑 |
TestInferTunerConvergence |
收敛判定条件 |
TestInferTunerMonitorRetrigger |
吞吐跌破阈值后重新爬山 |
TestInferTunerThrottledSample |
退让期间数据不计入窗口 |
单测的吞吐模型是用模拟函数(非真实推理)构建的,目的是验证调优逻辑的正确性,而非测真实推理速度。例如:
go
// 模拟 dml 模式下 workers 影响吞吐,threads 不影响
tps := 300 - abs(workers-4)*20
这确保了调优逻辑在不同参数下能"看到"差异,从而驱动爬山决策。
六、踩过的坑 & 经验总结
坑 1:"已试"标记漏设导致死循环
在 probeNextLocked() 中,如果候选点越界,必须标记 triedPos = true。漏掉这条,会在越界时反复试探同一个方向,永远无法收敛。
坑 2:试探失败后未回退
试探一个参数组合可能因为 ONNX Runtime 异常而失败。如果失败后不把系统恢复到旧最优点,下一批推理就会运行在"已拒绝"的参数上,污染观测数据。
解法 :applyLocked 失败时,主动调用 rollbackLocked() 恢复现场。
坑 3:步长倍增后"中间步长"被跳过
如果当前步长是 2,倍增到 4,那么步长 3 对应的参数点从未被试探。这在低维空间可能导致遗漏最优值。
解法 :我们的算法是先试尽当前步长的 ± 方向,再倍增。步长 2 的 ± 试探完 → 步长倍增到 4 → 再试 ±4。步长 3 在递增过程中永远不会出现,但这是模式搜索的刻意设计------用稀疏步长加速收敛,代价是可能跳过局部最优。实践中,由于多维度联合搜索,单一维度的步长跳跃通常能被其他维度补偿。
七、实测效果
| 环境 | 优化前吞吐 | 优化后吞吐 | 提升 |
|---|---|---|---|
| 8 核笔记本(i7-10510U) | ~25 texts/s | ~45 texts/s | +80% |
| 40 核服务器 | ~25 texts/s(被公式限死) | ~110 texts/s | +340% |
| 8 核 + DirectML(RTX 3060) | ~25 texts/s | ~480 texts/s | +1820% |
笔记本的提升来自于爬山找到了适合散热受限环境的线程配置;服务器提升是因为解锁了 cores 限制;GPU 提升则是 EP 切换后参数重探的结果。
八、适用场景 & 限制
这套调优方案适合:
- 参数空间小(≤ 5 维,每档 ≤ 20 个值)
- 单次试探成本低(秒级,分钟级勉强可接受)
- 有明确量化指标(吞吐量、延迟)
- 参数变更可热回退(不影响系统稳定性)
不适合:
- 参数空间巨大(需贝叶斯优化/强化学习)
- 单次试探代价极高(小时级,窗口期不可接受)
- 参数变更不可回退(试探失败会破坏系统状态)
九、总结
这次重构的核心收获:
- 参数调优不该靠公式推导,要靠运行时实测。 机器自己跑出来的最优值,比任何离线公式都可靠。
- 爬山算法足够好用。 不需要上贝叶斯优化这种重型武器,只要判据清晰(提升 ≥ 5% 就接受),模式搜索就能收敛到足够好的解。
- 环境变更必须重探所有维度。 全局状态是最大的陷阱------CPU 下收敛的参数在 GPU 下可能完全不适用。
- 确定性行为是单测的基石。 成对试探 + 固定顺序让调优轨迹可复现,单测才能写死断言,才能放心重构。
- 跨进程继承要考虑环境指纹。 版本号不够,需要包含运行时的实际能力(指令集、依赖版本)。
最后,这套设计并不局限于向量推理。任何需要运行时调优的并行系统------连接池大小、批处理尺寸、甚至 IO 并发度------都可以用同样的思路构建闭环调优层。关键只有一条:
让机器自己跑出答案,而不是告诉它答案应该是什么。