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 的既有竞态,需单独排查。
This commit is contained in:
JianFeeeee
2026-09-25 15:40:20 +08:00
parent 1291379b9f
commit 0d4399c173
11 changed files with 912 additions and 340 deletions

View File

@ -5,6 +5,9 @@
* 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"
@ -15,6 +18,9 @@
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);
@ -24,101 +30,175 @@ static void check_int(const char *what, int got, int want) {
}
}
static void check_str(const char *what, const char *got, const char *want) {
if (strcmp(got, want) != 0) {
printf(" [FAIL] %s: got \"%s\", want \"%s\"\n", what, got, want);
/* 断言「截断得到的字节数」确实是原文前缀,且正好是期望的字节长度。 */
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++;
} else {
g_pass++;
return;
}
if (got > len) {
printf(" [FAIL] %s: 返回值 %zu 超出输入长度 %zu\n", what, got, len);
g_fail++;
return;
}
g_pass++;
}
static void check_size(const char *what, size_t got, size_t want) {
if (got != want) {
printf(" [FAIL] %s: got %zu, want %zu\n", what, got, want);
/* 字节级断言:截断结果的字节内容必须与期望字符串逐字节相等。 */
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++;
} else {
g_pass++;
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("deepseek/deepseek-v4.1-flash"), 1048576);
ha_codec_model_context_window(LIT("deepseek/deepseek-v4.1-flash")), 1048576);
check_int("deepseek-v4-flash",
ha_codec_model_context_window("deepseek-v4-flash"), 1048576);
ha_codec_model_context_window(LIT("deepseek-v4-flash")), 1048576);
check_int("deepseek-chat",
ha_codec_model_context_window("deepseek-chat"), 65536);
ha_codec_model_context_window(LIT("deepseek-chat")), 65536);
check_int("claude-opus-5",
ha_codec_model_context_window("claude-opus-5"), 100000);
ha_codec_model_context_window(LIT("claude-opus-5")), 100000);
check_int("gpt-4-turbo",
ha_codec_model_context_window("gpt-4-turbo"), 128000);
ha_codec_model_context_window(LIT("gpt-4-turbo")), 128000);
check_int("llama-3-70b",
ha_codec_model_context_window("llama-3-70b"), 8192);
ha_codec_model_context_window(LIT("llama-3-70b")), 8192);
check_int("AUTO (unknown)",
ha_codec_model_context_window("AUTO"), HA_CODEC_CONTEXT_WINDOW_UNKNOWN);
ha_codec_model_context_window(LIT("AUTO")), HA_CODEC_CONTEXT_WINDOW_UNKNOWN);
check_int("NULL (unknown)",
ha_codec_model_context_window(NULL), HA_CODEC_CONTEXT_WINDOW_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("QWEN-MAX"), 131072);
ha_codec_model_context_window(LIT("QWEN-MAX")), 131072);
check_int("moonshot",
ha_codec_model_context_window("moonshot-v1-128k"), 131072);
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("gpt-4-mini"), 128000);
ha_codec_model_context_window(LIT("gpt-4-mini")), 128000);
check_int("gpt-4 (bare)",
ha_codec_model_context_window("gpt-4"), 8192);
ha_codec_model_context_window(LIT("gpt-4")), 8192);
/* claude-3 必须先于裸 claude */
check_int("claude-3-opus (branch order)",
ha_codec_model_context_window("claude-3-opus"), 200000);
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(""), 0);
check_int("NULL", ha_codec_estimate_tokens(NULL), 0);
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("abc"), 6);
/* "你好" = 2 rune * 2 = 4(注意:不是字节数 6) */
check_int("chinese 2 chars", ha_codec_estimate_tokens("你好"), 4);
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("a你"), 4);
check_int("mixed", ha_codec_estimate_tokens(LIT("a你")), 4);
/* 4 字节 emoji:1 rune * 2 = 2 */
check_int("emoji", ha_codec_estimate_tokens("\xF0\x9F\x98\x80"), 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");
char buf[64];
/* max_tokens<=0 → 空 */
ha_codec_truncate_by_tokens("hello", 0, buf, sizeof(buf));
check_str("max_tokens=0", buf, "");
/* max_tokens<=0 → 0 字节 */
check_trunc_prefix("max_tokens=0", LIT("hello"), 0, 0);
/* 未超限 → 原样返回 */
size_t n = ha_codec_truncate_by_tokens("abc", 100, buf, sizeof(buf));
check_str("no truncation", buf, "abc");
check_size("no truncation len", n, 3);
/* 未超限 → 全长 */
check_trunc_prefix("no truncation", LIT("abc"), 100, 3);
/* "abcdefghij" = 10 rune → 20 tokens;max=8 → keep=4 → "abcd" */
n = ha_codec_truncate_by_tokens("abcdefghij", 8, buf, sizeof(buf));
check_str("keep 4", buf, "abcd");
check_size("keep 4 len", n, 4);
check_trunc_prefix("keep 4", LIT("abcdefghij"), 8, 4);
/* 中文按 rune 截断,不切碎 UTF-8:"你好世界" 4 rune,max=4 → keep=2 → "你好" */
n = ha_codec_truncate_by_tokens("你好世界", 4, buf, sizeof(buf));
check_str("chinese keep 2", buf, "你好");
check_size("chinese keep 2 len (bytes)", n, 6);
/* 中文按 rune 截断,不切碎 UTF-8:"你好世界" 4 rune,max=4 → keep=2 → "你好"(6B) */
check_trunc_prefix("chinese keep 2", LIT("你好世界"), 4, 6);
/* 缓冲区不足:必须 NUL 结尾且不越界 */
char tiny[4];
n = ha_codec_truncate_by_tokens("abcdefghij", 100, tiny, sizeof(tiny));
check_size("tiny buf len", n, 3);
check_str("tiny buf NUL-terminated", tiny, "abc");
/* 恰好等于预算:不截断 */
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);
/* out_cap=0 不写 */
check_size("zero cap", ha_codec_truncate_by_tokens("abc", 100, tiny, 0), 0);
/* 长 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) {