mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-01 23:12:52 +00:00
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:
@ -206,6 +206,154 @@ C 实现不是「重新设计」,是**逐值复刻 Go**。而 Go 的 `encoding
|
||||
内容**静默变成 `?0?d?d?0`**(那正是 SDK `ha_json.c` 的缺陷 1 的形态)。
|
||||
代价是约 10 行代码 + 一组已通过的测试。
|
||||
|
||||
## 五、接线前探明的六个**语义**(决定「C 化到什么程度」)
|
||||
|
||||
第二刀把库验完后,接线前又探了一轮 wire 语义。其中两条**直接推翻了
|
||||
「整条 parseOpenAICompatibleStreamChunkFull 全 C 化」的设想**。
|
||||
|
||||
### 5.1 重复键是**字段级合并**(`json.Unmarshal` 的数组语义)
|
||||
|
||||
```
|
||||
{"choices":[{"delta":{"content":"a"}}],"choices":[{"delta":{"reasoning_content":"r"}}]}
|
||||
→ content="a" reasoning="r" ← 两个都保留!
|
||||
{"choices":[{"delta":{"content":"a"}}],"choices":[{"delta":{}}]}
|
||||
→ content="a" ← 第二次是空 delta,也没把 content 清掉
|
||||
{"usage":{"prompt_tokens":1},"usage":{"completion_tokens":2}}
|
||||
→ usage={1,2,0} ← 字段级合并
|
||||
```
|
||||
|
||||
**机制**:`d.saveError(&d.array)` 保存目标;`object()` 收尾时执行
|
||||
`v.SetIndex(i, subv.v)`,而 subv 解析时拿到的是**已存在元素的指针**
|
||||
⇒ 第二次 unmarshal 是**叠加**在第一次之上的,不是替换。
|
||||
|
||||
⇒ 「第二个 element 整体覆盖第一个」是**错的**。正确实现需要维护
|
||||
**「本次哪些字段出现过」的** 逐字段表**。这能做,但要显式建模。
|
||||
|
||||
### 5.2 `stringifyContent` 的默认分支 = `json.Marshal(interface{})`(**再编码**)
|
||||
|
||||
这是最关键的一条。`content` 是 `interface{}`,落到 default 分支时
|
||||
**重新序列化一遍**:
|
||||
|
||||
| content 输入 | stringifyContent 输出 |
|
||||
|---|---|
|
||||
| `{"b":1,"a":2}` | `{"a":2,"b":1}`(**键排序**) |
|
||||
| `{"k":"<a>&b"}` | `{"k":"\u003ca\u003e\u0026b"}`(**HTML 转义**) |
|
||||
| `1e2` | `100`(float64 归一) |
|
||||
| `1.0` | `1` |
|
||||
| `123456789012345678` | `123456789012345680`(float64 舍入) |
|
||||
| `1e21` | `1e+21` |
|
||||
|
||||
要让 C 版与 Go 逐值一致,就必须复刻 Go 的:
|
||||
① 浮点**最短往返**格式化(`strconv.AppendFloat` 的 Ryu 语义,位数随值变化)
|
||||
② `map` **按键排序**(Go 的 map 无序 ⇒ 排序是 Marshal 的确定性来源)
|
||||
③ 字符串的 **HTML 转义 + `
/
` 转义**
|
||||
④ int → **float64 舍入**再格式化
|
||||
|
||||
这不是「顺手写一下」的量级,而是一整套序列化器 + 一个浮点格式化器。
|
||||
|
||||
### 5.3 结论:C 化**降级**为「结构导航」层,序列化留在 Go
|
||||
|
||||
本条不是为了少做事,而是因为上面两条决定了一个可检验的事实:
|
||||
|
||||
> **C 负责把 JSON 定位到「哪个值在哪里」(零分配、零解码);
|
||||
> Go 负责把「已定位的原始字节」变成 `interface{}`(`json.Unmarshal`),
|
||||
> 再按既有逻辑变成字符串。**
|
||||
|
||||
C 层因此**无需**理解重复键的合并语义(5.1)、**无需**实现浮点格式化
|
||||
与键排序(5.2)—— 它只回答「`choices[0].delta.content` 的 span 在哪」。
|
||||
|
||||
代价与收益(如实记录):
|
||||
|
||||
| | 收益 | 代价 |
|
||||
|---|---|---|
|
||||
| C 定位 | 免除 `json.Unmarshal` 的**反射建树**(每块 12~21 allocs 的主因) | 命中字段仍要一次小 `Unmarshal` |
|
||||
| 保留 Go 序列化 | 5.1/5.2 的语义**逐字**保持,不需要两套实现 | 值转换仍有少量 alloc |
|
||||
|
||||
**唯一例外(已实测可达)**:`arguments` 若 upstream 发的是**非字符串**
|
||||
对象/数组,Go 侧会 `json.Marshal` 重新编码(`{"b":2,"a":1}` → `{"a":1,"b":2}`),
|
||||
**重新编码的键序可能与原文不同**。这类值必须走 Go(见接线实现的注释)。
|
||||
|
||||
### 5.4 其余四条语义(接线时直接照做即可)
|
||||
|
||||
| # | 语义 | 实测 |
|
||||
|---|---|---|
|
||||
| 1 | **key 大小写敏感**(map key) | `{"TEXT":"up"}` 取不到 `text`;但 `{"CHOICES":[{"DELTA":{"CONTENT":"ci"}}]}` 有效(struct 字段名不敏感) |
|
||||
| 2 | `content` 数组:非对象元素**静默跳过** | `["a",{"text":"b"}]` → `"b"` |
|
||||
| 3 | `content` 数组:`text` 非字符串**静默跳过** | `[{"text":123},{"text":"b"}]` → `"b"` |
|
||||
| 4 | `index` 非整数 ⇒ **整块作废** | `{"index":1.5}` → false |
|
||||
| 5 | usage 的 cache 字段类型错也**让整块作废** | `{"prompt_cache_hit_tokens":"x","prompt_tokens":1}` → false |
|
||||
|
||||
第 1 条与 ha_json_scan 的 `ha_json_key_eq`(大小写不敏感)**语义相反**,
|
||||
两者用途不同、互不冲突(见 §5.3)—— 但必须在代码里注明,否则后人会「统一」掉。
|
||||
|
||||
## 六、接线实测:**本架构比原实现慢**(诚实记录,已默认关闭)
|
||||
|
||||
第三刀把 `ha_json_scan` 接进了生产路径(`chunkParseFast`),
|
||||
**6 万+ 差分用例证明它与原实现逐值等价**(含语法、成员、解码、整数、
|
||||
随机 JSON、随机字节五组)。但基准给出了**否定结论**,故**默认关闭**。
|
||||
|
||||
### 6.1 实测对比(`codec_chunkfast_bench_test.go`,20000 次迭代)
|
||||
|
||||
| 场景 | 新路径(Entry) | 原实现(GoOnly) | 结论 |
|
||||
|---|---:|---:|---|
|
||||
| content_zh | 2245 ns / 20 allocs | 2038 ns / 13 allocs | 更慢 |
|
||||
| content_ascii | **2016 ns / 20 allocs** | **1325 ns / 13 allocs** | 慢 52% |
|
||||
| toolcall | **5854 ns / 33 allocs** | **3270 ns / 21 allocs** | 慢 79% |
|
||||
| usage | 3170 ns / 24 allocs | 2832 ns / 12 allocs | 更慢 |
|
||||
| finish | 1560 ns / 20 allocs | 1098 ns / 12 allocs | 更慢 |
|
||||
|
||||
分配数**也变多**(20 vs 13)—— 与本刀「消除 GC 抖动」的初衷相反。
|
||||
|
||||
### 6.2 根因(逐项测出来的,不是猜的)
|
||||
|
||||
| 测量 | 数值 | 含义 |
|
||||
|---|---:|---|
|
||||
| 裸 cgo 调用(无 out-param) | **168 ns** | 一次性边界成本 |
|
||||
| 带 out-param 的键查找 | **205 ns / 2 allocs** | 边界 + out-param 逃逸到堆 |
|
||||
| 一次解析需要的键查找次数 | **5+** | choices→[0]→delta→content/reasoning/tool_calls→finish_reason |
|
||||
|
||||
⇒ **5 × 205ns ≈ 1µs 的边界与分配成本,恰好把收益全部吃掉。**
|
||||
而 Go 侧是**一次** `json.Unmarshal` 遍历建整棵树。
|
||||
|
||||
**根因一句话**:本架构是「用很多次廉价调用,换一次昂贵调用」——
|
||||
在这个尺寸上不划算。逐字段往返是设计错误,不是实现调优能救的。
|
||||
|
||||
### 6.3 天花板实验:方向对,但当前实现没到
|
||||
|
||||
为判断「还值不值得改」,我测了一个假设性上界 —— **假设拿到 span 完全免费**
|
||||
(span 预先算好),只测本设计中**必须由 Go 做**的那部分:
|
||||
|
||||
| | ns/op | allocs |
|
||||
|---|---:|---:|
|
||||
| 我设计里的 Go 侧工作(零边界成本) | **505** | **7** |
|
||||
| 原实现(整块 json.Unmarshal) | 1239 | 13 |
|
||||
|
||||
⇒ 若边界成本能压到近零,**仍有 2.4× 时间与 46% 分配的空间**。
|
||||
故这不是「C 化没意义」,而是「**逐字段往返**这个交互方式是错的」。
|
||||
|
||||
### 6.4 正确的下一步(已由实测指明)
|
||||
|
||||
改造方向不是调优现有代码,而是**减少跨界次数**:
|
||||
|
||||
1. **一次 C 调用返回全部字段的 span**(批量),而不是逐字段往返
|
||||
—— 把 5+ 次边界压成 1 次
|
||||
2. **结果写入调用方栈上的 C 结构体**,消除 out-param 逃逸(那 2 allocs)
|
||||
3. 仅在 content/usage **确需重新编码**时回退 Go
|
||||
|
||||
### 6.5 为什么把「一个没有启用的优化」连代码一起提交
|
||||
|
||||
- **正确性基准**:6 万+ 差分用例已把 C 与 Go 的逐值等价钉死,
|
||||
这是改造的**已验证起点**(field-locating 与全部回退判据都验证正确了)
|
||||
- **一条永不静默回退的机制**:`TestChunkFast_BenchGate` 断言
|
||||
`chunkFastEnabled` 必须为 `false`。后来者看到「快速路径写得挺全 +
|
||||
差分测试全过」,很自然会以为它已生效并打开它 —— 而实测它更慢。
|
||||
断言把这个事实钉住,改动即判红。
|
||||
- **诚实**:不把「写了但没效果」包装成「已完成」。
|
||||
|
||||
> 教训(与第一刀同源):**「C 比 Go 快」不是前提,是待验证的假设。**
|
||||
> 第一刀推翻过一次(`C.CString` 造成 82% 自找开销),这一刀又推翻一次
|
||||
> (逐字段往返造成 5+ 次边界)。两次都是**测量**推翻了直觉。
|
||||
|
||||
### 已知边界(诚实记录)
|
||||
|
||||
- `ha_json_get_int` 返回 `long long`;Go 侧 usage 字段是 `int`(64 位平台相同,
|
||||
|
||||
Reference in New Issue
Block a user