Files
HomeAgent/internal/agent/api/codec_pure.go
JianFeeeee 7799ca4559 perf(c-core): 完全 C 化 + 消灭初版的 malloc/拷贝开销(C 从「更慢」变「明显更快」)
上一提交的基准结论是错的:"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 的既有竞态,需单独排查。
2026-09-25 15:40:20 +08:00

133 lines
5.2 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_pure.go —— 编解码层的**纯 Go 参考实现**。
//
// ★ 这**不是生产路径**。内核已「完全 C 化」:所有调用都走 C
// (internal/agent/api/codec_cgo.go),本文件只服务两个目的:
//
// 1. **规格基准**:`codec_golden_test.go` 用同一组输入对比它与 C 实现,
// 断言逐值相等。C 侧的任何语义偏差(尤其畸形 UTF-8 的解码边界)
// 都由它抓出。没有它,「C 化没改错」就只是感觉。
// 2. **可读的规格**:C 是命令式字节游走,Go 版是直白的语义陈述。
// 两者并读时,改哪边都能立刻看出另一边该怎么改。
//
// 因此本文件**不带 build tag**,永远参与编译(测试要能引用)。
// 但没有任何生产代码路径调用它:编解码层要求 cgo 才能编译
// (CGO_ENABLED=0 下整包构建失败,见 codec_cgo.go 顶部)。
//
// ★ 零分配:本文件刻意不用 `len([]rune(s))` / `[]rune(s)`。
// `[]rune(s)` 会分配 4×len 字节的临时切片(1KB 字符串就是 4KB 垃圾),
// 而 rune 计数与「前 keep 个 rune 的字节边界」都能用
// utf8.RuneCountInString / utf8.DecodeRuneInString 游走完成,零分配。
// 实测这曾使纯 Go 的 TruncateByTokens 在 1KB 中文上分配 4208 B/2 allocs。
import (
"strings"
"unicode/utf8"
)
// defaultInferredContextWindow 是模型名无法推断窗口时的兜底。
//
// 32768 是个保守值,但它属于**静默降级**:模型名写 AUTO(网关自己选上游)时
// 匹配不到任何分支,内核就会拿着一份比真实小得多的窗口去算全部预算
// (实测:deepseek-v4.1-flash 能吞 990,034 token,而预算按 32768 算)。
// 兜底值本身不猜大:猜大会让请求直接撞上游 400。
const defaultInferredContextWindow = 32768
// contextWindowUnknown 是「模型名推断不出窗口」的哨兵值。
//
// 与 C 侧 HA_CODEC_CONTEXT_WINDOW_UNKNOWN 取值必须一致。
// 用哨兵而非直接返回兜底值:调用方要能区分「真推断出了」与
// 「推断不出、只能兜底」——后者必须记日志,让窗口被低估这件事可见。
const contextWindowUnknown = -1
// modelContextWindowPure 由模型名推断最大上下文窗口;推断不出返回哨兵。
// 标称窗口 ≠ 有效窗口:接近满时注意力涣散,调用方应取 70-80% 为目标利用率。
//
// ★ 分支顺序即语义:先匹配者胜出(例:gpt-4-turbo 必须先于裸 gpt-4)。
// C 侧 ha_codec_model_context_window 必须保持同一顺序。
func modelContextWindowPure(model string) int {
model = strings.ToLower(model)
switch {
case strings.Contains(model, "deepseek-v4") || strings.Contains(model, "deepseek-v3"):
return 1048576
case strings.Contains(model, "deepseek-r1") || strings.Contains(model, "deepseek-chat"):
return 65536
case strings.Contains(model, "gpt-4") && (strings.Contains(model, "turbo") || strings.Contains(model, "mini") || strings.Contains(model, "omni")):
return 128000
case strings.Contains(model, "gpt-4"):
return 8192
case strings.Contains(model, "gpt-3.5"):
return 16384
case strings.Contains(model, "claude-3.5") || strings.Contains(model, "claude-3"):
return 200000
case strings.Contains(model, "claude"):
return 100000
case strings.Contains(model, "gemini-1.5") || strings.Contains(model, "gemini-2"):
return 1048576
case strings.Contains(model, "gemini"):
return 32768
case strings.Contains(model, "qwen"):
return 131072
case strings.Contains(model, "glm") || strings.Contains(model, "chatglm"):
return 131072
case strings.Contains(model, "llama-3"):
return 8192
case strings.Contains(model, "llama-2"):
return 4096
case strings.Contains(model, "mistral") || strings.Contains(model, "mixtral"):
return 32768
case strings.Contains(model, "yi-") || strings.Contains(model, "零一"):
return 200000
case strings.Contains(model, "moonshot") || strings.Contains(model, "kimi"):
return 131072
default:
return contextWindowUnknown
}
}
// estimateTokensPure 粗略估算 token 数。
// 中文 ~1.5 token/字,英文 ~0.3 token/字符,保守估计取 max(1, runeCount * 2)。
//
// 用 RuneCountInString 而非 len([]rune(text)):后者会分配 4×len 字节。
// 两者对**畸形 UTF-8** 的计数一致(无效字节各计 1 个 rune)。
func estimateTokensPure(text string) int {
if text == "" {
return 0
}
runeCount := utf8.RuneCountInString(text)
if runeCount == 0 {
return 0
}
t := runeCount * 2
if t < 1 {
return 1
}
return t
}
// truncateByTokensPure 截断字符串至不超过 maxTokens 估计值。
//
// 语义(与 C 侧一致):未超预算则原样返回;否则保留前 maxTokens/2 个 rune。
// 结果必然是输入的前缀,故直接按字节边界切片——无需构造 []rune。
func truncateByTokensPure(s string, maxTokens int) string {
if maxTokens <= 0 || s == "" {
return ""
}
runeCount := utf8.RuneCountInString(s)
if runeCount*2 <= maxTokens {
return s
}
keep := maxTokens / 2
if keep >= runeCount {
return s
}
// 游走到「前 keep 个 rune」的字节边界(零分配)。
n := 0
for count := 0; count < keep; count++ {
_, size := utf8.DecodeRuneInString(s[n:])
n += size
}
return s[:n]
}