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

View File

@ -0,0 +1,105 @@
package api
// codec_bench_test.go —— 编解码层的跨语言开销基线。
//
// 存在的理由:`codec.go` 的 EstimateTokens 注释写着「是否该留在 C 侧由
// codec_bench_test.go 的实测数据决定,不要凭直觉断言」。本文件就是那份数据。
//
// ============================ 为什么必须有 ============================
// C 化不是免费的:每次调用要走 cgo 边界(~50-100ns 固定开销)+ C.CString
// 分配/释放(O(n) 拷贝)。对**高频热路径**(上下文裁剪对每个事件都调),
// 短文本上这笔开销可能超过 C 实现省下的算术时间。
//
// 因此判据不是「C 比 Go 快」,而是「在真实输入分布下 C 是否更快」。
// 本基准跑 cgo 下的 EstimateTokens(走 C)与直调纯 Go 实现,给出分界点。
//
// 运行:go test -run XXX -bench BenchmarkEstimate -benchmem ./internal/agent/api/
// 注意:CGO_ENABLED=0 时 cgo 与纯 Go 是同一实现,对比无意义(差异应为 0)。
import (
"strings"
"testing"
)
// benchInputs 覆盖真实分布:短中文(裁剪查询)、长文本(预算计算)、ASCII。
var benchInputs = map[string]string{
"empty": "",
"ascii_short": "hello world",
"zh_short": "用户询问了系统状态",
"zh_200": strings.Repeat("这是一段中文文本。", 20),
"ascii_1k": strings.Repeat("x", 1024),
"zh_1k": strings.Repeat("中", 1024),
}
// BenchmarkEstimateTokensC 走 C 实现(经 cgo 边界 + CString 分配)。
func BenchmarkEstimateTokensC(b *testing.B) {
for name, in := range benchInputs {
b.Run(name, func(b *testing.B) {
b.SetBytes(int64(len(in)))
for i := 0; i < b.N; i++ {
_ = estimateTokensC(in)
}
})
}
}
// BenchmarkEstimateTokensPure 直调纯 Go 实现(同进程,无边界开销)。
// 与 C 版的差值即「跨语言开销 − C 实现省下的时间」。
func BenchmarkEstimateTokensPure(b *testing.B) {
for name, in := range benchInputs {
b.Run(name, func(b *testing.B) {
b.SetBytes(int64(len(in)))
for i := 0; i < b.N; i++ {
_ = estimateTokensPure(in)
}
})
}
}
// BenchmarkTruncateByTokensC 走 C(含 malloc/free 与结果拷贝)。
func BenchmarkTruncateByTokensC(b *testing.B) {
for name, in := range benchInputs {
if in == "" {
continue
}
b.Run(name, func(b *testing.B) {
b.SetBytes(int64(len(in)))
for i := 0; i < b.N; i++ {
_ = truncateByTokensC(in, 64)
}
})
}
}
// BenchmarkTruncateByTokensPure 直调纯 Go 实现。
func BenchmarkTruncateByTokensPure(b *testing.B) {
for name, in := range benchInputs {
if in == "" {
continue
}
b.Run(name, func(b *testing.B) {
b.SetBytes(int64(len(in)))
for i := 0; i < b.N; i++ {
_ = truncateByTokensPure(in, 64)
}
})
}
}
// BenchmarkModelContextWindowC 模型名映射(典型高频:每次预算计算)。
// 输入是短 ASCII,cgo 固定开销占比最高,是 C 化最可能「不划算」的场景。
func BenchmarkModelContextWindowC(b *testing.B) {
models := []string{"deepseek-v4.1-flash", "gpt-4-turbo", "qwen-max", "AUTO"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = modelContextWindowC(models[i%len(models)])
}
}
func BenchmarkModelContextWindowPure(b *testing.B) {
models := []string{"deepseek-v4.1-flash", "gpt-4-turbo", "qwen-max", "AUTO"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = modelContextWindowPure(models[i%len(models)])
}
}