Files
HomeAgent/internal/agent/api/codec_streamchunk_c.go
JianFeeeee 7748ec450e 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% 自找开销推翻一次,这一刀被逐字段往返推翻一次。
两次都是测量推翻直觉。
2026-09-26 10:32:20 +08:00

329 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build cgo
package api
// codec_streamchunk_c.go —— SSE 分块解析的 C 化「结构导航」层(Go 侧绑定)
//
// ============================ 为什么是「导航」而不是「全量编解码」 ============================
// 接线前实测出两条 wire 语义(docs/zh/c-core/sse-codec-c.md §5),它们让
// 「整条 parseOpenAICompatibleStreamChunkFull 全 C 化」不成立:
//
// §5.1 重复键是**字段级合并**:`{"choices":[{content:a}],"choices":[{reasoning:r}]}`
// → content="a" **且** 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:把 JSON 定位到「哪个值在哪里」——零分配、零解码,并直接给出两个热分支的结果
// Go:把「已定位的原始字节」按既有类型 unmarshal,成串逻辑完全不变
//
// ⇒ 类型检查的等价性靠「用**相同的 Go 类型** unmarshal **相同形状的子树**」保证,
// 而不靠 C 重新实现一遍类型规则。这是本设计同时拿到速度与正确性的关键。
//
// 代价如实记录:命中字段仍要一次小 Unmarshal(原来是对整块做)。收益是免除
// json.Unmarshal 对整块的**反射建树**——那正是每块 12~21 allocs 的主因。
//
// ★ 键匹配**大小写敏感**(与 ha_json_scan.h 的 ha_json_key_eq 相反,两者用途不同)
// `content` 是 map[string]interface{},取 `m["text"]` 走 map key 语义
// ⇒ 大小写敏感。实测 `{"TEXT":"up"}` 取不到 `text`。
// struct 字段(choices/delta/usage)是大小写**不**敏感 —— 那一跳交给
// encoding/json,天然正确。
//
// ★ C 实现放在 csrc/ha_sse.c 而**不是**本文件的 cgo 前言里:
// 前言里的 C 代码会逃出全部 C 门禁(告警 / ASan+UBSan / arm64 交叉 / 模糊测试),
// 而这里恰恰是本刀最容易出错的位置。这是结构性决定,不是形式主义。
/*
#cgo CFLAGS: -std=c99
#include <stdlib.h>
#include "ha_sse.h"
// C 结构体一律不跨越语言边界(cgo 禁止「Go 指针指向的 Go 指针」,
// 实测会 panic),故所有 span 传递都拆成 (指针, 长度) 标量。
static int go_obj_find(const char *p, size_t n, const char *key, int keylen,
char **vp, size_t *vlen, int *dup) {
ha_span obj, out;
obj.p = p; obj.len = n;
int rc = ha_sse_obj_find(&obj, key, (size_t)keylen, &out, dup);
if (rc == 1) { *vp = (char *)out.p; *vlen = out.len; }
return rc;
}
static int go_arr_first(const char *p, size_t n, char **vp, size_t *vlen) {
ha_span arr, out;
arr.p = p; arr.len = n;
int rc = ha_sse_arr_first(&arr, &out);
if (rc == 1) { *vp = (char *)out.p; *vlen = out.len; }
return rc;
}
static int go_stringify(const char *p, size_t n, char *out, size_t cap,
size_t *outlen) {
ha_span val;
val.p = p; val.len = n;
return ha_sse_stringify(&val, out, cap, outlen);
}
static int go_arg_string(const char *p, size_t n, char *out, size_t cap,
size_t *outlen) {
ha_span val;
val.p = p; val.len = n;
return ha_sse_arg_string(&val, out, cap, outlen);
}
static int go_obj_find_ci(const char *p, size_t n, const char *key, int keylen,
char **vp, size_t *vlen, int *dup) {
ha_span obj, out;
obj.p = p; obj.len = n;
int rc = ha_sse_obj_find_ci(&obj, key, (size_t)keylen, &out, dup);
if (rc == 1) { *vp = (char *)out.p; *vlen = out.len; }
return rc;
}
static int go_root_object(const char *p, size_t n) {
ha_span doc;
doc.p = p; doc.len = n;
return ha_sse_root_object(&doc);
}
// 把数组全部元素写进 out(Go 侧预分配的 span 数组)。
// 返回元素数;超出 cap 时返回 -1(调用方据此判定「需要更大的缓冲」⇒ 回退)。
static int go_arr_all(const char *p, size_t n, ha_span *out, int cap) {
ha_json_scan sc;
int count = 0;
ha_json_scan_init(&sc, p, n);
(void)ha_json_scan_ws(&sc);
if (ha_json_scan_eof(&sc) || sc.s[sc.i] != '[') { return -1; }
sc.i++;
for (;;) {
(void)ha_json_scan_ws(&sc);
if (ha_json_scan_eof(&sc) || sc.s[sc.i] == ']') { break; }
if (count >= cap) { return -1; }
size_t start = sc.i;
if (!ha_json_skip(&sc)) { return -1; }
out[count].p = p + start;
out[count].len = sc.i - start;
count++;
(void)ha_json_scan_ws(&sc);
if (ha_json_scan_eof(&sc)) { return -1; }
if (sc.s[sc.i] == ',') { sc.i++; continue; }
if (sc.s[sc.i] == ']') { break; }
return -1;
}
return count;
}
static int go_sse_abi(void) { return ha_sse_abi_version(); }
*/
import "C"
import "unsafe"
// ---------------------------------------------------------------------
// span 表示
// ---------------------------------------------------------------------
// strSpan 是 JSON 里一段字节,指向**原缓冲**(零拷贝)。
type strSpan struct {
p *C.char
n C.size_t
}
func (s strSpan) valid() bool { return s.p != nil && s.n > 0 }
// bytes 把 span 变成 Go 字节切片(此处才产生一次拷贝)。
//
// ★ 用途:把「已定位的原始子树」交给 json.Unmarshal —— 用同一 Go 类型
// unmarshal 同一形状,是本层保证「类型检查语义与原实现一致」的手段。
func (s strSpan) bytes() []byte {
if s.p == nil || s.n == 0 {
return nil
}
return unsafe.Slice((*byte)(unsafe.Pointer(s.p)), int(s.n))
}
// str 把 span 变成 Go 字符串(此处才产生一次拷贝)。
func (s strSpan) str() string {
if s.p == nil || s.n == 0 {
return ""
}
return string(unsafe.Slice((*byte)(unsafe.Pointer(s.p)), int(s.n)))
}
// firstByte 只看首字节,用于区分值类型。
func (s strSpan) firstByte() byte {
if s.p == nil || s.n == 0 {
return 0
}
return *(*byte)(unsafe.Pointer(s.p))
}
// ---------------------------------------------------------------------
// 定位
// ---------------------------------------------------------------------
// findKey 在 obj 里按**大小写敏感**的键定位值。
// 返回 (span, found, dup, malformed)。
// dup=true ⇒ 发现重复键,调用方**必须**整体回退 encoding/json(§5.1)。
func findKey(obj strSpan, name string) (strSpan, bool, bool, bool) {
if !obj.valid() {
return strSpan{}, false, false, false
}
keyp, keyn := cstr(name)
var vp *C.char
var vlen C.size_t
var dup C.int
rc := C.go_obj_find(obj.p, obj.n, keyp, C.int(keyn), &vp, &vlen, &dup)
switch rc {
case 1:
return strSpan{vp, vlen}, true, dup == 1, false
case 0:
return strSpan{}, false, dup == 1, false
default:
return strSpan{}, false, false, true // 畸形 ⇒ 让 encoding/json 判
}
}
// firstElem 取数组第一个元素的 span。
func firstElem(arr strSpan) (strSpan, bool) {
if !arr.valid() {
return strSpan{}, false
}
var vp *C.char
var vlen C.size_t
if C.go_arr_first(arr.p, arr.n, &vp, &vlen) != 1 {
return strSpan{}, false
}
return strSpan{vp, vlen}, true
}
// ---------------------------------------------------------------------
// 取值(C 可判定的热分支)
// ---------------------------------------------------------------------
// decBuf 是解码/反转义用的可写缓冲。
//
// ★ 尺寸必须按输入长度定:C 侧要求 cap >= len*3+4(最坏每字节一个 U+FFFD),
// 不足时它会返回 0 让调用方回退 Go(宁可慢也不截断)。
// 每次调用 1 次分配(原来整块 Unmarshal 是 12~21 次)—— 这是主要的节省点。
func decBuf(n int) []byte { return make([]byte, n*3+8) }
// stringifyC 对应 Go stringifyContent 的**C 可判定分支**
// (字符串值 / 文本数组),返回 (结果, handled)。
// handled=false ⇒ 值类型需要 json.Marshal 重新编码(§5.2),调用方须回退 Go。
func stringifyC(val strSpan) (string, bool) {
if !val.valid() {
// 缺失 / 空 ⇒ Go 侧 stringifyContent(nil) 也是 ""
return "", true
}
buf := decBuf(int(val.n))
var outLen C.size_t
if C.go_stringify(val.p, val.n, cstrb(buf), C.size_t(len(buf)), &outLen) != 1 {
return "", false
}
return string(buf[:int(outLen)]), true
}
// argStringC 取出 arguments 的**字符串**形态(省掉 interface{} 与二次解析)。
func argStringC(val strSpan) (string, bool) {
if !val.valid() {
return "", false
}
buf := decBuf(int(val.n))
var outLen C.size_t
if C.go_arg_string(val.p, val.n, cstrb(buf), C.size_t(len(buf)), &outLen) != 1 {
return "", false
}
return string(buf[:int(outLen)]), true
}
// sseABIVersion 供 ABI 漂移测试使用。
func sseABIVersion() int { return int(C.go_sse_abi()) }
// ---------------------------------------------------------------------
// 顶层 helper:大小写不敏感(struct 字段语义)与根对象校验
// ---------------------------------------------------------------------
// C_size 把 Go int 转成 C.size_t(零拷贝 span 的长度)。
func C_size(n int) C.size_t { return C.size_t(n) }
// rootSpan 构造指向 data 的 span(零拷贝)。
func rootSpan(data string) strSpan { return strSpan{cstrp(data), C_size(len(data))} }
// findKeyCI 按**大小写不敏感**定位(Go struct 字段语义)。
func findKeyCI(obj strSpan, name string) (strSpan, bool, bool, bool) {
return findKeyGeneric(obj, name, true)
}
// findKeyCS 按**大小写敏感**定位(Go map key 语义)。
func findKeyCS(obj strSpan, name string) (strSpan, bool, bool, bool) {
return findKeyGeneric(obj, name, false)
}
func findKeyGeneric(obj strSpan, name string, ci bool) (strSpan, bool, bool, bool) {
if !obj.valid() {
return strSpan{}, false, false, false
}
keyp, keyn := cstr(name)
var vp *C.char
var vlen C.size_t
var dup C.int
var rc C.int
if ci {
rc = C.go_obj_find_ci(obj.p, obj.n, keyp, C.int(keyn), &vp, &vlen, &dup)
} else {
rc = C.go_obj_find(obj.p, obj.n, keyp, C.int(keyn), &vp, &vlen, &dup)
}
switch rc {
case 1:
return strSpan{vp, vlen}, true, dup == 1, false
case 0:
return strSpan{}, false, dup == 1, false
default:
return strSpan{}, false, false, true
}
}
// sseRootObject 校验「恰好一个良构对象」(含尾部残留检查)。
func sseRootObject(doc strSpan) bool {
if !doc.valid() {
return false
}
return C.go_root_object(doc.p, doc.n) == 1
}
// scanArray 枚举数组的全部元素 span(零拷贝,指向原缓冲)。
//
// ★ 为什么要「先数一遍再填」:C 侧迭代器一次回一个元素,而 Go 需要一个切片。
// 做法是让 C 一次把**所有元素**写进 Go 侧的 span 数组
// (Go 预分配、容量按字节数上界估),单趟、无 C 分配。
func scanArray(arr strSpan) ([]strSpan, bool) {
if !arr.valid() || arr.firstByte() != '[' {
return nil, false
}
// 元素数上界:每个元素至少 1 字节 + 分隔符 ⇒ ≤ 字节数
capHint := int(arr.n)
if capHint < 4 {
capHint = 4
}
if capHint > 1024 {
capHint = 1024 // 工具调用数量级很小;超出部分不可能(协议上界)
}
spans := make([]C.ha_span, capHint)
n := C.go_arr_all(arr.p, arr.n, &spans[0], C.int(capHint))
if n < 0 {
return nil, false
}
out := make([]strSpan, 0, int(n))
for i := 0; i < int(n); i++ {
out = append(out, strSpan{spans[i].p, spans[i].len})
}
return out, true
}