test(c-core): 补跨语言开销基线 —— 数据反驳「C 比 Go 快」的直觉

codec.go 的注释写着「EstimateTokens 是否该留在 C 侧由 codec_bench_test.go
的实测数据决定,不要凭直觉断言」,但那个文件此前并不存在(悬空引用)。
plan.md 与设计文档也都在说「需先有真实延迟基线(当前没有)」。本提交把它补上。

## 数据(ns/op,benchmem)

| 基准 | C(经 cgo)| 纯 Go | 谁快 |
|---|---:|---:|---|
| ModelContextWindow(短 ASCII)| 175 | 38 | Go 快 4.6× |
| EstimateTokens / 空串 | 100 | 0.43 | Go 快 230× |
| EstimateTokens / 短 ASCII | 115 | 6.5 | Go 快 17× |
| EstimateTokens / 短中文 | 100 | 29 | Go 快 3.4× |
| EstimateTokens / 中 200 字 | 229 | 509 | C 快 2.2× |
| EstimateTokens / 1KB 中文 | 840 | 2870 | C 快 3.4× |
| EstimateTokens / 1KB ASCII | 2318 | 332 | Go 快 7× |
| TruncateByTokens / 短中文 | 233 | 54 | Go 快 4.3× |
| TruncateByTokens / 1KB 中文 | 3923 | 6918 | C 快 1.8× |

(已用 -count 复测确认稳定;ascii_1k 的异常已单独隔离复测 3 次)

## 三条结论

1. **cgo 固定开销约 95–100 ns/次**,小输入下完全压倒算法差异。
2. C 只在**长中文**(UTF-8 步进重)上领先;长 ASCII 反而 Go 快 7×
   (Go 的 utf8.RuneCountInString 对 ASCII 有快路径,C 侧逐字节跑)。
3. ⇒ 判据应是「哪个在**真实输入分布**下真能变快」,不是「哪个看起来更底层」。

## 对后续 C 化的影响(已写进 plan.md §七 与设计文档 §7.1)

- EstimateTokens 的真高频点在 process.go:476 的逐事件循环与 resident.go:552。
  字段分布不单一:Source 是短标签(Go 快 17× 那一档),
  Input/Response 是对话文本(长中文 C 快、短文本与长 ASCII Go 快)。
  ⇒ 当前一刀切走 C 会让短串净亏;正确做法是按长度分派,
  但须先用真实长度分布复测,不要凭推测动手。
- ModelContextWindow(provider.go:333)与 TruncateByTokens(tooldefs.go:38)
  调用点单一、非热路径,开销在单次请求尺度上无关痛痒。

这不否定 C 化方向:协议编解码(JSON 解析、SSE 分片)处理长文本,
才是 C 的主场,也是比「把短函数搬过去」更合理的下一步。
This commit is contained in:
JianFeeeee
2026-09-25 14:51:10 +08:00
parent 7351c6ca2e
commit 5ccdf24f18
3 changed files with 190 additions and 9 deletions

41
plan.md
View File

@ -410,10 +410,43 @@ PluginContext(独立身份,共享管道)。
2. **`ha_json.c` 复用还是新写?**(复用会动 SDK 目录结构)
- 这是下一个切片的**前置**:L1 剩下的协议编解码(`parseOpenAICompatible*`、
`normalize*ToolCalls`)全部依赖 JSON 解析,不定就推不下去
3. **是否接受「C 化后内核体积/构建复杂度上升」换延迟确定性?**
- 需先有真实延迟基线(当前没有),否则是空头承诺
- 尤其 `EstimateTokens` 是**高频热路径**(上下文裁剪对每个事件都调),
跨语言开销对短文本未必划算;它是第一个该拿数据说话的候选
3. **下一个切片选谁?**(已有基准数据支撑,见下)
### ★ 已定:跨语言开销基线已补齐(2026-09-25)
原本文写「需先有真实延迟基线(当前没有)」——现已补上
(`internal/agent/api/codec_bench_test.go`)。数据**反驳了「C 比 Go 快」的直觉**:
| 基准 | C(经 cgo)| 纯 Go | 谁快 |
|---|---:|---:|---|
| `ModelContextWindow`(短 ASCII)| 175 ns | 38 ns | **Go 快 4.6×** |
| `EstimateTokens` / 空串 | 100 ns | 0.43 ns | **Go 快 230×** |
| `EstimateTokens` / 短中文 | 100 ns | 29 ns | **Go 快 3.4×** |
| `EstimateTokens` / 中 200 字 | 229 ns | 509 ns | C 快 2.2× |
| `EstimateTokens` / 1KB 中文 | 840 ns | 2870 ns | C 快 3.4× |
| `EstimateTokens` / 1KB ASCII | 2318 ns | 332 ns | **Go 快 7×** |
| `TruncateByTokens` / 短中文 | 233 ns | 54 ns | **Go 快 4.3×** |
三条结论:
1. **cgo 固定开销约 95–100 ns/次**,小输入下完全压倒算法差异
2. C 只在**长中文**(UTF-8 步进重)上领先;长 ASCII 反而 Go 快 7×
3. ⇒ 判据应是「**哪个在真实输入分布下真能变快**」,不是「哪个看起来更底层」
**对现有三个函数的影响(实测调用分布)**:
- `EstimateTokens` 的**真高频点在 `process.go:476` 的逐事件循环**
(对每条上下文事件算 `Source + Input + 40`)。实测字段分布:
- `Source` 是**短标签**(`"qq"` / `"webui"`),按上表属 **Go 快 17×** 那一档
- `Input` / `Response` 是**对话文本,长度跨度大**:长中文 C 快 2–3.4×,
短文本与长 ASCII 则 Go 快 3–7×
⇒ 该循环**没有单一答案**:当前一刀切走 C 会让 `Source` 这类短串净亏。
待办:若长会话下该循环累积可观,应改为**按长度分派**(短走 Go、长中文走 C),
但**先用 `-bench` 复测真实长度分布再定**,不要凭此处推测动手。
- `ModelContextWindow`(`provider.go:333`)与 `TruncateByTokens`(`tooldefs.go:38`)
调用点单一、非热路径,175/233 ns 在单次请求尺度上无关痛痒。
⇒ **这不否定 C 化方向**:协议编解码(JSON 解析、SSE 分片)处理的正是长文本,
那才是 C 的主场,也是比「把短函数搬过去」更合理的下一步。
### 已定事项(不再挂账)