从“假智能”到“真闭环”:Go 向量推理引擎的零硬编码调优实践

如何用爬山算法让 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 核心数据流

  1. 起点:优先读取上次收敛点(跨进程继承),首次启动使用生产已验证的保守起点。
  2. 观测 :每批推理上报 Observe(texts, duration),攒满窗口(≥8 批且 ≥5s)结算一次档位吞吐,用 EMA 平滑。
  3. 爬山:按确定性顺序做邻域试探,接受提升 ≥5% 的新最优点,否则保持原状并标记该方向已试。
  4. 收敛 :当所有方向试探完毕,当前最优点即为收敛解,持久化到 infer_profile.json
  5. 监控:收敛后持续监控吞吐,若跌破最优值 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 切换,就出事了:

  1. CPU 模式下收敛到 {Workers=3, Threads=3}
  2. axisDone 标记了 Workers 和 Threads 都已完成
  3. 切换到 GPU(DML)后,系统判断"所有轴都已完成" → 直接全局收敛
  4. 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 个值)
  • 单次试探成本低(秒级,分钟级勉强可接受)
  • 有明确量化指标(吞吐量、延迟)
  • 参数变更可热回退(不影响系统稳定性)

不适合:

  • 参数空间巨大(需贝叶斯优化/强化学习)
  • 单次试探代价极高(小时级,窗口期不可接受)
  • 参数变更不可回退(试探失败会破坏系统状态)

九、总结

这次重构的核心收获:

  1. 参数调优不该靠公式推导,要靠运行时实测。 机器自己跑出来的最优值,比任何离线公式都可靠。
  2. 爬山算法足够好用。 不需要上贝叶斯优化这种重型武器,只要判据清晰(提升 ≥ 5% 就接受),模式搜索就能收敛到足够好的解。
  3. 环境变更必须重探所有维度。 全局状态是最大的陷阱------CPU 下收敛的参数在 GPU 下可能完全不适用。
  4. 确定性行为是单测的基石。 成对试探 + 固定顺序让调优轨迹可复现,单测才能写死断言,才能放心重构。
  5. 跨进程继承要考虑环境指纹。 版本号不够,需要包含运行时的实际能力(指令集、依赖版本)。

最后,这套设计并不局限于向量推理。任何需要运行时调优的并行系统------连接池大小、批处理尺寸、甚至 IO 并发度------都可以用同样的思路构建闭环调优层。关键只有一条:

让机器自己跑出答案,而不是告诉它答案应该是什么。


相关推荐
SamDeepThinking1 小时前
不改逻辑、不拆方法:仅靠「调整代码顺序」提升可读性
java·后端·程序员
泡海椒1 小时前
适配老旧项目:JQuick-Java兼容Java8+环境改造迁移实战指南
后端
IT_陈寒1 小时前
Java的HashMap竟然不是线程安全的,现在才知道!
前端·人工智能·后端
IT_陈寒1 小时前
React hooks闭包陷阱让我加了一宿班
前端·人工智能·后端
不一样的少年_1 小时前
设计稿里的图片明明很清晰,为什么到了手机上却糊了?一文讲透 DPR、压缩与格式选择
前端·后端·图片资源
卷无止境1 小时前
当"精简主义"住进AI编程助手:Ponytail深度解读
后端·python·fastapi
兔子零10241 小时前
我给 Pi Coding Agent 做了一个桌面控制台:Pi-Harness
前端·javascript·后端
名字还没想好☜1 小时前
Go 的 TCP 粘包与拆包:用长度前缀协议 + bufio 正确读消息
后端·tcp/ip·golang·go·php
用户298698530141 小时前
Python 实现 Word 文档转 PDF 的自动化方案
后端·python·api