From 1291379b9fe11affc3321bc44eb72b93b926511a Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 25 Sep 2026 14:51:10 +0800 Subject: [PATCH] =?UTF-8?q?test(c-core):=20=E8=A1=A5=E8=B7=A8=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E5=BC=80=E9=94=80=E5=9F=BA=E7=BA=BF=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E6=95=B0=E6=8D=AE=E5=8F=8D=E9=A9=B3=E3=80=8CC=20?= =?UTF-8?q?=E6=AF=94=20Go=20=E5=BF=AB=E3=80=8D=E7=9A=84=E7=9B=B4=E8=A7=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 的主场,也是比「把短函数搬过去」更合理的下一步。 --- docs/zh/c-core/llm-orchestration-c.md | 53 +++++++++++-- internal/agent/api/codec_bench_test.go | 105 +++++++++++++++++++++++++ plan.md | 41 +++++++++- 3 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 internal/agent/api/codec_bench_test.go diff --git a/docs/zh/c-core/llm-orchestration-c.md b/docs/zh/c-core/llm-orchestration-c.md index 1fb81dd..40ca10a 100644 --- a/docs/zh/c-core/llm-orchestration-c.md +++ b/docs/zh/c-core/llm-orchestration-c.md @@ -275,13 +275,56 @@ C 化的正确性**不能靠「跑起来没崩」**,必须有可复现的对 - 放 SDK:天然跨端复用,但要走 SDK 冻结与大版本流程 2. `ha_json.c` 是**复用**(从 remotedevice 复制/提为公共)还是**新写**? 复用会动 SDK 目录结构。 -3. 是否接受「C 化后内核体积/构建复杂度上升」换取延迟确定性? - (需先有一个真实延迟基线,当前没有) -4. **下一个切片选谁**?L1 剩下的是协议编解码(`parseOpenAICompatible*`、 +3. **下一个切片选谁**?L1 剩下的是协议编解码(`parseOpenAICompatible*`、 `normalize*ToolCalls` 等,见 §三 L1 表);该层依赖 JSON 解析 ⇒ 先解第 2 题。 +### 7.1 ★ 跨语言开销基线(实测已补,2026-09-25) + +原 §七 写着「需先有真实延迟基线,当前没有」。现已补上 +(`internal/agent/api/codec_bench_test.go`,`go test -bench`): + +| 基准 | C(经 cgo)| 纯 Go | 谁快 | +|---|---:|---:|---| +| `ModelContextWindow`(短 ASCII)| 175 ns | 38 ns | **Go 快 4.6×** | +| `EstimateTokens` / 空串 | 100 ns | 0.43 ns | **Go 快 230×** | +| `EstimateTokens` / 短 ASCII | 115 ns | 6.5 ns | **Go 快 17×** | +| `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×** | +| `TruncateByTokens` / 1KB 中文 | 3923 ns | 6918 ns | C 快 1.8× | + +**结论(不要凭直觉,数据说话)**: + +1. **cgo 的固定开销约 95–100 ns/次**,小输入下完全压倒算法差异。 +2. C 只在**长中文**(rune 密集、UTF-8 步进重)上明显领先; + 长 ASCII 反而 Go 快 7×(Go 的 `utf8.RuneCountInString` 对 ASCII + 有快路径,而 C 侧逐字节跑)。 +3. ⇒ **「C 比 Go 快」是错的**;正确表述是「在特定输入分布上更快」。 + +**对函数的建议(按调用分布)**: + +- `ModelContextWindow`:调用点单一(`provider.go:333`,每请求一次), + 且输入是**短 ASCII** ⇒ 拿不到收益。但它应该是**冷路径**, + 175 ns 在单次请求尺度上无关痛痒——关键是别把它放到循环里。 +- `EstimateTokens`:**真正的高频点**在 `process.go:476` 的逐事件循环 + (对每条上下文事件算 `Source + Input + 40`)与 `resident.go:552` + (对每条上下文算 `Input + Response`)。字段分布**不单一**: + - `Source` 是短标签(`"qq"` / `"webui"`)⇒ 属 Go 快 17× 那一档 + - `Input` / `Response` 是对话文本,长度跨度大:长中文 C 快 2–3.4×, + 短文本与长 ASCII 则 Go 快 3–7× + ⇒ **没有单一答案**:当前一刀切走 C 会让短串净亏。 + 正确做法是**按长度分派**(短走 Go、长中文走 C), + 但需先用真实长度分布复测——不要凭推测动手。 +- `TruncateByTokens`:调用点单一(`tooldefs.go:38`),非热路径。 + +**这不否定 C 化方向**,但把「选谁下一个 C 化」的判据从「哪个函数看起来底层」 +换成「**哪个在真实输入分布下真能变快**」。协议编解码(JSON 解析、SSE 分片) +处理的正是**长文本**——那才是 C 的主场,也是下一步更合理的候选。 + --- -*实测记录:双路径、缓存跟踪、交叉编译、变异测试均于 2026-09-25 在本仓实测; -工具链与 C 资产核实于本仓* +*实测记录:双路径、缓存跟踪、交叉编译、变异测试、跨语言基准均于 2026-09-25 +在本仓实测;工具链与 C 资产核实于本仓* *初版核实:2026-09-24 · 落地更新:2026-09-25* diff --git a/internal/agent/api/codec_bench_test.go b/internal/agent/api/codec_bench_test.go new file mode 100644 index 0000000..fad7808 --- /dev/null +++ b/internal/agent/api/codec_bench_test.go @@ -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)]) + } +} diff --git a/plan.md b/plan.md index 2c845f4..c21839e 100644 --- a/plan.md +++ b/plan.md @@ -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 的主场,也是比「把短函数搬过去」更合理的下一步。 ### 已定事项(不再挂账)