上一提交的基准结论是错的:"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 的既有竞态,需单独排查。
18 KiB
内核 C 化 · 第一刀:LLM 编排层
分支:
feature/c-core(从main拉出,遵循git-release-discipline) 状态:第一刀已落地并闭环(2026-09-25)。L1 纯函数层的三个函数已在 C 侧 实现,双路径(cgo / 纯 Go 回退)与黄金对照测试均已在仓库内跑通; 构建链已打通至linux/amd64与linux/arm64两个真实发布目标。 本文保留设计与可行性论证,并在每节标注落地后的实际情况。 本文只写经实测确认的结论;每个「可行」都附验证方式,每个「不可行」都附证据。
一、为什么要 C 化,以及为什么先动 LLM 编排
内核当前是纯 Go 单进程(homed),插件经子进程 + 共享内存与之通信。
C 化的目标不是「换语言重写」,而是把稳定、高频、无 GC 抖动敏感的热路径
下沉为可复用 C 库,让内核在保持 Go 编排能力的同时获得:
- 可预测的延迟(无 GC STW 影响热路径)
- 可跨端复用(鸿蒙 / 嵌入式 / C SDK 侧同一份实现)
- 与既有 C 资产统一(见 §三)
为什么第一刀是 LLM 编排:这一层是内核最核心的职责(assets/docs/zh/OVERVIEW.md
定义内核 = 「LLM 编排 + 记忆管理 + 知识检索」),且它天然分层——
上层是有状态的调度/工具循环,下层是无状态的协议编解码。
后者是纯字符串进、结构体出,最适合先下沉。
二、可行性核实结论(实测)
2.1 工具链齐备
cc/gcc/clang: /usr/bin/{cc,gcc,clang}
make/cmake: /usr/bin/{make,cmake}
go env: CC=gcc CGO_ENABLED=1 GOOS=linux GOARCH=amd64
2.2 已有纯 C 先例,可直接复用
third_party/homeagent-sdk/remotedevice/ 是一个零外部依赖的纯 C 库
(1320 行),已含可复用组件:
| 文件 | 行数 | 能力 |
|---|---|---|
src/ha_json.c |
368 | DOM 风格 JSON 解析器 + 流式构建器 |
src/ha_ws.c |
324 | WebSocket 客户端握手/帧 |
src/ha_remotedevice.c |
628 | 设备通道 |
CMakeLists.txt |
— | 静态/动态库、install 规则、可选测试 |
这意味着 JSON 解析这一 C 化的最大依赖,仓库里已有现成实现, 不需要引入 cJSON 等外部依赖,与「内核不新增外部依赖」的克制一致。
2.3 关键约束:Windows 构建是 CGO_ENABLED=0 → 前提已消失,改用另一条约束
CGO_ENABLED=0⚠️ 本节结论已因「放弃 Windows 原生」而过时(见 §2.5)。 关键的一点在于,原论证推理的根基“Windows 包需要 CGO_ENABLED=0”已不成立, 故「必须保留纯 Go 回退」这个推论也不再由它支撑。 现在的实际策论是:用
cgo/!cgo一组约束(回退实现保留,但理由是 waiter 等 CGO-free 目标与无 cgo 工具链场景,而不是 Windows), 而不需要额外的hacodectag ——因为 ha_codec 是零依赖纯 C99 源码 内联编译,不像 onnxruntime 那样需要运行期.so。
原论证(保留作为推理参考):
deploy/packaging/package-windows.sh:69
GOOS=windows GOARCH="$ARCH" CGO_ENABLED=0 \
这是 C 化最大的部署风险:若把编排逻辑改成必须 cgo,Windows 包直接编不出来。
已验证的解法:build-tag 双实现。实测(最小复现):
//go:build cgo
// a_cgo.go —— cgo 实现,链接 C 库
//go:build !cgo
// a_pure.go —— 纯 Go 回退实现,行为等价
CGO_ENABLED=1 go build ./... → exit 0
CGO_ENABLED=0 go build ./... → exit 0
结论:C 化必须始终保留纯 Go 回退路径,且两条路径要有同一组测试钉死 行为等价(黄金对照,见 §五)。这不是可选项——是 Windows 分发的前提。
2.4 落地后的形状修正(实测补充,2026-09-25)
实际落地时,三处原设计需要修正:
① 不链接静态库,也不 #include 包外源 —— 用包内符号链接。
原方案(LDFLAGS 指向 csrc/build/libha_codec.a)实测会造成两个必然失败:
.a是构建产物、不入库(.gitignore的build/命中csrc/build/), 而发布脚本原先并不产出它 ⇒ 「不入库 + 不生成」两头空,链接报cannot find .../libha_codec.a。- 交叉编译
linux/arm64(homed 的真实发布目标)时,宿主 x86-64 的.a被链进目标产物,报file in wrong format。
改为包内 ha_codec.c / ha_codec.h 符号链接到 csrc/ 权威源:
internal/agent/api/ha_codec.c -> ../../../csrc/src/ha_codec.c
internal/agent/api/ha_codec.h -> ../../../csrc/include/ha_codec.h
★ 为什么不能用 #include "../../../csrc/src/ha_codec.c"(包外相对包含):
Go 构建缓存不跟踪包外被 #include 的 C 文件。实测:在包外源里把返回值从 7
改成 8,go test 依然通过(缓存命中、静默沿用旧代码);同样改动落在包内文件时
立即判红。对「逐步推进 C 化」这是致命的——改 C 源码却不生效且无任何报错。
(包内 shim #include 包外源同样漏跟踪,已实测排除。)
符号链接同时满足两点:文件在包目录内 ⇒ 缓存按内容正确跟踪; 只有一份权威源 ⇒ 无副本漂移、无需同步目标。
② csrc/CMakeLists.txt 的定位变化:不再是 Go 构建的前置,而是
C 侧独立复用(鸿蒙/嵌入式/C SDK)与契约测试(ctest)的入口。
③ C 源文件会被 Go 工具链视为包的一部分:故 .c 需要 //go:build cgo 约束
(C 编译器把该行当普通注释,两侧兼容)。
2.5 真正的硬耦合点:Lua 适配器
internal/agent/api/provider.go 的 LuaAdaptedProvider 在每次请求都要
调 Lua VM(internal/lua/vm.go,基于 gopher-lua,679 行):
| 调用点 | provider.go 行 | 作用 |
|---|---|---|
CallTransformRequest |
:403, :872 | 改写请求体(适配器协议知识) |
GetAdapterEndpoint |
:408, :877 | 决定 endpoint |
CallTransformResponse |
:439 | 改写响应 |
BuildHeaders / GetAdapterHeaders |
:487, :489 | 动态签名头 |
TransformError |
:900 | 错误归一化 |
CallTransformStreamChunk |
:950 | 流式分片改写 |
结论:「把 provider.go 整体 C 化」是不可行的——它把一个嵌入式 Lua 解释器(带 GC、协程)拖进 C。可行的切法是只 C 化 Lua 之外的部分: 把 Lua 当作「回调钩子」,C 侧定义钩子接口,Go 侧注入 Lua 实现。
三、切分方案:按「无状态 → 有状态」分三层
L1 · 协议编解码(本轮目标,纯函数,零状态)
最适合先下沉。全部是 string in → struct out:
| 目标函数 | 现位置 | 说明 |
|---|---|---|
parseOpenAICompatibleResponse |
provider.go:499 |
非流式响应解析 |
parseOpenAICompatibleSSEBody |
provider.go:541 |
SSE body 整段解析 |
parseOpenAICompatibleStreamChunkFull |
provider.go:759 |
流式分片解析 |
normalizeOpenAIToolCalls |
provider.go:628 |
工具调用归一 |
normalizeStreamToolCalls |
provider.go:674 |
流式工具调用归一 |
ModelContextWindow |
provider.go:273 |
模型名 → 窗口(纯映射) |
EstimateTokens / TruncateByTokens |
tokenbudget.go |
纯计算 |
ComputeTokenBudget |
tokenbudget.go:52 |
纯计算(依赖上面两个) |
这些函数不碰网络、不碰 Lua、不碰 goroutine,是最安全的起点。
ModelContextWindow / EstimateTokens / ComputeTokenBudget 三个更是
同一组纯算术,可作为「第一个能跑通端到端 C 调用」的最小切片。
L2 · Provider 编排(暂不动)
LuaAdaptedProvider.Chat / ChatStream:HTTP + Lua 钩子 + SSE 流式,
强耦合 Go 的 net/http 与 context。C 化的收益低于风险,暂不动。
L3 · 工具循环 / 调度(明确不 C 化)
task.go / toolcall.go / eventloop.go / scheduler.go:有状态、
与记忆层和 goroutine 调度深度耦合。C 化会摧毁可维护性,不做。
四、落地结构(已落地,2026-09-25)
internal/agent/api/
├── provider.go # 不动(L2)
├── codec.go # 统一符号名(调用方只见这里)
├── codec_cgo.go # //go:build cgo → 调 C
├── codec_nocgo.go # //go:build !cgo → 转发到纯 Go
├── codec_pure.go # 纯 Go 实现(回退 + 黄金对照基准)
├── codec_golden_test.go # 黄金对照:C 与纯 Go 逐值相等
├── ha_codec.h -> ../../../csrc/include/ha_codec.h (符号链接)
└── ha_codec.c -> ../../../csrc/src/ha_codec.c (符号链接)
csrc/ # C 实现(主仓,非 SDK)
├── CMakeLists.txt # 供 C 侧独立复用与 ctest(不参与 Go 构建)
├── include/ha_codec.h # 对外 C 接口(冻结契约)
├── src/ha_codec.c # L1 编解码(当前:窗口推断 + token 估算/截断)
└── test/test_ha_codec.c # C 侧契约测试
★ 为什么是符号链接而不是 #include 包外源:Go 构建缓存不跟踪包外被
#include 的 C 文件(实测:改包外源后 go test 仍报 ok,静默用旧代码)。
详见 §2.4 ①。
接口设计原则(已遵守):
- C 接口只吃
const char*+ 长度,出数值/JSON 串——不传 Go 指针、 不回调 Go(回调留给 L2 的 Lua 钩子层,不在本轮) - C 侧不 malloc 长期持有的内存;调用方给缓冲区,或用「申请/释放」成对
API 并在 Go 侧
defer释放 ha_codec.h一旦定下就是冻结接口,与 SDK 冻结同一标准
五、验收方式(黄金对照,缺一不可)
C 化的正确性不能靠「跑起来没崩」,必须有可复现的对照。三类证据:
- 黄金对照测试:同一组输入分别喂 C 实现与 Go 实现,断言输出逐字段相等。
现有测试可直接复用做基准:
internal/agent/api/sse_body_test.go(4 个 Test)internal/agent/api/context_window_test.go(2 个)internal/agent/core/stream_accumulate_test.go(9 个)internal/agent/core/tokenbudget_test.go
- 双构建全绿:
CGO_ENABLED=1 go test ./...与CGO_ENABLED=0 go test ./...都必须通过(后者走纯 Go 回退)。CI 要同时跑。 - 契约测试:
ha_codec.h的每个函数有对应 C 单测(参照remotedevice/test/test_ha_remotedevice.c的写法,gcc ... -lpthread直编)。
六、第一步:最小可验证切片(已完成,2026-09-25)
目标:只 C 化一个纯函数族,跑通「Go → cgo → C → 返回」全链路, 证明结构可行,再谈扩张。
选 ModelContextWindow + EstimateTokens + TruncateByTokens
(三个纯函数,无依赖,逻辑确定,测试齐备)。下表为实际落地情况:
| # | 计划项 | 落地 |
|---|---|---|
| 1 | csrc/include/ha_codec.h 声明三函数 |
✅(含接口冻结声明与哨兵值约定) |
| 2 | csrc/src/ha_codec.c 纯 C 实现 |
✅(表驱动 switch + 手写 UTF-8 步进) |
| 3 | codec_cgo.go / codec_pure.go 双实现 |
✅(+ codec_nocgo.go 转发层) |
| 4 | csrc/CMakeLists.txt 产出静态库 |
✅(但 Go 不链接它,见 §2.4) |
| 5 | Go 侧构建集成 | ✅ 改为包内符号链接 + cgo 编译 C 源 |
| 6 | 黄金对照测试 | ✅ codec_golden_test.go(手写用例 + 2000 次随机对拍) |
| 7 | CGO_ENABLED=0 下全绿 |
✅ make check-codec-paths 钉死两条路径 |
额外钉死的约束(原计划未列,实测后补):
- 变异测试必须真判红:改 C 侧
result = 131072→777,go test必须 FAIL。这是「C 路径真的生效」的证据(不是「跑起来没崩」)。 ★ 此测试抓到过两种静默失效:包外#include漏跟踪、构建缓存隐藏改动。 cc交叉编译必须可过:make build-linux-arm64产出 ELF aarch64。- C 侧契约测试:
make csrc-test(ctest)与 Go 侧黄金对照互补。
这一步的价值不在功能(这三个函数 Go 版没问题),而在打通链路、 钉死双路径约束、建立黄金对照范式——后面每扩一个函数都复用它。
七、已知风险与未决问题
| 风险 | 现状 | 处置 |
|---|---|---|
CGO_ENABLED=0 编不出来 |
前提已消失(Windows 原生已放弃,见 §2.3) | 回退保留但理由换成「CGO-free 目标」 |
| Lua 适配器无法 C 化 | 硬耦合 gopher-lua | 钩子化,Lua 留在 Go 侧(L2 做) |
| ✅ 已解决:Go 不依赖预构建库,直接编包内符号链接的 C 源 | — | |
| 包外 C 源会被缓存漏跟踪 | ✅ 已避坑:实测确认,改用包内符号链接 | 扩张时勿改回 #include 包外路径 |
| JSON 解析能力不足 | ha_json.c 只存 int(无 float/long) |
用前须评估;必要时扩该库(会动 SDK,需走 SDK 冻结流程) |
| 接口冻结 | — | ha_codec.h 冻结标准对齐 SDK |
| 打包脚本 | ✅ 不再需带 .a(编源码,无外部产物依赖) |
扩张到多文件 C 实现时重评 |
仍未决(需 jianf 拍板):
- C 实现放主仓
csrc/还是 SDKthird_party/homeagent-sdk/?- 当前已在主仓
csrc/;若 SDK 侧也要复用,需定同步机制 - 放 SDK:天然跨端复用,但要走 SDK 冻结与大版本流程
- 当前已在主仓
ha_json.c是复用(从 remotedevice 复制/提为公共)还是新写? 复用会动 SDK 目录结构。- 下一个切片选谁?L1 剩下的是协议编解码(
parseOpenAICompatible*、normalize*ToolCalls等,见 §三 L1 表);该层依赖 JSON 解析 ⇒ 先解第 2 题。
7.1 ★ 性能:初版结论是错的,根因是我的绑定与 C 实现
初版结论「C 比 Go 慢」不成立 —— 那是把「我自己的 malloc/拷贝开销」误当成了
「cgo 的固有成本」。拆解实测(同一台机,-benchtime 百万次):
| 场景 | ns/op | 说明 |
|---|---|---|
| cgo 边界(零拷贝传指针 + 空函数体) | 31.9 | cgo 的真实固有成本 |
+ 一次 C.CString + C 侧 strlen |
105–111 | 多出 ~75ns(70%) |
初版 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 检测 + 位运算 UTF-8 校验 |
| 6 | truncate 扫完整串才判断 |
数满 keep 个 rune 立即返回(提前短路) |
| 7 | 纯 Go 侧 len([]rune(s)) / []rune(s)(1KB 分配 4KB) |
utf8.RuneCountInString / DecodeRuneInString 游走,零分配 |
优化后(完全 C 化:一律走 C,无按长度分派)
| 基准 | 初版 C | 优化后 C | 纯 Go | 提升 |
|---|---|---|---|---|
ModelContextWindow(短 ASCII) |
175 | 76.5 | 46.8 | 2.3× |
EstimateTokens / 短 ASCII |
114.6 | 47.2 | 2.8 | 2.4× |
EstimateTokens / 短中文 |
99.6 | 49.7 | 22.8 | 2.0× |
EstimateTokens / 1KB ASCII |
2318 | 80.8 | 326 | 28.7× |
EstimateTokens / 1KB 中文 |
840 | 1467 | 2844 | 0.57×(见下) |
TruncateByTokens / 短中文 |
233 | 40.2 | 25.3 | 5.8× |
TruncateByTokens / 1KB ASCII |
2594 | 71.7 | 411 | 36× |
TruncateByTokens / 1KB 中文 |
3923 | 70.1 | 3097 | 56× |
★ 必须如实说明的两点
① 中文密集输入比初版慢(1467 vs 840)—— 这是刻意的正确性代价。
初版的 utf8_next 只按首字节推断长度、不校验后续字节,因此对畸形序列会与 Go
分叉(例:"\xE4\x41\x41",Go 判 3 个 rune,初版判 1 个 ⇒ rune 计数偏差 ⇒
token 预算与截断点偏移)。现在 C 侧做了与 Go utf8.DecodeRuneInString 等价的
完整校验(含过长编码、代理对、超 U+10FFFF、截断序列)。
换来的能力由 TestGolden_InvalidUTF8(3000 组随机字节)钉死 —— 这类偏差
只影响计数、不会崩,不测就发现不了。正确性优先,且仍比纯 Go 快 2×。
② 极短串上 C 慢于 Go(约慢一个数量级)—— 这是「完全 C 化」的已知代价。
EstimateTokens("qq"):C 约 47ns(几乎全是 31ns 的边界成本)vs 纯 Go 约 3ns。
绝对值是纳秒级(47ns = 0.000047ms),单次请求尺度可忽略;
但若某个循环对极短串高频调用,这一项会累积。
⇒ 正确的应对是「C 化那个循环(批量传一次)」而不是「按长度分派回 Go」 (后者正是被否掉的混合做法:它会同时存在两份语义可能分叉的实现)。 这也是 §三 L2/L3 把「有状态编排」明确留给 Go、而把「长 payload 编解码」 作为下一步目标的原因 —— 协议编解码(JSON / SSE 分片)处理的正是长文本。
实测记录:双路径、缓存跟踪、交叉编译、变异测试、跨语言基准均于 2026-09-25 在本仓实测;工具链与 C 资产核实于本仓 初版核实:2026-09-24 · 落地更新:2026-09-25