mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 12:23:23 +00:00
上一提交的基准结论是错的:"C 比 Go 慢" 不成立 —— 那是我把自己的
malloc/拷贝开销误当成了 cgo 的固有成本。本提交先拆解成本、再逐项消灭。
## 成本拆解(同机、百万次 benchtime)
| 场景 | ns/op |
|---|---:|
| cgo 边界(零拷贝传指针 + 空函数体)| 31.9 ← cgo 真实固有成本 |
| + 一次 C.CString + C 侧 strlen | 105-111(多出 ~75ns)|
| 初版 ModelContextWindow(另加 lower_dup malloc + 16×strstr)| 175 |
即 82% 开销是自找的。而初版还违反了自己写在设计文档 §四 的原则第 1 条
「C 接口只吃 const char* + 长度」——它没传长度,让 C 侧 strlen 再扫一遍。
## 逐项修复
1. C.CString(malloc+整串拷贝)→ unsafe.StringData 传指针+长度,零拷贝
2. C 侧 strlen 再扫一遍 → 长度由调用方传入,不扫
3. truncate 的 malloc 输出缓冲 + GoStringN 拷回 → C 只返回**字节数**
(结果必然是输入前缀),Go 侧 s[:n] 完成切片,全程零分配
4. lower_dup 每次 malloc 模型名 → 栈缓冲折叠,超长走零分配回退
5. 逐字节 utf8_next 函数调用 → 字级(8 字节)ASCII 检测
6. truncate 扫完整串才判断 → 数满 keep 个 rune 立即返回(提前短路)
7. 纯 Go 侧 len([]rune(s))/[]rune(s)(1KB 分配 4KB)→
utf8.RuneCountInString / DecodeRuneInString 游走,零分配
## 结果
| 基准 | 初版 C | 优化后 C | 纯 Go | 提升 |
|---|---:|---:|---:|---:|
| ModelContextWindow | 175 | 76.5 | 46.8 | 2.3× |
| EstimateTokens / 1KB ASCII | 2318 | 80.8 | 326 | 28.7× |
| TruncateByTokens / 1KB ASCII | 2594 | 71.7 | 411 | 36× |
| TruncateByTokens / 1KB 中文 | 3923 | 70.1 | 3097 | 56× |
## 完全 C 化(jianf 裁定)
撤掉我一度加的「短串 <32B 走回 Go」按长度分派:那会同时存在两份语义
可能分叉的实现。C 是唯一实现。
代价如实记录:EstimateTokens("qq") 这类极短串上 C 约 47ns(几乎全是
31ns 边界成本)vs 纯 Go 约 3ns,慢约一个数量级;绝对值纳秒级
(0.000047ms),单次请求尺度可忽略。若某循环对极短串高频调用,
正确应对是**把该循环 C 化(批量传一次)**,而不是按长度分派回 Go。
## 顺带补的正确性缺口(初版是真错的)
初版 C 的 UTF-8 解码只按首字节推断长度、**不校验后续字节**,
因此对畸形序列会与 Go 分叉:例 "\xE4\x41\x41",Go 判 3 rune,
初版判 1 rune ⇒ rune 计数偏差 ⇒ token 预算与截断点偏移。
这类偏差**只影响计数、不会崩**,不测发现不了。
现在 C 侧做与 utf8.DecodeRuneInString 等价的完整校验(含过长编码、
代理对、超 U+10FFFF、截断序列),语义边界逐条注释。
代价:中文密集输入比初版慢(1467 vs 840)—— 这是刻意的正确性代价,
且仍比纯 Go 快 2×。
新增测试:
- TestGolden_InvalidUTF8:3000 组**任意字节**(含畸形序列)对拍,
覆盖初版会分叉的输入类别
- TestGolden_TruncateAlwaysPrefix:截断结果必为原串前缀且不超长
- C 契约测试从 21 项扩到 40 项(含非 NUL 结尾、超长名、畸形 UTF-8)
## 包现在要求 cgo 才能编译
删除 codec_nocgo.go:CGO_ENABLED=0 下整包构建失败(错误直指缺失符号)。
不保留回退的理由:只验证过一条路,就不该存在第二条。
实测这不影响任何构建 —— go list -deps 证明只有 cmd/homed 依赖本包,
而 waiter/initconfig/memgc/mock-server 均不依赖(逐个验过),
且 homed 本就强制 cgo(sqlite3 + gojieba)。仓库无 CI。
Makefile 把「不许有第二条路」变成可执行断言:check-codec-cgo-only
(断言 cgo 下全绿 **且** CGO_ENABLED=0 下必须失败)。
## 验证
- C 契约测试 40/40(gcc -Wall -Wextra 零警告)
- 黄金对照 6 个测试全绿(含 2000 组随机 + 3000 组畸形字节对拍)
- 变异测试:改 C 侧返回值后 go test 立即 FAIL(确认真的走 C)
- make check-codec-cgo-only 两项断言通过
- go vet ./... 干净;全量 go test -count=1 ./... → 38 ok / 0 FAIL
## 已知既有 flaky(与本改动无关,单独记录)
internal/plugins 在全量并发下偶发一次 SIGSEGV,栈在
internal/plugin/proc/{unified.go:234,arena.go:197}(arena 的 getU32)。
该两文件最后修改于 09-10,本提交 0 处触及;随后连跑 5 次单包 +
2 次全量均通过。初步判断是 arena/shared-region 的既有竞态,需单独排查。
180 lines
6.4 KiB
Go
180 lines
6.4 KiB
Go
package api
|
||
|
||
// codec_golden_test.go —— 黄金对照测试:C 实现与纯 Go 参考实现必须逐值等价。
|
||
//
|
||
// 这是 C 化**最重要的验收**(见 docs/zh/c-core/llm-orchestration-c.md §五)。
|
||
// 没有它,「C 化没坏」就只是感觉,不是证据。
|
||
//
|
||
// 运行前提:**CGO_ENABLED=1**。内核已完全 C 化:本包**要求 cgo 才能编译**
|
||
// (无 !cgo 回退文件),故 CGO_ENABLED=0 时整包构建失败 —— 这是有意的
|
||
// 响亮失败,见 codec_cgo.go 顶部与 Makefile 的 check-codec-cgo-only。
|
||
|
||
import (
|
||
"math/rand"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func TestGolden_ModelContextWindow(t *testing.T) {
|
||
cases := []string{
|
||
// 已覆盖的分支各取一个(含大小写、子公司前缀、带路径的模型名)
|
||
"deepseek/deepseek-v4.1-flash", "deepseek-v4-flash", "DEEPSEEK-V3", "deepseek-r1",
|
||
"deepseek-chat", "gpt-4-turbo", "gpt-4o-mini", "gpt-4-omni", "gpt-4", "gpt-4-0613",
|
||
"gpt-3.5-turbo", "claude-3.5-sonnet", "claude-3-opus", "claude-opus-5", "claude-2",
|
||
"gemini-1.5-pro", "gemini-2.0-flash", "gemini-pro", "qwen-max", "QWEN-MAX",
|
||
"glm-4", "chatglm3", "llama-3-70b", "llama-2-7b", "mistral-large", "mixtral-8x7b",
|
||
"yi-34b", "零一万物", "moonshot-v1-128k", "kimi-128k",
|
||
// 推断不出(哨兵路径)
|
||
"AUTO", "auto", "", "unknown-model", "some-local-model",
|
||
}
|
||
for _, model := range cases {
|
||
c := modelContextWindowC(model)
|
||
p := modelContextWindowPure(model)
|
||
if c != p {
|
||
t.Errorf("ModelContextWindow(%q): C=%d, pure=%d", model, c, p)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestGolden_EstimateTokens 覆盖 ASCII / 中文 / emoji / 空 / 长文本。
|
||
func TestGolden_EstimateTokens(t *testing.T) {
|
||
cases := []string{
|
||
"", "a", "ab", "abc", "hello world",
|
||
"你好", "你好世界", "中文English混合", "a你b好c",
|
||
"😀", "😀😀", "👨👩👧👦", // 含 ZWJ 组合序列(多 rune)
|
||
strings.Repeat("x", 1000),
|
||
strings.Repeat("你", 1000),
|
||
"\n\t\r ", "{}[]()",
|
||
}
|
||
for _, s := range cases {
|
||
c := estimateTokensC(s)
|
||
p := estimateTokensPure(s)
|
||
if c != p {
|
||
t.Errorf("EstimateTokens(%q): C=%d, pure=%d", s, c, p)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestGolden_TruncateByTokens 覆盖边界:maxTokens 为 0/负/奇数/超限/恰好。
|
||
func TestGolden_TruncateByTokens(t *testing.T) {
|
||
texts := []string{
|
||
"", "a", "abc", "abcdefghij",
|
||
"你好世界", "你好世界再见", "a你b好c世d界",
|
||
"😀😀😀😀", strings.Repeat("x", 100), strings.Repeat("你", 100),
|
||
}
|
||
maxTokensList := []int{-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 20, 100, 200, 201, 1000}
|
||
for _, s := range texts {
|
||
for _, mt := range maxTokensList {
|
||
c := truncateByTokensC(s, mt)
|
||
p := truncateByTokensPure(s, mt)
|
||
if c != p {
|
||
t.Errorf("TruncateByTokens(%q, %d): C=%q, pure=%q", s, mt, c, p)
|
||
}
|
||
// 额外不变量:结果必须是原串前缀,且不超过预算
|
||
if !strings.HasPrefix(s, c) && c != "" {
|
||
t.Errorf("TruncateByTokens(%q, %d)=%q 不是原串前缀", s, mt, c)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestGolden_Randomized 随机输入对拍:抓前面手写用例没想到的组合。
|
||
// 固定 seed,失败可复现。
|
||
func TestGolden_Randomized(t *testing.T) {
|
||
rng := rand.New(rand.NewSource(20260924))
|
||
alphabet := []rune("abcXYZ019 你好世界😀-_./")
|
||
|
||
for i := 0; i < 2000; i++ {
|
||
n := rng.Intn(40)
|
||
var sb strings.Builder
|
||
for j := 0; j < n; j++ {
|
||
sb.WriteRune(alphabet[rng.Intn(len(alphabet))])
|
||
}
|
||
s := sb.String()
|
||
|
||
if c, p := estimateTokensC(s), estimateTokensPure(s); c != p {
|
||
t.Fatalf("EstimateTokens(%q): C=%d, pure=%d", s, c, p)
|
||
}
|
||
|
||
mt := rng.Intn(60) - 5
|
||
if c, p := truncateByTokensC(s, mt), truncateByTokensPure(s, mt); c != p {
|
||
t.Fatalf("TruncateByTokens(%q, %d): C=%q, pure=%q", s, mt, c, p)
|
||
}
|
||
|
||
// 模型名:拼一段 ASCII 再随机插入已知子串
|
||
models := []string{"deepseek-v4", "gpt-4-turbo", "claude-3", "qwen", "llama-3", "kimi", "zzz"}
|
||
m := models[rng.Intn(len(models))]
|
||
if rng.Intn(2) == 0 {
|
||
m = strings.ToUpper(m)
|
||
}
|
||
if c, p := modelContextWindowC(m), modelContextWindowPure(m); c != p {
|
||
t.Fatalf("ModelContextWindow(%q): C=%d, pure=%d", m, c, p)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestGolden_InvalidUTF8 用**任意字节**(含畸形序列)对比 C 与纯 Go。
|
||
//
|
||
// 为什么必须有:C 侧的解码必须与 Go 的 utf8.DecodeRuneInString 完全同语义
|
||
// ——尤其是「无效/截断序列只前进 1 字节」(Go 返回 RuneError 且 size=1)。
|
||
// 若 C 侧放宽校验,两侧 rune 计数就会分叉,而合法 UTF-8 的测试**抓不到**这个。
|
||
// 这是 C 化最容易出错、也最容易被漏测的地方。
|
||
func TestGolden_InvalidUTF8(t *testing.T) {
|
||
// 覆盖各类边界字节:续字节、过长编码、代理对、超出 U+10FFFF、截断序列。
|
||
seed := []byte{
|
||
0x00, 0x41, 0x7F, 0x80, 0xBF, 0xC0, 0xC1, 0xC2, 0xDF, 0xE0, 0xE1,
|
||
0xED, 0xEF, 0xF0, 0xF1, 0xF4, 0xF5, 0xF8, 0xFE, 0xFF,
|
||
0xE4, 0xBD, 0xA0, // 你
|
||
0xF0, 0x9F, 0x98, 0x80, // 😀
|
||
0xED, 0xA0, 0x80, // 0xED 0xA0 0x80 = UTF-16 代理对,非法
|
||
0xC0, 0x80, // 过长编码 NUL,非法
|
||
0xF4, 0x90, 0x80, 0x80, // > U+10FFFF,非法
|
||
}
|
||
rng := rand.New(rand.NewSource(20260925))
|
||
|
||
for i := 0; i < 3000; i++ {
|
||
n := rng.Intn(24)
|
||
b := make([]byte, n)
|
||
for j := range b {
|
||
if rng.Intn(3) == 0 {
|
||
b[j] = byte(rng.Intn(256)) // 完全随机字节
|
||
} else {
|
||
b[j] = seed[rng.Intn(len(seed))]
|
||
}
|
||
}
|
||
s := string(b)
|
||
|
||
if c, p := estimateTokensC(s), estimateTokensPure(s); c != p {
|
||
t.Fatalf("EstimateTokens(%q) 畸形输入: C=%d, pure=%d", b, c, p)
|
||
}
|
||
// 截断也必须落在同一字节边界上(不得切在字符中间,且两侧一致)
|
||
mt := rng.Intn(40) - 2
|
||
if c, p := truncateByTokensC(s, mt), truncateByTokensPure(s, mt); c != p {
|
||
t.Fatalf("TruncateByTokens(%q, %d): C=%q, pure=%q", b, mt, c, p)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestGolden_TruncateAlwaysPrefix 不变量:截断结果必须是原串前缀,且 <= 原长。
|
||
func TestGolden_TruncateAlwaysPrefix(t *testing.T) {
|
||
inputs := []string{
|
||
"", "a", "abc", "你好世界", "a你b好c", "😀😀😀", strings.Repeat("x", 300),
|
||
strings.Repeat("中", 300), "\xe4\xbd", "a\xed\xa0\x80b",
|
||
}
|
||
for _, s := range inputs {
|
||
for mt := -2; mt <= 60; mt++ {
|
||
got := truncateByTokensC(s, mt)
|
||
if !strings.HasPrefix(s, got) {
|
||
t.Fatalf("TruncateByTokens(%q, %d)=%q 不是原串前缀", s, mt, got)
|
||
}
|
||
if len(got) > len(s) {
|
||
t.Fatalf("TruncateByTokens(%q, %d) 结果长于输入", s, mt)
|
||
}
|
||
if got != truncateByTokensPure(s, mt) {
|
||
t.Fatalf("TruncateByTokens(%q, %d): C=%q, pure=%q", s, mt, got, truncateByTokensPure(s, mt))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|