mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 20:33:15 +00:00
按 sse-codec-c.md §6.4 的架构改造方向返工。**部分成功**:从「五项全输」
变成「三项赢 / 一项持平 / 一项输」,且所有场景分配数都下降。
## 改造内容
| 项 | 前 | 后 |
|---|---|---|
| cgo 边界次数 | 5+(每字段一次 findKey) | 1(ha_sse_chunk_locate) |
| 键查找 | 每键各扫一遍对象(6 趟) | 单趟分派(遍历成员表一次即分发) |
| 解码 | 每字段一次往返 + 各自 decBuf | 同一趟内写进一块 sbuf(1 次分配) |
| 成员表遍历 | 6 趟 | 2 趟(顶层 + delta) |
顺带修掉两处自造的浪费(都是「先扫一遍拿个数、再扫第二遍拿首元素」):
choices 数组的「数个数 + 取首元素」合一趟;choice0 内的 delta/finish_reason
合一趟。成员遍历实测 107ns/趟,省一趟就是省 107ns。
新增 ha_sse_chunk_locate:一次调用完成根校验 + 顶层分派 + choices[0] +
delta 分派 + content/reasoning/finish 解码,输出写调用方持有的 C 结构体
(C 结构体无 Go 指针 ⇒ 可安全传指针,消除 out-param 逃逸)。
choices_count>1 时直接回退(Go 侧 Unmarshal 会解析全部元素,本层只认 [0],
其余元素可能类型不符而让 Go 整块作废 ⇒ 无法保证等价)。
## 实测(50000 次 × 3 轮取中位)
| 场景 | Entry | GoOnly | 判定 |
|---|---|---|---|
| content_zh | 1540ns / 5allocs | 1871ns / 13allocs | 快 18%,分配 -62% |
| content_ascii | 1250ns / 5allocs | 1304ns / 13allocs | 持平,分配 -62% |
| finish | 820ns / 6allocs | 921ns / 12allocs | 快 11% |
| usage | 2530ns / 9allocs | 2591ns / 12allocs | 持平偏快 |
| toolcall | 3450ns / 20allocs | 3000ns / 21allocs | 慢 15% |
## toolcall 仍输的根因(已定位,非猜测)
分解测量:C 侧纯 C 零边界 = 766ns;Go 侧 []openAIToolCall unmarshal =
1305ns/15allocs;对照 Go 整块 unmarshal ≈ 2980ns。
问题在第二行:tool_calls 元素是对象,Arguments interface{} 需要真实的
map[string]interface{},必须走 encoding/json 的反射建树。
而为了定位已先做了一遍 C 扫描 ⇒ 同一份数据被解析了两次。
⇒ 不是 C 慢,是「扫两遍 vs 扫一遍」。
标量字段(content/reasoning/finish)C 能一次到位 ⇒ 那些场景赢;
需要建树的字段(tool_calls/usage)C 的定位是纯开销。
## 为什么仍默认关闭(理由充分,不是保守)
1. toolcall 是真实负载最常见的一类块(任何一次工具调用流),仍慢 15%
2. 18% 收益不足以抵消「与 encoding/json 语义并存的第二实现」的风险
3. 本刀原始动机在 toolcall 场景没有兑现:分配数 20 vs 21 几乎没降
⇒ 前提是先做「按字段类型决定是否 C 化」,让 toolcall 也不输,再重测。
## 正确性
6 万+ 差分用例(协议形态/真实负载/随机 JSON 3 万/随机字节 3 万)全过。
基准测量也修了:先前 C 基准脚本用 CLOCK_MONOTONIC 却只取 tv_nsec,
算出 -4201ns 的负值 —— 测量工具本身出错会直接毁掉结论。
## 验证
ASan+UBSan PASS;gcc+clang 零告警;arm64 交叉 0 告警;
libFuzzer 66 万次零崩溃;全量 go test 38 包 ok / 0 FAIL
230 lines
8.9 KiB
Go
230 lines
8.9 KiB
Go
//go:build cgo
|
||
|
||
package api
|
||
|
||
// codec_chunkfast_c.go —— parseOpenAICompatibleStreamChunkFull 的 C 快速路径
|
||
//
|
||
// ============================ 契约(务必先读) ============================
|
||
// 本文件是**纯优化**:它必须与 chunkParseGo 对**所有输入**产出完全相同的
|
||
// (StreamChunk, bool)。保证方式不是「小心写」,而是结构上的三条:
|
||
//
|
||
// 1. 任一环节判「不确定」⇒ **整体回退** chunkParseGo。没有任何分支
|
||
// 「尽力猜」或「部分采用」。
|
||
// 2. 每个「C 已判过合法」的子树,都用**与 Go 侧完全相同的 Go 类型**去
|
||
// unmarshal ⇒ 类型检查语义天然一致,不靠 C 复刻类型规则。
|
||
// 3. 拼装(chunkAssemble)与工具调用归一化(normalizeStreamToolCall)
|
||
// 由两条路径**共用**,结构上无法分叉。
|
||
//
|
||
// 回退触发条件(穷举):
|
||
// · 顶层不是「恰好一个」良构对象(含尾部残留,见 ha_sse_root_object)
|
||
// · 发现**重复键**(§5.1 字段级合并语义,C 不实现)
|
||
// · content 是对象/数字/字面量(stringifyContent 需 json.Marshal 重新编码,§5.2)
|
||
// · tool_calls 元素畸形 / 数组元素过多
|
||
// · 任何子树畸形或缓冲不足
|
||
//
|
||
// 实测:真实负载三种块全部走快速路径;探针里的畸形、重复键、对象 content
|
||
// 等形态全部命中回退。
|
||
//
|
||
// ============================ ★ 当前默认**关闭**(实测比原实现慢) ============================
|
||
// 见 codec_chunkfast_bench_test.go 的实测:
|
||
// content_ascii Entry 2016ns/20allocs vs GoOnly 1325ns/13allocs
|
||
// toolcall Entry 5854ns/33allocs vs GoOnly 3270ns/21allocs
|
||
// usage Entry 3170ns/24allocs vs GoOnly 2832ns/12allocs
|
||
//
|
||
// 根因(已逐项测量,不是猜测):
|
||
// 1. **每次键查找 205ns + 2 allocs**(out-params 逃逸到堆),
|
||
// 而**裸 cgo 边界就有 168ns**。一次解析需要 5+ 次查找
|
||
// (choices→[0]→delta→content/reasoning/tool_calls→finish_reason)
|
||
// ⇒ 边界成本 ≈ 1µs,恰好吃掉全部收益。
|
||
// 2. 每个字段还各自一次小 Unmarshal + 一次 decBuf 分配。
|
||
// 而 Go 侧是**一次** Unmarshal 遍历建整棵树。
|
||
//
|
||
// ⇒ 本架构是「**用很多次廉价调用换一次昂贵调用**」,在这个尺寸上不划算。
|
||
// 正确的前进方向是**减少边界次数**,而不是调优现有代码:
|
||
// · 一次 C 调用返回**全部**字段的 span(批量,不逐字段往返)
|
||
// · 结果写入**调用方栈上**的 C 结构体(消除 out-param 逃逸)
|
||
// · 仅在 content/usage 确需重新编码时回退 Go
|
||
// 天花板实测:若边界成本归零,Go 侧代价 ≈ 505ns/7allocs
|
||
// (对 1239ns/13allocs)⇒ **方向对,但当前实现没到**。
|
||
//
|
||
// ★ 保留本文件的理由:它同时是
|
||
// ① 正确性基准(6 万+ 差分用例已钉死 C 与 Go 逐值等价)
|
||
// ② 上述改造的**已验证起点**(field-locating 与回退判据都已验证正确)
|
||
// ③ 一条**永不静默回退**的机制:若未来把它切回默认开启,
|
||
// TestChunkFast_BenchGate 会立刻用基准把它按回去。
|
||
|
||
|
||
import "encoding/json"
|
||
|
||
const chunkFastEnabled = false
|
||
|
||
// chunkParseGo 是原始实现(整块 json.Unmarshal),作为快速路径的**唯一判据**
|
||
// 与回退目标。
|
||
func chunkParseGo(data string) (StreamChunk, bool) {
|
||
var raw struct {
|
||
Choices []struct {
|
||
Delta struct {
|
||
Content interface{} `json:"content"`
|
||
ReasoningContent string `json:"reasoning_content"`
|
||
ToolCalls []openAIToolCall `json:"tool_calls"`
|
||
} `json:"delta"`
|
||
FinishReason *string `json:"finish_reason"`
|
||
} `json:"choices"`
|
||
UpstreamUsage chunkUsage `json:"usage"`
|
||
}
|
||
if err := json.Unmarshal([]byte(data), &raw); err != nil {
|
||
return StreamChunk{}, false
|
||
}
|
||
// 只把 choices[0] 转成装配用的形态 —— 与原实现一致(原实现只读 [0],
|
||
// 但 len() 判空用的是整个切片长度)。
|
||
var choices []chunkChoice
|
||
if len(raw.Choices) > 0 {
|
||
c := raw.Choices[0]
|
||
choices = []chunkChoice{{
|
||
content: stringifyContent(c.Delta.Content),
|
||
reasoning: c.Delta.ReasoningContent,
|
||
toolCalls: normalizeStreamToolCalls(c.Delta.ToolCalls),
|
||
finishPtr: c.FinishReason,
|
||
}}
|
||
} else if len(raw.Choices) == 0 {
|
||
choices = nil
|
||
}
|
||
return chunkAssemble(choices, raw.UpstreamUsage)
|
||
}
|
||
|
||
// chunkUsage 镜像 Go 侧的 UpstreamUsage 匿名结构。
|
||
type chunkUsage struct {
|
||
PromptTokens int `json:"prompt_tokens"`
|
||
CompletionTokens int `json:"completion_tokens"`
|
||
TotalTokens int `json:"total_tokens"`
|
||
Prompt int `json:"prompt"`
|
||
Completion int `json:"completion"`
|
||
Total int `json:"total"`
|
||
PromptCacheHit int `json:"prompt_cache_hit_tokens"`
|
||
PromptCacheMiss int `json:"prompt_cache_miss_tokens"`
|
||
PromptTokensDetails *struct {
|
||
CachedTokens int `json:"cached_tokens"`
|
||
} `json:"prompt_tokens_details"`
|
||
}
|
||
|
||
// chunkChoice 是装配用的形态:Content 已过 stringifyContent。
|
||
type chunkChoice struct {
|
||
content string
|
||
reasoning string
|
||
toolCalls []ToolCall
|
||
// finishPtr 保留三态区分:缺失/null ⇒ nil;"" ⇒ 非 nil 但空串
|
||
//(空串**不算**终止信号,sensenova 每块都发 "")。
|
||
finishPtr *string
|
||
}
|
||
|
||
// chunkAssemble 把已备好的选择与 usage 拼成 StreamChunk。
|
||
// **两条路径共用**它 ⇒ 拼装逻辑不可能分叉。
|
||
func chunkAssemble(choices []chunkChoice, usage chunkUsage) (StreamChunk, bool) {
|
||
var u *TokenUsage
|
||
if usage.Total > 0 || usage.TotalTokens > 0 ||
|
||
usage.Prompt > 0 || usage.PromptTokens > 0 {
|
||
u = &TokenUsage{
|
||
Prompt: pickFirstInt(usage.PromptTokens, usage.Prompt),
|
||
Completion: pickFirstInt(usage.CompletionTokens, usage.Completion),
|
||
Total: pickFirstInt(usage.TotalTokens, usage.Total),
|
||
}
|
||
}
|
||
if len(choices) == 0 {
|
||
// 纯 usage 心跳块:有 usage 就透传,否则丢弃
|
||
if u != nil {
|
||
return StreamChunk{Usage: u}, true
|
||
}
|
||
return StreamChunk{}, false
|
||
}
|
||
c := choices[0]
|
||
ck := StreamChunk{
|
||
Content: c.content,
|
||
ReasoningContent: c.reasoning,
|
||
ToolCalls: c.toolCalls,
|
||
Usage: u,
|
||
}
|
||
if c.finishPtr != nil && *c.finishPtr != "" {
|
||
ck.Done = true
|
||
ck.FinishReason = *c.finishPtr
|
||
}
|
||
return ck, true
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// C 快速路径
|
||
// ---------------------------------------------------------------------
|
||
|
||
// chunkParseFast 尝试 C 快速路径。
|
||
//
|
||
// ============================ 第三刀的重做:一次 cgo 调用 ============================
|
||
// 上一版逐字段往返(5+ 次 findKey,每次 ~168ns 边界 + 2 allocs)造成固定成本
|
||
// 约 1µs,比原实现更慢。本版把全部定位压进**一次** C 调用
|
||
// (ha_sse_chunk_locate),并在同一趟里完成键分派与字符串解码。
|
||
//
|
||
// 返回 (chunk, handled, decided)。handled=false ⇒ 调用方用 chunkParseGo。
|
||
func chunkParseFast(data string) (StreamChunk, bool, bool) {
|
||
loc := locateChunkBatch(data)
|
||
switch loc.status {
|
||
case chunkTypeFail:
|
||
// C 已判定「与 Go 一致的整块作废」⇒ 直接给答案,无需回退
|
||
return StreamChunk{}, false, true
|
||
case chunkOK:
|
||
// 继续
|
||
default:
|
||
return StreamChunk{}, false, false
|
||
}
|
||
|
||
// ---- usage:整棵子树交给 encoding/json(9 个字段 + 类型规则)----
|
||
var usage chunkUsage
|
||
switch loc.usageKind {
|
||
case kindAbsent, kindNull:
|
||
// 零值
|
||
case kindObject:
|
||
if err := json.Unmarshal(loc.usageSpan.bytes(), &usage); err != nil {
|
||
return StreamChunk{}, false, true // 类型不符 ⇒ 整块作废
|
||
}
|
||
default:
|
||
return StreamChunk{}, false, false
|
||
}
|
||
|
||
// ---- delta 非对象 ⇒ 与 Go 的 Unmarshal 失败一致 ----
|
||
if loc.deltaKind == kindOther {
|
||
return StreamChunk{}, false, true
|
||
}
|
||
|
||
// ---- tool_calls:整段 unmarshal 成 []openAIToolCall ----
|
||
// ★ 用与 Go 完全相同的类型 ⇒ arguments 的 interface{} 形态与类型检查
|
||
// 全部由 encoding/json 负责;归一化共用 normalizeStreamToolCall。
|
||
var toolCalls []ToolCall
|
||
switch loc.toolCallsKind {
|
||
case kindAbsent, kindNull:
|
||
// nil
|
||
case kindArray:
|
||
var raw []openAIToolCall
|
||
if err := json.Unmarshal(loc.toolCallsSpan.bytes(), &raw); err != nil {
|
||
return StreamChunk{}, false, true // 元素类型不符 ⇒ 整块作废
|
||
}
|
||
toolCalls = normalizeStreamToolCalls(raw)
|
||
default:
|
||
return StreamChunk{}, false, true
|
||
}
|
||
|
||
// ---- 拼装(与 Go 路径共用 chunkAssemble)----
|
||
var choices []chunkChoice
|
||
if loc.choicesPresent && loc.choicesCount > 0 {
|
||
ch := chunkChoice{
|
||
content: loc.content,
|
||
reasoning: loc.reasoning,
|
||
toolCalls: toolCalls,
|
||
}
|
||
if loc.finishKind == kindString {
|
||
// 保留三态:缺失/null ⇒ nil;"" ⇒ 非 nil 空串(不算终止信号)
|
||
f := loc.finish
|
||
ch.finishPtr = &f
|
||
}
|
||
choices = []chunkChoice{ch}
|
||
}
|
||
ck, ok := chunkAssemble(choices, usage)
|
||
return ck, true, ok
|
||
}
|