mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 12:23:23 +00:00
perf(api): SSE 导航层返工 —— 5+ 次边界压成 1 次(三项赢,仍默认关闭)
按 sse-codec-c.md §6.4 的架构改造方向返工。**部分成功**:从「五项全输」
变成「三项赢 / 一项持平 / 一项输」,且所有场景分配数都下降。
## 改造内容
| 项 | 前 | 后 |
|---|---|---|
| cgo 边界次数 | 5+(每字段一次 findKey) | 1(ha_sse_chunk_locate) |
| 键查找 | 每键各扫一遍对象(6 趟) | 单趟分派(遍历成员表一次即分发) |
| 解码 | 每字段一次往返 + 各自 decBuf | 同一趟内写进一块 sbuf(1 次分配) |
| 成员表遍历 | 6 趟 | 2 趟(顶层 + delta) |
顺带修掉两处自造的浪费(都是「先扫一遍拿个数、再扫第二遍拿首元素」):
choices 数组的「数个数 + 取首元素」合一趟;choice0 内的 delta/finish_reason
合一趟。成员遍历实测 107ns/趟,省一趟就是省 107ns。
新增 ha_sse_chunk_locate:一次调用完成根校验 + 顶层分派 + choices[0] +
delta 分派 + content/reasoning/finish 解码,输出写调用方持有的 C 结构体
(C 结构体无 Go 指针 ⇒ 可安全传指针,消除 out-param 逃逸)。
choices_count>1 时直接回退(Go 侧 Unmarshal 会解析全部元素,本层只认 [0],
其余元素可能类型不符而让 Go 整块作废 ⇒ 无法保证等价)。
## 实测(50000 次 × 3 轮取中位)
| 场景 | Entry | GoOnly | 判定 |
|---|---|---|---|
| content_zh | 1540ns / 5allocs | 1871ns / 13allocs | 快 18%,分配 -62% |
| content_ascii | 1250ns / 5allocs | 1304ns / 13allocs | 持平,分配 -62% |
| finish | 820ns / 6allocs | 921ns / 12allocs | 快 11% |
| usage | 2530ns / 9allocs | 2591ns / 12allocs | 持平偏快 |
| toolcall | 3450ns / 20allocs | 3000ns / 21allocs | 慢 15% |
## toolcall 仍输的根因(已定位,非猜测)
分解测量:C 侧纯 C 零边界 = 766ns;Go 侧 []openAIToolCall unmarshal =
1305ns/15allocs;对照 Go 整块 unmarshal ≈ 2980ns。
问题在第二行:tool_calls 元素是对象,Arguments interface{} 需要真实的
map[string]interface{},必须走 encoding/json 的反射建树。
而为了定位已先做了一遍 C 扫描 ⇒ 同一份数据被解析了两次。
⇒ 不是 C 慢,是「扫两遍 vs 扫一遍」。
标量字段(content/reasoning/finish)C 能一次到位 ⇒ 那些场景赢;
需要建树的字段(tool_calls/usage)C 的定位是纯开销。
## 为什么仍默认关闭(理由充分,不是保守)
1. toolcall 是真实负载最常见的一类块(任何一次工具调用流),仍慢 15%
2. 18% 收益不足以抵消「与 encoding/json 语义并存的第二实现」的风险
3. 本刀原始动机在 toolcall 场景没有兑现:分配数 20 vs 21 几乎没降
⇒ 前提是先做「按字段类型决定是否 C 化」,让 toolcall 也不输,再重测。
## 正确性
6 万+ 差分用例(协议形态/真实负载/随机 JSON 3 万/随机字节 3 万)全过。
基准测量也修了:先前 C 基准脚本用 CLOCK_MONOTONIC 却只取 tv_nsec,
算出 -4201ns 的负值 —— 测量工具本身出错会直接毁掉结论。
## 验证
ASan+UBSan PASS;gcc+clang 零告警;arm64 交叉 0 告警;
libFuzzer 66 万次零崩溃;全量 go test 38 包 ok / 0 FAIL
This commit is contained in:
@ -354,6 +354,73 @@ C 层因此**无需**理解重复键的合并语义(5.1)、**无需**实现
|
||||
> 第一刀推翻过一次(`C.CString` 造成 82% 自找开销),这一刀又推翻一次
|
||||
> (逐字段往返造成 5+ 次边界)。两次都是**测量**推翻了直觉。
|
||||
|
||||
## 七、第三刀返工:批量定位(把 5+ 次边界压成 1 次)—— **部分成功,仍默认关闭**
|
||||
|
||||
§六 的否定结论指出根因是「逐字段往返」。本节按 §6.4 做架构改造并重测。
|
||||
|
||||
### 7.1 改造内容
|
||||
|
||||
| 项 | 改造前 | 改造后 |
|
||||
|---|---|---|
|
||||
| cgo 边界次数 | **5+**(每字段一次 findKey) | **1**(`ha_sse_chunk_locate`) |
|
||||
| 键查找方式 | 每个键各扫一遍对象(6 趟) | **单趟分派**(遍历成员表一次就分发) |
|
||||
| 解码 | 每字段一次往返 + 各自 decBuf | 同一趟内解码进**一块** sbuf(1 次分配) |
|
||||
| 成员表遍历 | 6 趟 | **2 趟**(顶层 + delta) |
|
||||
|
||||
顺带修掉两处自己造的浪费(都是「先扫一遍拿个数、再扫第二遍拿首元素」):
|
||||
`choices` 数组的「数个数 + 取首元素」合一趟;`choice0` 内的
|
||||
delta/finish_reason 合一趟。
|
||||
|
||||
### 7.2 实测(50000 次迭代 × 3 轮,取中位;`benchtime` 与机器同前)
|
||||
|
||||
| 场景 | 改造后 Entry | 原实现 GoOnly | 判定 |
|
||||
|---|---:|---:|---|
|
||||
| content_zh | **1540** ns / 5 allocs | 1871 ns / 13 allocs | ✅ **快 18%**,分配 -62% |
|
||||
| content_ascii | 1250 ns / 5 allocs | 1304 ns / 13 allocs | ⚠ 持平,分配 -62% |
|
||||
| finish | **820** ns / 6 allocs | 921 ns / 12 allocs | ✅ 快 11% |
|
||||
| usage | 2530 ns / 9 allocs | 2591 ns / 12 allocs | ✅ 持平偏快 |
|
||||
| toolcall | **3450** ns / 20 allocs | **3000** ns / 21 allocs | ❌ **慢 15%** |
|
||||
|
||||
**从「五项全输」变成「三项赢 / 一项持平 / 一项输」**,且**所有场景的
|
||||
分配数都下降**(13→5、12→6、12→9)。
|
||||
|
||||
### 7.3 为什么 `toolcall` 仍输(根因已定位)
|
||||
|
||||
分解测量:
|
||||
|
||||
| 组成 | 成本 |
|
||||
|---|---:|
|
||||
| C 侧一次 `ha_sse_chunk_locate`(纯 C,零边界零分配) | **766 ns** |
|
||||
| Go 侧 `[]openAIToolCall` unmarshal | **1305 ns / 15 allocs** |
|
||||
| 对照:Go 整块 unmarshal(一次搞定) | ~2980 ns |
|
||||
|
||||
问题在第二行:tool_calls 的元素是**对象**,`Arguments interface{}` 需要
|
||||
真实的 `map[string]interface{}`,所以**必须**走 encoding/json 的反射建树。
|
||||
而我们为了定位又先做了一遍 C 扫描 —— 于是「扫两遍」必然慢于「扫一遍」。
|
||||
|
||||
⇒ **这不是 C 慢,是「同一份数据被解析了两次」**:
|
||||
C 负责定位(读一遍),encoding/json 负责建树(再读一遍)。
|
||||
对**标量**字段(content / reasoning / finish)C 能一次到位,所以那些场景赢;
|
||||
对**需要建树**的字段(tool_calls / usage)C 的定位是纯开销。
|
||||
|
||||
**解法(下一步)**:tool_calls / usage 命中时**完全跳过 C 定位**,
|
||||
直接让 encoding/json 整块处理 —— 也就是「**按字段类型决定要不要 C 化**」。
|
||||
这需要一次「试解析」来判断字段是否需要建树,或改为「先看顶层键集合再决策」。
|
||||
|
||||
### 7.4 当前状态:仍默认关闭
|
||||
|
||||
「五项全输」→「三项赢一项输」,不足以打开默认开关,理由:
|
||||
|
||||
1. **toolcall 是真实负载里最常见的一类块**(任何一次工具调用流),
|
||||
而它仍慢 15%。在真实会话里,工具调用往往比纯文本多。
|
||||
2. **收益幅度不足以抵消风险**:18% 的时间收益 vs 引入一层
|
||||
与 `encoding/json` 语义并存的第二实现。而 tool_calls 路径的
|
||||
分配数几乎没降(20 vs 21)—— 本刀的原始动机(消除 GC 抖动)
|
||||
在最需要它的场景**没有兑现**。
|
||||
|
||||
⇒ 继续做的前提是**先把 §7.3 的解法做掉**(按字段类型决定是否 C 化),
|
||||
让 toolcall 也不输,再重测。届时再决定是否开启。
|
||||
|
||||
### 已知边界(诚实记录)
|
||||
|
||||
- `ha_json_get_int` 返回 `long long`;Go 侧 usage 字段是 `int`(64 位平台相同,
|
||||
|
||||
Reference in New Issue
Block a user