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% 自找开销推翻一次,这一刀被逐字段往返推翻一次。
两次都是测量推翻直觉。
83 lines
4.2 KiB
Go
83 lines
4.2 KiB
Go
//go:build cgo
|
||
|
||
package api
|
||
|
||
// codec_chunkfast_bench_test.go —— 快速路径 vs 原实现的真实开销对比。
|
||
//
|
||
// 判据不是「C 比 Go 快」,而是「在真实输入分布下是否真的省下分配与时间」。
|
||
// 分配数是重点:C 化的原始动机就是消除每 chunk 12~21 次堆分配带来的 GC 抖动。
|
||
|
||
import (
|
||
"testing"
|
||
)
|
||
|
||
var benchChunks = map[string]string{
|
||
"content_zh": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":"这是一段来自真实流式响应的中文内容,用于测量解析开销。"},"finish_reason":null}]}`,
|
||
"content_ascii": `{"id":"chatcmpl-abc","choices":[{"index":0,"delta":{"content":"hello world this is a longer ascii content chunk"},"finish_reason":null}]}`,
|
||
"toolcall": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_9a","type":"function","function":{"name":"memory_recall","arguments":"{\"query\":\"用户偏好\",\"limit\":20}"}}]},"finish_reason":null}]}`,
|
||
"usage": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}],"usage":{"prompt_tokens":3821,"completion_tokens":117,"total_tokens":3938,"prompt_cache_hit_tokens":3584,"prompt_cache_miss_tokens":237}}`,
|
||
"finish": `{"id":"c","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
|
||
}
|
||
|
||
// BenchmarkChunkFast_Entry 走真实入口(含 C 路径 + 必要的回退)。
|
||
func BenchmarkChunkFast_Entry(b *testing.B) {
|
||
for name, in := range benchChunks {
|
||
b.Run(name, func(b *testing.B) {
|
||
b.SetBytes(int64(len(in)))
|
||
b.ReportAllocs()
|
||
for i := 0; i < b.N; i++ {
|
||
_, _ = parseOpenAICompatibleStreamChunkFull(in)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// BenchmarkChunkFast_GoOnly 直接调原实现(整块 json.Unmarshal),作对照。
|
||
func BenchmarkChunkFast_GoOnly(b *testing.B) {
|
||
for name, in := range benchChunks {
|
||
b.Run(name, func(b *testing.B) {
|
||
b.SetBytes(int64(len(in)))
|
||
b.ReportAllocs()
|
||
for i := 0; i < b.N; i++ {
|
||
_, _ = parseOpenAICompatibleStreamChunkFullGo(in)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// 基准门禁:防止「优化」悄悄退步,或在没实测过收益时被打开
|
||
// ---------------------------------------------------------------------
|
||
|
||
// TestChunkFast_BenchGate 钉死当前事实:chunkFastEnabled 必须为 false。
|
||
//
|
||
// ★ 为什么把「一个优化是关的」也做成断言:
|
||
// 「还没验证有效就先关着」是**容易丢失的状态** —— 后来者看到
|
||
// 「快速路径写得挺全 + 6 万条差分测试全过」,很自然会以为它已生效,
|
||
// 进而打开它、甚至删掉开关。而实测它**更慢**。
|
||
// 断言把这个事实钉在测试里,开关一旦被改就立刻判红。
|
||
func TestChunkFast_BenchGate(t *testing.T) {
|
||
if chunkFastEnabled {
|
||
t.Fatalf("chunkFastEnabled 被打开了,但实测本架构比原实现慢:\n" +
|
||
" content_ascii Entry 2016ns/20allocs vs GoOnly 1325ns/13allocs\n" +
|
||
" toolcall Entry 5854ns/33allocs vs GoOnly 3270ns/21allocs\n" +
|
||
" 根因:5+ 次 cgo 边界 × 每次约 200ns(out-param 逃逸到堆)。\n" +
|
||
" 改造方向(已由天花板实验确认可行):一次 C 调用返回全部字段 span、\n" +
|
||
" 结果写入调用方栈上的 C 结构体。先改架构,再打开此开关。\n" +
|
||
" 改之前请先跑 BenchmarkChunkFast_* 拿到自己的数据。")
|
||
}
|
||
}
|
||
|
||
// TestChunkFast_CGoBoundaryCost 记录「每次带 out-param 的 cgo 调用 ≈ 2 allocs」
|
||
// 这条经济事实。它是判断任何后续改造是否值得的标尺。
|
||
func TestChunkFast_CGoBoundaryCost(t *testing.T) {
|
||
// 断言存在(防止有人「顺手优化」掉这两个 helper 里的关键细节)
|
||
doc := `{"a":1,"b":{"c":"x"}}`
|
||
if _, found, _, bad := findKeyCI(rootSpan(doc), "b"); !found || bad {
|
||
t.Fatalf("findKeyCI 失效: found=%v bad=%v", found, bad)
|
||
}
|
||
if _, found, _, bad := findKeyCS(rootSpan(doc), "a"); bad || !found {
|
||
t.Fatalf("findKeyCS 失效: found=%v bad=%v", found, bad)
|
||
}
|
||
}
|