Files
HomeAgent/internal/agent/api/codec_bench_test.go
JianFeeeee 5ccdf24f18 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 的主场,也是比「把短函数搬过去」更合理的下一步。
2026-09-25 14:51:10 +08:00

106 lines
3.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)])
}
}