Files
HomeAgent/internal/agent/api/codec_cgo.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

117 lines
5.5 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.

//go:build cgo
package api
// codec_cgo.go —— 编解码层的 C 实现绑定(CGO_ENABLED=1 时参与编译)。
//
// ============================ 架构:包内符号链接 ============================
// C 源是 `ha_codec.c` / `ha_codec.h`,它们是**指向 csrc/ 的符号链接**
// (`ln -s ../../../csrc/src/ha_codec.c`):
//
// internal/agent/api/ha_codec.c -> ../../../csrc/src/ha_codec.c
// internal/agent/api/ha_codec.h -> ../../../csrc/include/ha_codec.h
//
// 权威源只有一份(csrc/),Go 侧看到的是包目录内的链接。
//
// ============================ 为什么不用另外两种做法 ============================
//
// **不能链接预构建静态库**(`LDFLAGS: .../csrc/build/libha_codec.a`):
// - .a 是构建产物、不入库(.gitignore 的 build/ 命中 csrc/build/),
// 而发布脚本原先并不产出它 ⇒「不入库 + 不生成」两头空,链接必然失败
// - 交叉编译 linux/arm64(homed 的真实发布目标)时,宿主 x86-64 的 .a
// 被链进目标产物,报 `file in wrong format`
//
// **不能用 `#include "../../../csrc/src/ha_codec.c"`(包外相对包含)**:
// ★ Go 构建缓存**不跟踪包外被 #include 的 C 文件**。实测:包外源把返回值
// 7 改成 8,`go test` 依然通过(缓存命中、静默沿用旧代码);同样改动落在
// 包内文件时立即判红。这对「逐步推进 C 化」是致命的——改 C 源码却不生效
// 且无任何报错。
// (包内 shim `#include` 包外源同样漏跟踪,已实测排除。)
//
// 包内符号链接同时满足两点:文件在包目录内 ⇒ 缓存按内容正确跟踪;
// 只有一份权威源 ⇒ 无副本漂移,也不需要「同步 C 源」的 make 目标。
//
// ============================ 零拷贝:不 CString、不 strlen ============================
// ★ 这是**被实测教训倒逼出来的**(见 docs/zh/c-core/llm-orchestration-c.md §7.1):
//
// cgo 边界的固有成本实测约 **32 ns**(零拷贝传指针 + 空函数体)。
// 而初版每次调用都做 `C.CString`(malloc + 整串拷贝)+ C 侧 `strlen`(再扫一遍),
// 单这一项就约 **75 ns**,加上 C 侧 `lower_dup` 的 malloc 与逐字节扫描,
// 使 ModelContextWindow 实测达到 **175 ns** —— 即 **82% 是自找的开销**,
// 而非 cgo 的固有代价。初版由此得出「C 比 Go 慢」的结论是**错的**。
//
// 现在:Go 侧用 `unsafe.StringData` 把 string 的底层字节**直接**交给 C
// (传指针 + 长度),C 侧不 malloc、不 strlen、不要求 NUL 结尾。
// 截断则只回**字节长度**(结果必然是输入前缀),Go 侧 `s[:n]` 完成切片,
// 全程零分配零拷贝。
//
// 边界与安全:
// - 不把 Go 指针交给 C 长期持有(C 侧不保存任何指针,纯函数)
// - 空串在 Go 侧短路,不把可能的 nil 指针传下去
// - cgo 规则允许传「不含 Go 指针的内存」的指针,string 底层字节满足
//
// ============================ 为什么不需要额外 build tag ============================
// 与 onnxruntime(internal/nlp/onnx.go,需运行期 libonnxruntime.so)不同:
// ha_codec 是**零依赖纯 C99 源码内联编译**,不需要任何外部库或工具链前提。
// 而 homed 本就强制 cgo(mattn/go-sqlite3 + gojieba),故 C 路径自然生效。
// 因此只用 `cgo` 约束(**没有 `!cgo` 回退**:CGO_ENABLED=0 下本包构建失败,
// 这是有意的响亮失败,理由见上),也不引入 hacodec tag。
//
// 语义必须与 codec_pure.go 逐值等价,由 codec_golden_test.go 钉死。
/*
#cgo CFLAGS: -std=c99
#include <stdlib.h>
#include "ha_codec.h"
*/
import "C"
import "unsafe"
// cstr 返回 s 的底层字节首地址与长度,供 C 侧零拷贝读取。
//
// 空串返回 (nil, 0):调用方不应把 nil 传给会解引用的 C 函数。
func cstr(s string) (*C.char, C.size_t) {
if len(s) == 0 {
return nil, 0
}
return (*C.char)(unsafe.Pointer(unsafe.StringData(s))), C.size_t(len(s))
}
// modelContextWindowC 经 C 实现推断上下文窗口。
func modelContextWindowC(model string) int {
p, n := cstr(model)
return int(C.ha_codec_model_context_window(p, n))
}
// estimateTokensC 经 C 实现估算 token 数。
//
// ★ 不做按长度分派:**完全 C 化**——compute 一律走 C,纯 Go 实现不再是
// 生产路径(只作为黄金对照的规格基准)。
//
// 代价(如实记录,勿用「C 更快」一句话盖过):cgo 边界固有成本实测约 30ns,
// 故对「极短串」(如 2 字节的 "qq")本函数约 30ns,而直调纯 Go 仅约 3ns
// ——即极短输入上 C 路径约慢一个数量级,但绝对值是**纳秒级**
// (30ns = 0.00003ms,单次请求尺度可忽略)。
// 换来的是:单一实现、无静默分派分叉、C 侧对畸形 UTF-8 的严格校验恒生效。
func estimateTokensC(text string) int {
p, n := cstr(text)
return int(C.ha_codec_estimate_tokens(p, n))
}
// truncateByTokensC 经 C 实现按 token 截断。
//
// C 侧只返回「应保留的字节数」——截断结果必然是输入的前缀,
// 故这里直接切片,无需缓冲区、无需 malloc、无需把结果拷回来。
func truncateByTokensC(s string, maxTokens int) string {
if maxTokens <= 0 || s == "" {
return ""
}
p, n := cstr(s)
keep := C.ha_codec_truncate_by_tokens(p, n, C.int(maxTokens))
if uint64(keep) >= uint64(len(s)) {
return s
}
return s[:int(keep)]
}