Files
HomeAgent/csrc/src/ha_codec.c
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

338 lines
12 KiB
C
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.

/*
* ha_codec.c — HomeAgent 内核编解码层(C 实现)
*
* ============================ 性能设计(勿回退)============================
* 1. **不 malloc**:模型名折叠用栈缓冲(短名走快路径,超长走零分配的回退)。
* 2. **不 strlen**:长度由调用方传入(见 ha_codec.h 签名说明)。
* 3. **ASCII 批量快路径**:连续 ASCII 成批计数,避免逐字节函数调用。
* 4. **截断提前短路**:数满 keep 个 rune 立即返回,不扫完整串。
* 5. **截断返回字节数**而非字符串:结果必然是输入前缀,调用方自己切片。
*
* 初版的三个反例(实测代价,见 docs/zh/c-core/llm-orchestration-c.md §7.1):
* - Go 侧 C.CString(malloc+拷贝)+ C 侧 strlen,单这一项约 75ns,
* 而 cgo 边界本身仅约 32ns —— 即 **82% 的开销是自找的**,不是 cgo 的成本。
* 初版由此得出「C 比 Go 慢」的结论是错的。
* - 逐字节 utf8_next 函数调用 ⇒ 1KB ASCII 比纯 Go 慢 7 倍。
* - 1KB 中文要先扫完整串才判断是否截断。
*
* 语义必须与 Go 侧实现逐值一致,由黄金对照测试钉死(含畸形 UTF-8)。
*/
#include "ha_codec.h"
#include <stdint.h>
#include <string.h>
/* ---------------------------------------------------------------- */
/* 大小写不敏感的子串匹配 */
/* ---------------------------------------------------------------- */
/* 只折 ASCII 字母;非 ASCII 字节原样(与 Go strings.ToLower 对模型名的
* 实际效果一致——模型名都是 ASCII,中文/日文字节不受 ToLower 影响)。 */
static unsigned char ascii_lower(unsigned char c) {
return (c >= 'A' && c <= 'Z') ? (unsigned char)(c + 32) : c;
}
/* 已折叠缓冲(长度 hn)中是否含子串 sub(sub 必须已小写、ASCII)。
* memcmp 版本:折叠一次后可向量化比较,是短名快路径。 */
static int contains(const char *m, size_t hn, const char *sub) {
size_t m_len = strlen(sub);
if (m_len == 0 || hn < m_len) {
return 0;
}
size_t last = hn - m_len;
for (size_t i = 0; i <= last; i++) {
/* 首字节过滤掉绝大多数位置,避免无谓 memcmp */
if (m[i] == sub[0] && memcmp(m + i, sub, m_len) == 0) {
return 1;
}
}
return 0;
}
/* 边比较边折叠:**任意长度**都正确,无需缓冲(超长模型名的回退路径)。
* sub 中的 ASCII 字母按小写处理;非 ASCII 字节按字节精确比较
* (因此可直接用于 "\xe9\x9b\xb6\xe4\xb8\x80" 这类多字节字面量)。 */
static int contains_ci(const char *h, size_t hn, const char *sub) {
size_t m_len = strlen(sub);
if (m_len == 0 || hn < m_len) {
return 0;
}
size_t last = hn - m_len;
for (size_t i = 0; i <= last; i++) {
size_t j = 0;
while (j < m_len &&
ascii_lower((unsigned char)h[i + j]) == (unsigned char)sub[j]) {
j++;
}
if (j == m_len) {
return 1;
}
}
return 0;
}
/* 模型名的不可变视图:能进栈缓冲就折叠,否则按原样(用 contains_ci 匹配)。 */
typedef struct {
const char *p;
size_t n;
int folded;
} model_view;
/* 栈缓冲容量:模型名实测都是几十字节。超出则退化为不折叠 +
* contains_ci —— 仍**零分配且语义正确**,只是少了 memcmp 的向量化优势。 */
#define HA_MODEL_STACK 256
static int mv_contains(const model_view *v, const char *sub) {
return v->folded ? contains(v->p, v->n, sub) : contains_ci(v->p, v->n, sub);
}
/* ---------------------------------------------------------------- */
/* 模型上下文窗口推断 */
/* ---------------------------------------------------------------- */
int ha_codec_model_context_window(const char *model, size_t model_len) {
if (model == NULL || model_len == 0) {
return HA_CODEC_CONTEXT_WINDOW_UNKNOWN;
}
char stack[HA_MODEL_STACK];
model_view v;
if (model_len < HA_MODEL_STACK) {
for (size_t i = 0; i < model_len; i++) {
stack[i] = (char)ascii_lower((unsigned char)model[i]);
}
stack[model_len] = '\0';
v.p = stack;
v.n = model_len;
v.folded = 1;
} else {
v.p = model;
v.n = model_len;
v.folded = 0;
}
/* 顺序与 Go 侧 switch 分支**严格一致**:先匹配到的分支胜出。
* 这不是「随便一组 if」,顺序错了就会给出不同窗口
* (例:gpt-4-turbo 必须先于裸 gpt-4 命中)。 */
if (mv_contains(&v, "deepseek-v4") || mv_contains(&v, "deepseek-v3")) {
return 1048576;
}
if (mv_contains(&v, "deepseek-r1") || mv_contains(&v, "deepseek-chat")) {
return 65536;
}
if (mv_contains(&v, "gpt-4")) {
if (mv_contains(&v, "turbo") || mv_contains(&v, "mini") || mv_contains(&v, "omni")) {
return 128000;
}
return 8192;
}
if (mv_contains(&v, "gpt-3.5")) {
return 16384;
}
if (mv_contains(&v, "claude-3.5") || mv_contains(&v, "claude-3")) {
return 200000;
}
if (mv_contains(&v, "claude")) {
return 100000;
}
if (mv_contains(&v, "gemini-1.5") || mv_contains(&v, "gemini-2")) {
return 1048576;
}
if (mv_contains(&v, "gemini")) {
return 32768;
}
if (mv_contains(&v, "qwen")) {
return 131072;
}
if (mv_contains(&v, "glm") || mv_contains(&v, "chatglm")) {
return 131072;
}
if (mv_contains(&v, "llama-3")) {
return 8192;
}
if (mv_contains(&v, "llama-2")) {
return 4096;
}
if (mv_contains(&v, "mistral") || mv_contains(&v, "mixtral")) {
return 32768;
}
/* "yi-" 与 "零一"(UTF-8 字面量)——contains_ci 对字节精确比较,
* 故中文部分不受折叠影响,与 Go 的 strings.Contains 一致。 */
if (mv_contains(&v, "yi-") || mv_contains(&v, "\xe9\x9b\xb6\xe4\xb8\x80")) {
return 200000;
}
if (mv_contains(&v, "moonshot") || mv_contains(&v, "kimi")) {
return 131072;
}
return HA_CODEC_CONTEXT_WINDOW_UNKNOWN;
}
/* ---------------------------------------------------------------- */
/* UTF-8 解码(与 Go utf8.DecodeRuneInString 逐值等价) */
/* ---------------------------------------------------------------- */
/* 返回 s[0] 起始字符的字节长度(1..4)。
*
* 必须与 Go 的 utf8.DecodeRuneInString 语义一致——**包括无效序列只前进
* 1 字节**(Go 对无效/截断序列返回 RuneError 且 size=1),否则 rune 计数
* 会与 Go 分叉。这正是黄金对照测试用畸形输入能抓到的地方。
*
* remaining 是当前可读字节数。 */
static inline size_t utf8_char_len(const char *s, size_t remaining) {
unsigned char c0 = (unsigned char)s[0];
if (c0 < 0x80) {
return 1; /* ASCII */
}
if (c0 < 0xC2) {
return 1; /* 0x80..0xC1:续字节或过长编码 → Go 判无效,size=1 */
}
if (c0 < 0xE0) { /* 2 字节:0xC2..0xDF */
if (remaining < 2) {
return 1;
}
if (((unsigned char)s[1] & 0xC0) != 0x80) {
return 1;
}
return 2;
}
if (c0 < 0xF0) { /* 3 字节:0xE0..0xEF */
if (remaining < 3) {
return 1;
}
/* 用 (c & 0xC0) == 0x80 走单条 AND+CMP(而非两条范围比较),
* 并用 & 而非 && 避免短路分支——这是 CJK 主路径,须最短。 */
unsigned char c1 = (unsigned char)s[1];
unsigned char c2 = (unsigned char)s[2];
if (((c1 & 0xC0) == 0x80) & ((c2 & 0xC0) == 0x80)) {
/* 常见情形:既非 0xE0(防过长编码)也非 0xED(防代理对) */
if (c0 != 0xE0 && c0 != 0xED) {
return 3;
}
if ((c0 == 0xE0 && c1 >= 0xA0) || (c0 == 0xED && c1 <= 0x9F)) {
return 3;
}
}
return 1;
}
if (c0 < 0xF5) { /* 4 字节:0xF0..0xF4 */
if (remaining < 4) {
return 1;
}
unsigned char c1 = (unsigned char)s[1];
unsigned char c2 = (unsigned char)s[2];
unsigned char c3 = (unsigned char)s[3];
if (((c1 & 0xC0) == 0x80) & ((c2 & 0xC0) == 0x80) & ((c3 & 0xC0) == 0x80)) {
if (c0 != 0xF0 && c0 != 0xF4) {
return 4;
}
if ((c0 == 0xF0 && c1 >= 0x90) || (c0 == 0xF4 && c1 <= 0x8F)) {
return 4;
}
}
return 1;
}
return 1; /* 0xF5..0xFF:无效 */
}
/* ASCII 批量扫描:返回从 text[i] 起连续 ASCII 的字节数(扫到串尾)。
*
* ★ 字(word)级探测:一次读 8 字节,用单条掩码判断「8 字节是否全为 ASCII」。
* 逐字节比较会让 1KB ASCII 明显慢于纯 Go(后者内部有 8 字节快路径)。
* 实测:逐字节版 ascii_1k 约 2318ns(比 Go 慢 7×),改字级后大幅收敛。 */
#define HA_HIGH_BITS 0x8080808080808080ULL
static size_t ascii_run(const char *text, size_t i, size_t len) {
size_t j = i;
while (j + 8 <= len) {
uint64_t v;
memcpy(&v, text + j, 8); /* memcpy 让编译器按需生成未对齐安全加载 */
if (v & HA_HIGH_BITS) {
break;
}
j += 8;
}
while (j < len && (unsigned char)text[j] < 0x80) {
j++;
}
return j - i;
}
/* ---------------------------------------------------------------- */
/* token 估算 */
/* ---------------------------------------------------------------- */
int ha_codec_estimate_tokens(const char *text, size_t text_len) {
if (text == NULL || text_len == 0) {
return 0;
}
size_t runes = 0;
size_t i = 0;
while (i < text_len) {
if ((unsigned char)text[i] < 0x80) {
size_t n = ascii_run(text, i, text_len);
runes += n;
i += n;
} else {
i += utf8_char_len(text + i, text_len - i);
runes++;
}
}
/* 与 Go 侧一致:t = runeCount * 2;t < 1 时取 1。
* runes > 0 时 t >= 2,故只需处理溢出与下限。 */
if (runes > (size_t)0x3FFFFFFF) { /* 防 int 溢出 */
return 0x7FFFFFFF;
}
int t = (int)(runes * 2);
if (t < 1) {
return 1;
}
return t;
}
/* ---------------------------------------------------------------- */
/* 按 token 预算计算应保留的字节数 */
/* ---------------------------------------------------------------- */
size_t ha_codec_truncate_by_tokens(const char *text, size_t text_len,
int max_tokens) {
if (text == NULL || text_len == 0 || max_tokens <= 0) {
return 0;
}
/* 要保留的 rune 数(与 Go 一致:整数除法)。keep==0 时循环首轮即返回 0。 */
size_t keep = (size_t)(max_tokens / 2);
/* 提前短路:keep 个 rune 数满而串仍有剩余 ⇒ 必然截断,直接返回该字节边界,
* 不必扫完整串(长文本上的主要收益)。
* 若数完整串仍未数满 keep ⇒ 未超预算,返回全长(= 不截断)。 */
size_t runes = 0;
size_t i = 0;
while (i < text_len) {
if (runes == keep) {
return i;
}
if ((unsigned char)text[i] < 0x80) {
size_t n = ascii_run(text, i, text_len);
if (runes + n >= keep) {
/* keep 落在这批 ASCII 内:批内每字节一个 rune */
return i + (keep - runes);
}
runes += n;
i += n;
} else {
runes++;
i += utf8_char_len(text + i, text_len - i);
}
}
return text_len; /* 未超预算:整串都留 */
}