mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 12:23:23 +00:00
按 sse-codec-c.md §6.4 的架构改造方向返工。**部分成功**:从「五项全输」
变成「三项赢 / 一项持平 / 一项输」,且所有场景分配数都下降。
## 改造内容
| 项 | 前 | 后 |
|---|---|---|
| cgo 边界次数 | 5+(每字段一次 findKey) | 1(ha_sse_chunk_locate) |
| 键查找 | 每键各扫一遍对象(6 趟) | 单趟分派(遍历成员表一次即分发) |
| 解码 | 每字段一次往返 + 各自 decBuf | 同一趟内写进一块 sbuf(1 次分配) |
| 成员表遍历 | 6 趟 | 2 趟(顶层 + delta) |
顺带修掉两处自造的浪费(都是「先扫一遍拿个数、再扫第二遍拿首元素」):
choices 数组的「数个数 + 取首元素」合一趟;choice0 内的 delta/finish_reason
合一趟。成员遍历实测 107ns/趟,省一趟就是省 107ns。
新增 ha_sse_chunk_locate:一次调用完成根校验 + 顶层分派 + choices[0] +
delta 分派 + content/reasoning/finish 解码,输出写调用方持有的 C 结构体
(C 结构体无 Go 指针 ⇒ 可安全传指针,消除 out-param 逃逸)。
choices_count>1 时直接回退(Go 侧 Unmarshal 会解析全部元素,本层只认 [0],
其余元素可能类型不符而让 Go 整块作废 ⇒ 无法保证等价)。
## 实测(50000 次 × 3 轮取中位)
| 场景 | Entry | GoOnly | 判定 |
|---|---|---|---|
| content_zh | 1540ns / 5allocs | 1871ns / 13allocs | 快 18%,分配 -62% |
| content_ascii | 1250ns / 5allocs | 1304ns / 13allocs | 持平,分配 -62% |
| finish | 820ns / 6allocs | 921ns / 12allocs | 快 11% |
| usage | 2530ns / 9allocs | 2591ns / 12allocs | 持平偏快 |
| toolcall | 3450ns / 20allocs | 3000ns / 21allocs | 慢 15% |
## toolcall 仍输的根因(已定位,非猜测)
分解测量:C 侧纯 C 零边界 = 766ns;Go 侧 []openAIToolCall unmarshal =
1305ns/15allocs;对照 Go 整块 unmarshal ≈ 2980ns。
问题在第二行:tool_calls 元素是对象,Arguments interface{} 需要真实的
map[string]interface{},必须走 encoding/json 的反射建树。
而为了定位已先做了一遍 C 扫描 ⇒ 同一份数据被解析了两次。
⇒ 不是 C 慢,是「扫两遍 vs 扫一遍」。
标量字段(content/reasoning/finish)C 能一次到位 ⇒ 那些场景赢;
需要建树的字段(tool_calls/usage)C 的定位是纯开销。
## 为什么仍默认关闭(理由充分,不是保守)
1. toolcall 是真实负载最常见的一类块(任何一次工具调用流),仍慢 15%
2. 18% 收益不足以抵消「与 encoding/json 语义并存的第二实现」的风险
3. 本刀原始动机在 toolcall 场景没有兑现:分配数 20 vs 21 几乎没降
⇒ 前提是先做「按字段类型决定是否 C 化」,让 toolcall 也不输,再重测。
## 正确性
6 万+ 差分用例(协议形态/真实负载/随机 JSON 3 万/随机字节 3 万)全过。
基准测量也修了:先前 C 基准脚本用 CLOCK_MONOTONIC 却只取 tv_nsec,
算出 -4201ns 的负值 —— 测量工具本身出错会直接毁掉结论。
## 验证
ASan+UBSan PASS;gcc+clang 零告警;arm64 交叉 0 告警;
libFuzzer 66 万次零崩溃;全量 go test 38 包 ok / 0 FAIL
219 lines
10 KiB
C
219 lines
10 KiB
C
#ifndef HA_SSE_H
|
||
#define HA_SSE_H
|
||
|
||
/*
|
||
* ha_sse — LLM 流式协议(SSE 分块)的「结构导航」辅助层
|
||
*
|
||
* ============================ 定位 ============================
|
||
* 本层不是 JSON 库(那是 ha_json_scan),而是把 ha_json_scan 原语组合成
|
||
* **协议层需要的几次定位**,供内核 parseOpenAICompatibleStreamChunkFull 使用。
|
||
*
|
||
* ★ 为什么这些函数放在 csrc/ 而不是内联在 Go 的 cgo 前言里:
|
||
* 放在 cgo 前言里的 C 代码**逃出了全部 C 门禁**(告警 / ASan+UBSan /
|
||
* 交叉编译 / 模糊测试),而它恰恰是本刀最容易出错的位置。
|
||
* 移进 csrc/ 后,同一个 -Wall -Wextra -Wpedantic -Wconversion 门禁
|
||
* 与 sanitizer 都覆盖到它 —— 这是一次真实的结构调整,不是形式主义。
|
||
*
|
||
* ============================ 为什么只做「导航」 ============================
|
||
* 实测两条 wire 语义(docs/zh/c-core/sse-codec-c.md §5)使「全量 C 化」不成立:
|
||
* §5.1 重复键是**字段级合并**(json.Unmarshal 的 SetIndex 叠加语义)
|
||
* §5.2 stringifyContent 的 default 分支是 json.Marshal(键排序 / 浮点
|
||
* 最短往返 / HTML 转义 / int 舍入)
|
||
* 二者都只在**取值**阶段需要,故本层只回答「值在哪里、它的热分支结果是什么」,
|
||
* 需要重新序列化的形态交回 Go(由 encoding/json 保证语义)。
|
||
*
|
||
* ============================ 键匹配:大小写敏感 ============================
|
||
* 本层是 **map key** 语义(`m["text"]`)⇒ 大小写敏感。
|
||
* 实测 `{"TEXT":"up"}` 取不到 `text`、`{"text":"low"}` 可以(§5.4-1)。
|
||
*
|
||
* ⚠️ 与 ha_json_key_eq(大小写**不**敏感,用于 struct 字段名)语义相反。
|
||
* 两者用途不同、都必要,**不要「统一」掉**。
|
||
* struct 字段那一跳由 encoding/json 负责,天然正确。
|
||
*/
|
||
|
||
#include <stddef.h>
|
||
|
||
#include "ha_abi.h"
|
||
#include "ha_json_scan.h"
|
||
|
||
#ifdef __cplusplus
|
||
extern "C" {
|
||
#endif
|
||
|
||
#define HA_SSE_ABI_MAJOR 1
|
||
#define HA_SSE_ABI_MINOR 1
|
||
#define HA_SSE_ABI_VERSION (HA_SSE_ABI_MAJOR * 1000 + HA_SSE_ABI_MINOR)
|
||
|
||
HA_STATIC_ASSERT(HA_SSE_ABI_MAJOR >= 1 && HA_SSE_ABI_MAJOR <= 9,
|
||
ha_sse_abi_major_in_range);
|
||
HA_STATIC_ASSERT(HA_SSE_ABI_MINOR >= 0 && HA_SSE_ABI_MINOR <= 99,
|
||
ha_sse_abi_minor_in_range);
|
||
|
||
int ha_sse_abi_version(void);
|
||
|
||
/*
|
||
* 在对象里按**大小写敏感**的键定位值。
|
||
*
|
||
* 返回: 1 = 找到(*out 已写);0 = 未找到(对象良构);-1 = 对象畸形。
|
||
* *dup 在发现**重复键**时置 1(后者胜已写入 *out)——
|
||
* 调用方据此整体回退到 encoding/json,因为重复键的字段级合并语义
|
||
* 见 sse-codec-c.md §5.1,本层不实现。
|
||
*/
|
||
int ha_sse_obj_find(const ha_span *obj, const char *key, size_t keylen,
|
||
ha_span *out, int *dup);
|
||
|
||
/*
|
||
* 在对象里按**大小写不敏感**的键定位值(struct 字段语义)。
|
||
*
|
||
* ★ 为什么必须与 ha_sse_obj_find 并存(两个函数,语义相反):
|
||
* - Go 的 `raw struct{ Choices ... \`json:"choices"\` }` 是 **struct 字段**,
|
||
* encoding/json 对字段名做**大小写不敏感**匹配 ⇒ 实测
|
||
* `{"CHOICES":[{"DELTA":{"CONTENT":"ci"}}]}` 能取到 content="ci"。
|
||
* - 而 `content` 是 `interface{}` → `map[string]interface{}`,取 `m["text"]`
|
||
* 是 **map key** 语义 ⇒ 大小写**敏感**(实测 `{"TEXT":"up"}` 取不到)。
|
||
* 跳错层就会静默漏掉字段(或取到不该取的),故两个函数都必要,
|
||
* 调用方必须按「这一跳在 Go 里是 struct 还是 map」来选择。
|
||
*
|
||
* 返回与 ha_sse_obj_find 相同:1=找到 0=未找到 -1=畸形。
|
||
* 对 *dup:大小写不敏感语义下,`{"CHOICES":..,"choices":..}` 两次都会命中
|
||
* 同一个 Go 字段(后者胜),故同样置 dup 让调用方回退。
|
||
*/
|
||
int ha_sse_obj_find_ci(const ha_span *obj, const char *key, size_t keylen,
|
||
ha_span *out, int *dup);
|
||
|
||
/*
|
||
* 校验 doc 是「**恰好一个**良构 JSON 对象」(尾部只允许空白)。
|
||
*
|
||
* 返回 1 = 是;0 = 否。
|
||
*
|
||
* ★ 为什么必须单独校验尾部:ha_sse_obj_find 用 members_complete 只保证
|
||
* 对象本身闭合,**不检查尾部残留** —— 而 Go 的 json.Unmarshal 会拒绝
|
||
* `{"a":1}{"b":2}`(trailing garbage)。少了这一步,快速路径会比 Go 宽松,
|
||
* 把一个 Go 判为失败的块判为成功 ⇒ 静默接受垃圾块。
|
||
*/
|
||
int ha_sse_root_object(const ha_span *doc);
|
||
|
||
/*
|
||
* 取数组**第一个元素**的 span。
|
||
*
|
||
* 返回: 1 = 有元素;0 = 空数组;-1 = 非数组或畸形。
|
||
*
|
||
* 为什么只要第一个:`choices[0]` 是协议约定(Go 侧也只读 resp.Choices[0]),
|
||
* 本层据此避免为后续元素做无用功。
|
||
*/
|
||
int ha_sse_arr_first(const ha_span *arr, ha_span *out);
|
||
|
||
/*
|
||
* stringifyContent 的 **C 可判定分支**:
|
||
* - 字符串值 → 反转义后原样输出
|
||
* - 数组值 → 逐元素取对象的 "text" 字段(精确键)拼接
|
||
*
|
||
* 返回: 1 = 已写入(*outlen 为字节数);0 = 需回退 Go。
|
||
* 回退的两种情形:
|
||
* a) 缓冲不足(调用方应给 >= val->len*3+4 的 cap)
|
||
* b) 值类型是对象 / 数字 / 字面量 —— 那些要走 json.Marshal(§5.2)
|
||
*
|
||
* 数组元素的规则(§5.4-2/3 实测):
|
||
* · 非对象元素 **静默跳过**(`["a",{"text":"b"}]` → "b")
|
||
* · 非对象的 "text"(如 text:123)**静默跳过**
|
||
* · 元素里出现重复的 "text" 键 ⇒ 整体回退 Go(合并语义)
|
||
*/
|
||
int ha_sse_stringify(const ha_span *val, char *out, size_t cap, size_t *outlen);
|
||
|
||
/*
|
||
* arguments 为**字符串**时,取出其解码结果(省掉 interface{} 与二次解析)。
|
||
*
|
||
* 返回 1 = 已写入;0 = 不是字符串或失败(调用方按既有路径处理)。
|
||
* 非字符串 arguments(对象/数组/数字)**必须**回退 Go:那里的
|
||
* `rawArgsString` 会 `json.Marshal` 重新编码,而这个**重新编码的键序
|
||
* 可能与原文不同**(实测 {"b":2,"a":1} → {"a":1,"b":2})——
|
||
* 逐值一致要求由 encoding/json 来做。
|
||
*/
|
||
int ha_sse_arg_string(const ha_span *val, char *out, size_t cap, size_t *outlen);
|
||
|
||
/* ==================================================================== */
|
||
/* 批量定位:**一次调用**返回整块解析所需的全部字段 */
|
||
/* ==================================================================== */
|
||
/*
|
||
* ★ 为什么需要它(第三刀实测的教训,见 sse-codec-c.md §六):
|
||
* 逐字段往返做 5+ 次 cgo 调用,每次约 168ns 边界 + 2 allocs(out-param
|
||
* 逃逸到堆)⇒ 约 1µs 固定成本,把全部收益吃光,结果比原实现更慢。
|
||
*
|
||
* 本接口把它压成 **1 次调用**,并顺带解决另外两点:
|
||
* · **单趟键分派**:不再「每个键各扫一遍对象」,而是遍历一次成员表
|
||
* 就分派(原来 6 次扫描 → 2 次)
|
||
* · **解码内联**:content / reasoning_content 的解码在同一趟里写进
|
||
* 调用方缓冲,不再各来一次往返
|
||
*
|
||
* 结果写在调用方的 ha_chunk_out 里(C 结构体、无 Go 指针 ⇒ 可安全传指针)。
|
||
*/
|
||
|
||
/* 槽位索引(固定约定,**改动必须 bump ABI**)。 */
|
||
#define HA_CHUNK_SLOT_DELTA 0
|
||
#define HA_CHUNK_SLOT_CONTENT 1
|
||
#define HA_CHUNK_SLOT_REASONING 2
|
||
#define HA_CHUNK_SLOT_TOOL_CALLS 3
|
||
#define HA_CHUNK_SLOT_FINISH_REASON 4
|
||
#define HA_CHUNK_SLOT_USAGE 5
|
||
#define HA_CHUNK_SLOT_COUNT 6
|
||
|
||
/* 槽位类型。与 Go 侧「该字段是什么 Go 类型」对应,而非单纯 JSON 类型。 */
|
||
#define HA_CHUNK_KIND_ABSENT 0
|
||
#define HA_CHUNK_KIND_NULL 1
|
||
#define HA_CHUNK_KIND_STRING 2
|
||
#define HA_CHUNK_KIND_OBJECT 3
|
||
#define HA_CHUNK_KIND_ARRAY 4
|
||
#define HA_CHUNK_KIND_OTHER 5 /* 数字 / 布尔 */
|
||
|
||
/* ha_sse_chunk_locate 返回码。 */
|
||
#define HA_CHUNK_OK 0 /* 定位成功,可用快速路径 */
|
||
#define HA_CHUNK_FALLBACK -1 /* 需回退 Go:重复键 / 畸形 / 顶层非对象 /
|
||
* 多 choices / 缓冲不足 */
|
||
#define HA_CHUNK_TYPE_FAIL -2 /* 与 Go 一致的「整块作废」(类型不符) */
|
||
|
||
typedef struct {
|
||
ha_span span; /* 原始值 span(未解码,指向 data) */
|
||
int kind; /* HA_CHUNK_KIND_* */
|
||
} ha_chunk_slot;
|
||
|
||
typedef struct {
|
||
ha_chunk_slot slot[HA_CHUNK_SLOT_COUNT];
|
||
/* choices 数组本身的 span(choices_count>0 时有效) */
|
||
ha_span choices_span;
|
||
/* choices[0] 的 span(choices_count==1 时有效) */
|
||
ha_span choice0_span;
|
||
/* 解码/反转义结果(写入 sbuf,以 [off,len) 表示;kind 非字符串时为 (0,0)) */
|
||
size_t content_off; size_t content_len;
|
||
size_t reasoning_off; size_t reasoning_len;
|
||
size_t finish_off; size_t finish_len;
|
||
|
||
int has_choices; /* choices 是否存在且非 null */
|
||
int choices_kind; /* ABSENT / NULL / ARRAY */
|
||
int choices_count; /* 元素个数(>1 时调用方必须回退,见下) */
|
||
int choice0_kind; /* ABSENT / NULL / OBJECT */
|
||
} ha_chunk_out;
|
||
|
||
/*
|
||
* 一次调用定位整块解析所需的全部字段。
|
||
*
|
||
* data/len : SSE chunk 原始字节(不需要 NUL 结尾)
|
||
* out : 输出(调用方持有;C 只在本调用内写它)
|
||
* sbuf/scap : 解码输出缓冲(content / reasoning_content / finish_reason)
|
||
* sused : 出参,缓冲区实际用量
|
||
*
|
||
* 返回 HA_CHUNK_OK / HA_CHUNK_FALLBACK / HA_CHUNK_TYPE_FAIL。
|
||
*
|
||
* ★ 调用方**必须**检查 choices_count:Go 侧是 `[]struct`,Unmarshal 会解析
|
||
* **全部**元素,而本层只取 [0](协议约定)。若元素 >1,本层无法保证
|
||
* 其余元素也能被 Go 解析(它们可能有类型错误)⇒ 必须回退。
|
||
* 本函数在 choices_count>1 时**直接返回 FALLBACK**,不给调用方犯错的机会。
|
||
*/
|
||
int ha_sse_chunk_locate(const char *data, size_t len, ha_chunk_out *out,
|
||
char *sbuf, size_t scap, size_t *sused);
|
||
|
||
#ifdef __cplusplus
|
||
}
|
||
#endif
|
||
|
||
#endif /* HA_SSE_H */
|