mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 12:53:35 +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 的既有竞态,需单独排查。
338 lines
12 KiB
C
338 lines
12 KiB
C
/*
|
||
* 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; /* 未超预算:整串都留 */
|
||
}
|