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 2b78a9288e
commit bf8a01fb67
10 changed files with 1703 additions and 77 deletions

138
csrc/include/ha_sse.h Normal file
View File

@ -0,0 +1,138 @@
#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 0
#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);
#ifdef __cplusplus
}
#endif
#endif /* HA_SSE_H */