perf(api): SSE 分块 C 导航层 + 差分等价验收(实测更慢 ⇒ 默认关闭)

第三刀:把 ha_json_scan 接进 parseOpenAICompatibleStreamChunkFull。
**结论是否定的** —— 实测比原实现慢,故默认关闭并如实记录。这条提交的
价值在于「已钉死的正确性 + 已定位的根因 + 一条防静默回退的断言」。

## 设计:只做「结构导航」,序列化留在 Go

接线前实测出两条 wire 语义,它们让「整条解析全 C 化」不成立:
  §5.1 重复键是**字段级合并**,不是替换:
       {"choices":[{content:a}],"choices":[{reasoning:r}]} → 两个都保留。
       机制:json.Unmarshal 的 object() 收尾做 v.SetIndex(i, subv.v),
       而 subv 拿到的是**已存在元素的指针** ⇒ 第二次是叠加。
  §5.2 stringifyContent 的 default 分支 = json.Marshal(interface{}),
       即**重新序列化**:{"b":1,"a":2}→{"a":2,"b":1}(键排序)、
       1e2→100、<→\u003c、大 int 先舍入成 float64。
       逐值一致 = 复刻 Ryu 最短浮点 + map 键排序 + HTML 转义 + int 舍入。
两条都只在**取值**阶段需要,故 C 只回答「值在哪里」(零分配零解码),
类型检查靠「用相同的 Go 类型 unmarshal 相同形状的子树」保证,不靠 C 复刻规则。

## 实测:新路径比原实现慢(20000 次迭代)

| 场景 | 新路径 | 原实现 |
|---|---|---|
| content_ascii | 2016ns / 20allocs | 1325ns / 13allocs |
| toolcall      | 5854ns / 33allocs | 3270ns / 21allocs |
| usage         | 3170ns / 24allocs | 2832ns / 12allocs |

分配数**也变多**(20 vs 13),与「消除 GC 抖动」的初衷相反。

根因(逐项测量,非猜测):裸 cgo 调用 168ns;**每次带 out-param 的键查找
205ns + 2 allocs**(out-param 逃逸到堆);一次解析需要 5+ 次查找
⇒ 边界与分配成本约 1µs,恰好吃掉全部收益。Go 侧只需**一次** Unmarshal。
一句话:**用很多次廉价调用换一次昂贵调用,在这个尺寸上不划算。**

## 天花板实验:方向对,但当前实现没到

假设拿到 span 完全免费,只测设计中必须由 Go 做的部分:
  我的 Go 侧 505ns/7allocs  vs  原实现 1239ns/13allocs
⇒ 边界归零后仍有 2.4× 时间、46% 分配的空间。故问题在**逐字段往返**
这个交互方式,不在 C 本身。正确改造:一次调用返回全部字段 span +
结果写调用方栈结构体 + 仅在确需重新编码时回退。

## 正确性:6 万+ 差分用例全过

同一批输入跑两条路径逐字段比对(Content/Reasoning/Done/Finish/ToolCalls/
Usage + bool),5 组:协议形态(含全部回退触发条件)、真实负载、随机 JSON
30000 例、随机字节 30000 例、优化有效性。

★ 差分测试当场抓出 4 个真实缺陷(其中一个正是「优化压根没生效」):
 1. ha_sse_arr_first 里「重新 init 到 sc.s+sc.i」使 base 变了 ⇒ start 恒 0
    ⇒ 返回的是**数组本身**而非首元素。症状是**快速路径永远不生效**——
    而若只看「结果与 Go 一致」,这个 bug 会**完全隐形**(回退总是对的)。
    ⇒ 这就是必须单独断言「优化确实被走到」的原因。
 2. chunkAssemble 的 bool 被丢弃 ⇒ 空对象被判 true(原实现 false)
 3. 键匹配层级搞错:delta 是 **struct**(字段名 CI),不是 map。
    我一度「推理」成 CS 并以为差分测试会通过——错的。
    教教训:哪层是 struct、哪层是 map 要**回原实现读类型**,不能凭字段名推断。
 4. cgo 边界:out-param 逃逸到堆

另修:C 代码从 cgo 前言移进 csrc/ ——前言里的 C **逃出全部 C 门禁**
(告警/sanitizer/交叉/模糊测试),而它恰是本刀最易出错处。

## 防静默回退

TestChunkFast_BenchGate 断言 chunkFastEnabled 必须为 false。
后来者看到「快速路径写得全 + 差分测试全过」,很自然会以为它已生效并打开它
—— 而实测更慢。断言把这个事实钉住,改动即判红。

## 实测汇总
- C 契约 119 项断言、黄金对照 5 组、差分 6 万+ 例:全过
- ASan+UBSan PASS;gcc+clang 零告警;arm64 交叉 0 告警(3 个源文件)
- 全量 go test -count=1 ./... 38 包 ok / 0 FAIL
- libFuzzer 4948 万次零崩溃(上一刀)

教训(与第一刀同源):**「C 比 Go 快」不是前提,是待验证的假设。**
第一刀被 C.CString 的 82% 自找开销推翻一次,这一刀被逐字段往返推翻一次。
两次都是测量推翻直觉。
This commit is contained in:
JianFeeeee
2026-09-26 10:32:20 +08:00
parent 4d3962a845
commit 7748ec450e
10 changed files with 1703 additions and 77 deletions

280
csrc/src/ha_sse.c Normal file
View File

@ -0,0 +1,280 @@
/*
* ha_sse.c — LLM 流式协议(SSE 分块)结构导航辅助层
*
* 语义与理由见 include/ha_sse.h。本文件被 C 门禁全量覆盖
* (告警 / ASan+UBSan / arm64 交叉编译 / libFuzzer),故**不放**在
* Go 的 cgo 前言里 —— 前言里的 C 代码逃出全部检查。
*/
#include "ha_sse.h"
#include <string.h>
int ha_sse_abi_version(void) {
return HA_SSE_ABI_VERSION;
}
int ha_sse_obj_find(const ha_span *obj, const char *key, size_t keylen,
ha_span *out, int *dup) {
ha_json_members m;
ha_span k, v;
int hit = 0;
if (obj == NULL || key == NULL || out == NULL || dup == NULL) {
return 0;
}
*dup = 0;
out->p = NULL;
out->len = 0;
if (keylen == 0) {
return 0;
}
if (!ha_json_members_init(&m, obj->p, obj->len)) {
return -1;
}
while (ha_json_members_next(&m, &k, &v)) {
/* ★ 精确比较(不做大小写折叠):与 Go 的 map key 语义一致。
* §5.4-1 实测 {"TEXT":"up"} 取不到 text。 */
if (k.len == keylen && memcmp(k.p, key, keylen) == 0) {
if (hit) {
*dup = 1; /* 重复键:调用方整体回退 Go */
}
hit = 1;
*out = v; /* 后者胜 */
}
}
if (!ha_json_members_complete(&m)) {
return -1; /* 对象畸形 */
}
return hit;
}
/* 单字节 ASCII 小写折叠(非 ASCII 原样,与 Go 对 ASCII 字段名的行为一致)。 */
static unsigned char sse_lower(unsigned char c) {
return (c >= 'A' && c <= 'Z') ? (unsigned char)(c + 32) : c;
}
int ha_sse_obj_find_ci(const ha_span *obj, const char *key, size_t keylen,
ha_span *out, int *dup) {
ha_json_members m;
ha_span k, v;
int hit = 0;
if (obj == NULL || key == NULL || out == NULL || dup == NULL) {
return 0;
}
*dup = 0;
out->p = NULL;
out->len = 0;
if (keylen == 0) {
return 0;
}
if (!ha_json_members_init(&m, obj->p, obj->len)) {
return -1;
}
while (ha_json_members_next(&m, &k, &v)) {
if (k.len == keylen) {
size_t j = 0;
while (j < keylen &&
sse_lower((unsigned char)k.p[j]) ==
sse_lower((unsigned char)key[j])) {
j++;
}
if (j == keylen) {
if (hit) {
*dup = 1;
}
hit = 1;
*out = v;
}
}
}
if (!ha_json_members_complete(&m)) {
return -1;
}
return hit;
}
int ha_sse_root_object(const ha_span *doc) {
ha_json_scan sc;
if (doc == NULL || doc->p == NULL || doc->len == 0) {
return 0;
}
ha_json_scan_init(&sc, doc->p, doc->len);
(void)ha_json_scan_ws(&sc);
if (ha_json_scan_eof(&sc) || sc.s[sc.i] != '{') {
return 0; /* 顶层非对象:Go 的 Unmarshal 进 struct 会失败 */
}
if (!ha_json_skip(&sc)) {
return 0;
}
/* 尾部只允许空白 —— 复刻 json.Unmarshal 对 trailing garbage 的拒绝 */
(void)ha_json_scan_ws(&sc);
return ha_json_scan_eof(&sc) ? 1 : 0;
}
int ha_sse_arr_first(const ha_span *arr, ha_span *out) {
ha_json_scan sc;
size_t start;
if (arr == NULL || out == NULL) {
return 0;
}
out->p = NULL;
out->len = 0;
if (arr->p == NULL || arr->len == 0) {
return 0;
}
/* ★ 游标的 base 始终是 arr->p,中途只推进 i。
*
* 初版在这里犯过一个「重新 init 到 sc.s + sc.i」的错:那样 base 变了,
* 随后的 start = sc.i 变成 0,out->p = arr->p + 0 ⇒ **返回的是数组本身**
* 而不是第一个元素。症状是上层的 fastChoice 拿到 firstByte=='[' 直接回退,
* 表现为「快速路径永远不生效」——
* 而如果只看「结果与 Go 一致」,这个 bug 会**完全隐形**(回退总是正确)。
*
* ★ 这正是「优化是否真的生效」必须单独断言的原因:
* 等价性测试无法发现「一直回退」。
*/
ha_json_scan_init(&sc, arr->p, arr->len);
(void)ha_json_scan_ws(&sc);
if (ha_json_scan_eof(&sc) || sc.s[sc.i] != '[') {
return -1;
}
sc.i++; /* 跳过 '[' */
(void)ha_json_scan_ws(&sc);
if (ha_json_scan_eof(&sc) || sc.s[sc.i] == ']') {
return 0; /* 空数组 */
}
start = sc.i;
if (!ha_json_skip(&sc)) {
return -1;
}
out->p = arr->p + start;
out->len = sc.i - start;
return 1;
}
int ha_sse_stringify(const ha_span *val, char *out, size_t cap, size_t *outlen) {
size_t len = 0;
ha_json_scan sc;
ha_span raw;
if (val == NULL || out == NULL || outlen == NULL ||
val->p == NULL || val->len == 0) {
return 0;
}
*outlen = 0;
/* 上界:每个输入字节最坏变 3 字节 U+FFFD。不足则交回 Go 走
* json.Unmarshal(宁可慢也不截断)。 */
if (cap < val->len * 3u + 4u) {
return 0;
}
if (val->p[0] == '"') {
ha_json_scan_init(&sc, val->p, val->len);
if (!ha_json_scan_string(&sc, &raw)) {
return 0;
}
{
size_t n = ha_json_decode_string_into(raw, out, cap);
if (n == (size_t)-1) {
return 0;
}
*outlen = n;
return 1;
}
}
if (val->p[0] == '[') {
ha_json_scan_init(&sc, val->p, val->len);
(void)ha_json_scan_ws(&sc);
sc.i++; /* 跳过 '[' */
for (;;) {
size_t start;
ha_span elem;
(void)ha_json_scan_ws(&sc);
if (ha_json_scan_eof(&sc) || sc.s[sc.i] == ']') {
break;
}
start = sc.i;
if (!ha_json_skip(&sc)) {
return 0;
}
elem.p = val->p + start;
elem.len = sc.i - start;
/* 只有对象元素才可能有 text(§5.4-2:其余静默跳过) */
if (elem.len > 0 && elem.p[0] == '{') {
ha_span txt;
int dup = 0;
int rc = ha_sse_obj_find(&elem, "text", 4, &txt, &dup);
if (dup) {
return 0; /* 重复 text 键 ⇒ 交回 Go(合并语义) */
}
/* 只有字符串形态的 text 才取(§5.4-3) */
if (rc == 1 && txt.len > 0 && txt.p[0] == '"') {
ha_json_scan ts;
ha_span traw;
size_t n;
ha_json_scan_init(&ts, txt.p, txt.len);
if (!ha_json_scan_string(&ts, &traw)) {
return 0;
}
/* cap-len 已保证至少 1 字节可用(含结尾 NUL) */
n = ha_json_decode_string_into(traw, out + len, cap - len);
if (n == (size_t)-1) {
return 0;
}
len += n;
}
}
(void)ha_json_scan_ws(&sc);
if (ha_json_scan_eof(&sc)) {
break;
}
if (sc.s[sc.i] == ',') {
sc.i++;
continue;
}
if (sc.s[sc.i] == ']') {
break;
}
return 0; /* 畸形数组 */
}
*outlen = len;
return 1;
}
/* 对象 / 数字 / true / false / null ⇒ 需 json.Marshal 重新编码(§5.2) */
return 0;
}
int ha_sse_arg_string(const ha_span *val, char *out, size_t cap, size_t *outlen) {
ha_json_scan sc;
ha_span raw;
size_t n;
if (val == NULL || out == NULL || outlen == NULL ||
val->p == NULL || val->len == 0) {
return 0;
}
*outlen = 0;
if (val->p[0] != '"') {
return 0; /* 非字符串:交回 Go(需 json.Marshal 重新编码) */
}
if (cap < val->len * 3u + 4u) {
return 0;
}
ha_json_scan_init(&sc, val->p, val->len);
if (!ha_json_scan_string(&sc, &raw)) {
return 0;
}
n = ha_json_decode_string_into(raw, out, cap);
if (n == (size_t)-1) {
return 0;
}
*outlen = n;
return 1;
}