mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 20:33:15 +00:00
第三刀:把 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% 自找开销推翻一次,这一刀被逐字段往返推翻一次。
两次都是测量推翻直觉。
329 lines
12 KiB
Go
329 lines
12 KiB
Go
//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
|
||
}
|