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 的既有竞态,需单独排查。
212 lines
8.6 KiB
C
212 lines
8.6 KiB
C
/*
|
||
* test_ha_codec.c — ha_codec C 侧契约测试
|
||
*
|
||
* 编译运行(无 cmake 亦可):
|
||
* gcc -std=c99 -I../include ../src/ha_codec.c test_ha_codec.c -o test_ha_codec && ./test_ha_codec
|
||
*
|
||
* 这一层钉死 C 实现的语义;与 Go 的逐值一致由黄金对照测试负责(双保险)。
|
||
*
|
||
* ★ 注意签名已改为「指针 + 长度」(见 ha_codec.h):不再依赖 NUL 结尾,
|
||
* 截断返回字节数而非字符串。测试相应用 LIT()/LEN 辅助宏。
|
||
*/
|
||
|
||
#include "ha_codec.h"
|
||
|
||
#include <stdio.h>
|
||
#include <string.h>
|
||
|
||
static int g_fail = 0;
|
||
static int g_pass = 0;
|
||
|
||
/* 字面量 → (指针, 长度):避免每处手写 sizeof-1。 */
|
||
#define LIT(s) (s), (sizeof(s) - 1)
|
||
|
||
static void check_int(const char *what, int got, int want) {
|
||
if (got != want) {
|
||
printf(" [FAIL] %s: got %d, want %d\n", what, got, want);
|
||
g_fail++;
|
||
} else {
|
||
g_pass++;
|
||
}
|
||
}
|
||
|
||
/* 断言「截断得到的字节数」确实是原文前缀,且正好是期望的字节长度。 */
|
||
static void check_trunc_prefix(const char *what, const char *text, size_t len,
|
||
int max_tokens, size_t want_bytes) {
|
||
size_t got = ha_codec_truncate_by_tokens(text, len, max_tokens);
|
||
if (got != want_bytes) {
|
||
printf(" [FAIL] %s: got %zu bytes, want %zu\n", what, got, want_bytes);
|
||
g_fail++;
|
||
return;
|
||
}
|
||
if (got > len) {
|
||
printf(" [FAIL] %s: 返回值 %zu 超出输入长度 %zu\n", what, got, len);
|
||
g_fail++;
|
||
return;
|
||
}
|
||
g_pass++;
|
||
}
|
||
|
||
/* 字节级断言:截断结果的字节内容必须与期望字符串逐字节相等。 */
|
||
static void check_trunc_bytes(const char *what, const char *text, size_t len,
|
||
int max_tokens, const char *want) {
|
||
size_t got = ha_codec_truncate_by_tokens(text, len, max_tokens);
|
||
size_t want_len = strlen(want);
|
||
if (got != want_len) {
|
||
printf(" [FAIL] %s: got %zu bytes, want %zu\n", what, got, want_len);
|
||
g_fail++;
|
||
return;
|
||
}
|
||
if (got > 0 && memcmp(text, want, got) != 0) {
|
||
printf(" [FAIL] %s: 字节内容不匹配\n", what);
|
||
g_fail++;
|
||
return;
|
||
}
|
||
g_pass++;
|
||
}
|
||
|
||
static void test_context_window(void) {
|
||
printf("model_context_window:\n");
|
||
check_int("deepseek-v4.1-flash",
|
||
ha_codec_model_context_window(LIT("deepseek/deepseek-v4.1-flash")), 1048576);
|
||
check_int("deepseek-v4-flash",
|
||
ha_codec_model_context_window(LIT("deepseek-v4-flash")), 1048576);
|
||
check_int("deepseek-chat",
|
||
ha_codec_model_context_window(LIT("deepseek-chat")), 65536);
|
||
check_int("claude-opus-5",
|
||
ha_codec_model_context_window(LIT("claude-opus-5")), 100000);
|
||
check_int("gpt-4-turbo",
|
||
ha_codec_model_context_window(LIT("gpt-4-turbo")), 128000);
|
||
check_int("llama-3-70b",
|
||
ha_codec_model_context_window(LIT("llama-3-70b")), 8192);
|
||
check_int("AUTO (unknown)",
|
||
ha_codec_model_context_window(LIT("AUTO")), HA_CODEC_CONTEXT_WINDOW_UNKNOWN);
|
||
check_int("NULL (unknown)",
|
||
ha_codec_model_context_window(NULL, 0), HA_CODEC_CONTEXT_WINDOW_UNKNOWN);
|
||
check_int("zero len (unknown)",
|
||
ha_codec_model_context_window("abc", 0), HA_CODEC_CONTEXT_WINDOW_UNKNOWN);
|
||
check_int("case-insensitive",
|
||
ha_codec_model_context_window(LIT("QWEN-MAX")), 131072);
|
||
check_int("moonshot",
|
||
ha_codec_model_context_window(LIT("moonshot-v1-128k")), 131072);
|
||
/* 分支顺序:gpt-4-turbo 必须先于裸 gpt-4 命中 */
|
||
check_int("gpt-4-mini (branch order)",
|
||
ha_codec_model_context_window(LIT("gpt-4-mini")), 128000);
|
||
check_int("gpt-4 (bare)",
|
||
ha_codec_model_context_window(LIT("gpt-4")), 8192);
|
||
/* claude-3 必须先于裸 claude */
|
||
check_int("claude-3-opus (branch order)",
|
||
ha_codec_model_context_window(LIT("claude-3-opus")), 200000);
|
||
/* 中文子串:非 ASCII 字节不受折叠影响 */
|
||
check_int("零一万物",
|
||
ha_codec_model_context_window(LIT("\xe9\x9b\xb6\xe4\xb8\x80\xe4\xb8\x87\xe7\x89\xa9")), 200000);
|
||
|
||
/* 非 NUL 结尾:把模型名放在大缓冲中间,只传前 N 字节。
|
||
* 这是新签名的关键能力(旧签名会读到后续垃圾)。 */
|
||
{
|
||
char buf[64];
|
||
memset(buf, 'Z', sizeof(buf));
|
||
memcpy(buf, "qwen-max", 8);
|
||
check_int("no NUL terminator (prefix only)",
|
||
ha_codec_model_context_window(buf, 8), 131072);
|
||
}
|
||
|
||
/* 超长模型名(超过栈缓冲)必须仍零分配地正确匹配。 */
|
||
{
|
||
static char big[512];
|
||
memset(big, 'a', sizeof(big));
|
||
memcpy(big + 400, "gpt-4-turbo", 11);
|
||
check_int("oversize model name (heap-free fallback)",
|
||
ha_codec_model_context_window(big, sizeof(big)), 128000);
|
||
}
|
||
}
|
||
|
||
static void test_estimate_tokens(void) {
|
||
printf("estimate_tokens:\n");
|
||
check_int("empty", ha_codec_estimate_tokens(LIT("")), 0);
|
||
check_int("NULL", ha_codec_estimate_tokens(NULL, 0), 0);
|
||
check_int("zero len", ha_codec_estimate_tokens("abc", 0), 0);
|
||
/* "abc" = 3 rune * 2 = 6 */
|
||
check_int("ascii abc", ha_codec_estimate_tokens(LIT("abc")), 6);
|
||
/* "你好" = 2 rune * 2 = 4(不是字节数 6) */
|
||
check_int("chinese 2 chars", ha_codec_estimate_tokens(LIT("你好")), 4);
|
||
/* 混合 "a你" = 2 rune * 2 = 4 */
|
||
check_int("mixed", ha_codec_estimate_tokens(LIT("a你")), 4);
|
||
/* 4 字节 emoji:1 rune * 2 = 2 */
|
||
check_int("emoji", ha_codec_estimate_tokens(LIT("\xF0\x9F\x98\x80")), 2);
|
||
|
||
/* ASCII 快路径跨界:长度正好落在批量块边界附近,计数必须精确。 */
|
||
{
|
||
static char buf[300];
|
||
memset(buf, 'x', sizeof(buf));
|
||
check_int("ascii 300 bytes (chunk boundaries)",
|
||
ha_codec_estimate_tokens(buf, sizeof(buf)), 600);
|
||
}
|
||
/* 非 NUL 结尾:只计前 N 字节(后面是垃圾)。 */
|
||
{
|
||
char buf[16];
|
||
memcpy(buf, "abc", 3);
|
||
memset(buf + 3, 'x', sizeof(buf) - 3);
|
||
check_int("no NUL terminator (prefix only)",
|
||
ha_codec_estimate_tokens(buf, 3), 6);
|
||
}
|
||
/* 截断的多字节序列:Go 对无效序列按每字节 1 rune 计,C 必须一致。 */
|
||
check_int("truncated 3-byte seq (invalid)",
|
||
ha_codec_estimate_tokens("\xE4\xBD", 2), 4); /* 2 rune → 4 */
|
||
}
|
||
|
||
static void test_truncate(void) {
|
||
printf("truncate_by_tokens:\n");
|
||
|
||
/* max_tokens<=0 → 0 字节 */
|
||
check_trunc_prefix("max_tokens=0", LIT("hello"), 0, 0);
|
||
|
||
/* 未超限 → 全长 */
|
||
check_trunc_prefix("no truncation", LIT("abc"), 100, 3);
|
||
|
||
/* "abcdefghij" = 10 rune → 20 tokens;max=8 → keep=4 → "abcd" */
|
||
check_trunc_prefix("keep 4", LIT("abcdefghij"), 8, 4);
|
||
|
||
/* 中文按 rune 截断,不切碎 UTF-8:"你好世界" 4 rune,max=4 → keep=2 → "你好"(6B) */
|
||
check_trunc_prefix("chinese keep 2", LIT("你好世界"), 4, 6);
|
||
|
||
/* 恰好等于预算:不截断 */
|
||
check_trunc_prefix("exact budget", LIT("abc"), 6, 3);
|
||
/* 差一:截断。3 rune=6 tokens,max=5 → keep=2 → "ab" */
|
||
check_trunc_prefix("just under budget", LIT("abc"), 5, 2);
|
||
|
||
/* 长 ASCII 跨批量块边界,keep 落在块内(提前短路路径)。 */
|
||
{
|
||
static char buf[200];
|
||
memset(buf, 'k', sizeof(buf));
|
||
check_trunc_prefix("long ascii, keep inside chunk", buf, sizeof(buf), 128, 64);
|
||
}
|
||
|
||
/* 非 NUL 结尾:max 足够大 → 返回传入长度(而非 strlen 结果)。 */
|
||
{
|
||
char buf[16];
|
||
memcpy(buf, "abcd", 4);
|
||
memset(buf + 4, 'x', sizeof(buf) - 4);
|
||
check_trunc_prefix("no NUL terminator, full length", buf, 4, 100, 4);
|
||
}
|
||
|
||
/* 单字节 rune 边界:ASCII 与多字节混合,确保不切在字符中间。
|
||
* "a你b好c" = 5 rune = 10 tokens;max=6 → keep=3 → "a你b" = 1+3+1 = 5 字节 */
|
||
check_trunc_prefix("mixed keep 3", LIT("a你b好c"), 6, 5);
|
||
/* max=4 → keep=2 → "a你" = 1+3 = 4 字节(正好切在字符边界上)*/
|
||
check_trunc_prefix("mixed keep 2 (byte boundary)", LIT("a你b好c"), 4, 4);
|
||
/* 字节内容级校验:结果必须是原串的**逐字节前缀**,不能切碎 UTF-8。 */
|
||
check_trunc_bytes("content zh keep 2", "你好世界", sizeof("你好世界") - 1, 4, "你好");
|
||
check_trunc_bytes("content ascii keep 4", "abcdefghij", 10, 8, "abcd");
|
||
check_trunc_bytes("content no truncation", "abc", 3, 100, "abc");
|
||
}
|
||
|
||
int main(void) {
|
||
printf("=== ha_codec 契约测试 ===\n\n");
|
||
test_context_window();
|
||
test_estimate_tokens();
|
||
test_truncate();
|
||
printf("\n=== 结果: %d passed, %d failed ===\n", g_pass, g_fail);
|
||
return g_fail == 0 ? 0 : 1;
|
||
}
|