mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 05:13:27 +00:00
C 化从「一把刀」推进到「可持续推进」,本轮先把基础设施建起来:
不建它,后续每个 C 切片都在裸奔(无告警门禁、无内存安全检查、
无交叉编译验证、无 ABI 漂移检测)。
新增门禁(make check-csrc / check-csrc-full,已接进 make test):
- csrc-lint gcc+clang 双编译器 × -Wall -Wextra -Wpedantic -Wshadow
-Wconversion,零告警才算过(-Wconversion 是为 cgo 窄化
准备的:size_t→int 截断在默认档下是静默的)
- csrc-abi ABI 版本运行期自述 + 荒谬值检查
- csrc-headers 头文件自包含性(每个 .h 能单独编过)
- csrc-sanitize ASan+UBSan 跑 C 契约测试
- csrc-cross arm64 交叉编译(homed 的真实发布目标)
- csrc-fuzz libFuzzer:内存安全 + 6 条不变式(可 CI 门禁)
新增 ABI 契约(csrc/include/ha_abi.h):
- ha_codec.h 声明「签名冻结」,但冻结只写在注释里;现改为
HA_CODEC_ABI_MAJOR/MINOR + 运行期自述 + Go 侧常量,
三方交叉断言,版本漂移在测试期判红而非线上表现为行为诡异。
- 门禁当场抓出我自己的两个真 bug:①_Static_assert 是 C11 而项目
是 -std=c99(-Wpedantic 报的);②##msg 不能拼接字符串字面量,
导致两个断言共用一个 typedef 名(clang 报的)。
基础设施当场抓出的三个真实缺陷(都是「本机 gcc 能编过、别处会炸」类):
- bench 用了 POSIX clock_gettime,而 CMake 刻意 C_EXTENSIONS OFF
(严格 c99)→ 头文件未声明;补 _POSIX_C_SOURCE(须在任何头之前)
- 头文件缺 include 时只在「恰好被别的头先包含」处静默编过
- Go 不允许在 _test.go 用 cgo ⇒ C 侧 const 桥接只能放非测试文件,
且 cgo 生成的 *_Cvar_* 不是 Go 常量(「两边一起错成一样」的盲区,
改用 C 函数返回 + 编译期 _Static_assert 补上)
实测(全部当场可复现):
- 双编译器 × c99/c11 零告警;ASan+UBSan PASS
- libFuzzer 91s 跑 3329316 次、零崩溃(6 条不变式全过)
- 变异测试:改 C 侧宏 / 让 Go 常量与 C 函数「一起错成一样」,
均被对应断言抓住(证明门禁不是摆设)
- C 侧纯函数基准(首次把函数体成本与 cgo 边界成本分开测):
ascii_1k 121.7ns/8.4GB/s;zh_1k 1434ns;truncate 两者均约 128-134ns
- 全量 go test -count=1 ./...:57 包 0 FAIL
- make build-linux-arm64 → ELF aarch64
- 纪律检查 FAIL=0;SDK 公开接口 diff = 0 行
未动:csrc/ 是内核 C ABI,不属 third_party/homeagent-sdk 公开接口。
346 lines
12 KiB
C
346 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; /* 未超预算:整串都留 */
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- */
|
||
/* ABI 自述 */
|
||
/* ---------------------------------------------------------------- */
|
||
|
||
int ha_codec_abi_version(void) {
|
||
return HA_CODEC_ABI_VERSION;
|
||
}
|