Files
HomeAgent/csrc/include/ha_sse.h
JianFeeeee 7a1322c97f perf(api): SSE 导航层返工 —— 5+ 次边界压成 1 次(三项赢,仍默认关闭)
按 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
2026-09-26 10:41:52 +08:00

219 lines
10 KiB
C
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.

#ifndef HA_SSE_H
#define HA_SSE_H
/*
* ha_sse — LLM 流式协议(SSE 分块)的「结构导航」辅助层
*
* ============================ 定位 ============================
* 本层不是 JSON 库(那是 ha_json_scan),而是把 ha_json_scan 原语组合成
* **协议层需要的几次定位**,供内核 parseOpenAICompatibleStreamChunkFull 使用。
*
* ★ 为什么这些函数放在 csrc/ 而不是内联在 Go 的 cgo 前言里:
* 放在 cgo 前言里的 C 代码**逃出了全部 C 门禁**(告警 / ASan+UBSan /
* 交叉编译 / 模糊测试),而它恰恰是本刀最容易出错的位置。
* 移进 csrc/ 后,同一个 -Wall -Wextra -Wpedantic -Wconversion 门禁
* 与 sanitizer 都覆盖到它 —— 这是一次真实的结构调整,不是形式主义。
*
* ============================ 为什么只做「导航」 ============================
* 实测两条 wire 语义(docs/zh/c-core/sse-codec-c.md §5)使「全量 C 化」不成立:
* §5.1 重复键是**字段级合并**(json.Unmarshal 的 SetIndex 叠加语义)
* §5.2 stringifyContent 的 default 分支是 json.Marshal(键排序 / 浮点
* 最短往返 / HTML 转义 / int 舍入)
* 二者都只在**取值**阶段需要,故本层只回答「值在哪里、它的热分支结果是什么」,
* 需要重新序列化的形态交回 Go(由 encoding/json 保证语义)。
*
* ============================ 键匹配:大小写敏感 ============================
* 本层是 **map key** 语义(`m["text"]`)⇒ 大小写敏感。
* 实测 `{"TEXT":"up"}` 取不到 `text`、`{"text":"low"}` 可以(§5.4-1)。
*
* ⚠️ 与 ha_json_key_eq(大小写**不**敏感,用于 struct 字段名)语义相反。
* 两者用途不同、都必要,**不要「统一」掉**。
* struct 字段那一跳由 encoding/json 负责,天然正确。
*/
#include <stddef.h>
#include "ha_abi.h"
#include "ha_json_scan.h"
#ifdef __cplusplus
extern "C" {
#endif
#define HA_SSE_ABI_MAJOR 1
#define HA_SSE_ABI_MINOR 1
#define HA_SSE_ABI_VERSION (HA_SSE_ABI_MAJOR * 1000 + HA_SSE_ABI_MINOR)
HA_STATIC_ASSERT(HA_SSE_ABI_MAJOR >= 1 && HA_SSE_ABI_MAJOR <= 9,
ha_sse_abi_major_in_range);
HA_STATIC_ASSERT(HA_SSE_ABI_MINOR >= 0 && HA_SSE_ABI_MINOR <= 99,
ha_sse_abi_minor_in_range);
int ha_sse_abi_version(void);
/*
* 在对象里按**大小写敏感**的键定位值。
*
* 返回: 1 = 找到(*out 已写);0 = 未找到(对象良构);-1 = 对象畸形。
* *dup 在发现**重复键**时置 1(后者胜已写入 *out)——
* 调用方据此整体回退到 encoding/json,因为重复键的字段级合并语义
* 见 sse-codec-c.md §5.1,本层不实现。
*/
int ha_sse_obj_find(const ha_span *obj, const char *key, size_t keylen,
ha_span *out, int *dup);
/*
* 在对象里按**大小写不敏感**的键定位值(struct 字段语义)。
*
* ★ 为什么必须与 ha_sse_obj_find 并存(两个函数,语义相反):
* - Go 的 `raw struct{ Choices ... \`json:"choices"\` }` 是 **struct 字段**,
* encoding/json 对字段名做**大小写不敏感**匹配 ⇒ 实测
* `{"CHOICES":[{"DELTA":{"CONTENT":"ci"}}]}` 能取到 content="ci"。
* - 而 `content` 是 `interface{}` → `map[string]interface{}`,取 `m["text"]`
* 是 **map key** 语义 ⇒ 大小写**敏感**(实测 `{"TEXT":"up"}` 取不到)。
* 跳错层就会静默漏掉字段(或取到不该取的),故两个函数都必要,
* 调用方必须按「这一跳在 Go 里是 struct 还是 map」来选择。
*
* 返回与 ha_sse_obj_find 相同:1=找到 0=未找到 -1=畸形。
* 对 *dup:大小写不敏感语义下,`{"CHOICES":..,"choices":..}` 两次都会命中
* 同一个 Go 字段(后者胜),故同样置 dup 让调用方回退。
*/
int ha_sse_obj_find_ci(const ha_span *obj, const char *key, size_t keylen,
ha_span *out, int *dup);
/*
* 校验 doc 是「**恰好一个**良构 JSON 对象」(尾部只允许空白)。
*
* 返回 1 = 是;0 = 否。
*
* ★ 为什么必须单独校验尾部:ha_sse_obj_find 用 members_complete 只保证
* 对象本身闭合,**不检查尾部残留** —— 而 Go 的 json.Unmarshal 会拒绝
* `{"a":1}{"b":2}`(trailing garbage)。少了这一步,快速路径会比 Go 宽松,
* 把一个 Go 判为失败的块判为成功 ⇒ 静默接受垃圾块。
*/
int ha_sse_root_object(const ha_span *doc);
/*
* 取数组**第一个元素**的 span。
*
* 返回: 1 = 有元素;0 = 空数组;-1 = 非数组或畸形。
*
* 为什么只要第一个:`choices[0]` 是协议约定(Go 侧也只读 resp.Choices[0]),
* 本层据此避免为后续元素做无用功。
*/
int ha_sse_arr_first(const ha_span *arr, ha_span *out);
/*
* stringifyContent 的 **C 可判定分支**:
* - 字符串值 → 反转义后原样输出
* - 数组值 → 逐元素取对象的 "text" 字段(精确键)拼接
*
* 返回: 1 = 已写入(*outlen 为字节数);0 = 需回退 Go。
* 回退的两种情形:
* a) 缓冲不足(调用方应给 >= val->len*3+4 的 cap)
* b) 值类型是对象 / 数字 / 字面量 —— 那些要走 json.Marshal(§5.2)
*
* 数组元素的规则(§5.4-2/3 实测):
* · 非对象元素 **静默跳过**(`["a",{"text":"b"}]` → "b")
* · 非对象的 "text"(如 text:123)**静默跳过**
* · 元素里出现重复的 "text" 键 ⇒ 整体回退 Go(合并语义)
*/
int ha_sse_stringify(const ha_span *val, char *out, size_t cap, size_t *outlen);
/*
* arguments 为**字符串**时,取出其解码结果(省掉 interface{} 与二次解析)。
*
* 返回 1 = 已写入;0 = 不是字符串或失败(调用方按既有路径处理)。
* 非字符串 arguments(对象/数组/数字)**必须**回退 Go:那里的
* `rawArgsString` 会 `json.Marshal` 重新编码,而这个**重新编码的键序
* 可能与原文不同**(实测 {"b":2,"a":1} → {"a":1,"b":2})——
* 逐值一致要求由 encoding/json 来做。
*/
int ha_sse_arg_string(const ha_span *val, char *out, size_t cap, size_t *outlen);
/* ==================================================================== */
/* 批量定位:**一次调用**返回整块解析所需的全部字段 */
/* ==================================================================== */
/*
* ★ 为什么需要它(第三刀实测的教训,见 sse-codec-c.md §六):
* 逐字段往返做 5+ 次 cgo 调用,每次约 168ns 边界 + 2 allocs(out-param
* 逃逸到堆)⇒ 约 1µs 固定成本,把全部收益吃光,结果比原实现更慢。
*
* 本接口把它压成 **1 次调用**,并顺带解决另外两点:
* · **单趟键分派**:不再「每个键各扫一遍对象」,而是遍历一次成员表
* 就分派(原来 6 次扫描 → 2 次)
* · **解码内联**:content / reasoning_content 的解码在同一趟里写进
* 调用方缓冲,不再各来一次往返
*
* 结果写在调用方的 ha_chunk_out 里(C 结构体、无 Go 指针 ⇒ 可安全传指针)。
*/
/* 槽位索引(固定约定,**改动必须 bump ABI**)。 */
#define HA_CHUNK_SLOT_DELTA 0
#define HA_CHUNK_SLOT_CONTENT 1
#define HA_CHUNK_SLOT_REASONING 2
#define HA_CHUNK_SLOT_TOOL_CALLS 3
#define HA_CHUNK_SLOT_FINISH_REASON 4
#define HA_CHUNK_SLOT_USAGE 5
#define HA_CHUNK_SLOT_COUNT 6
/* 槽位类型。与 Go 侧「该字段是什么 Go 类型」对应,而非单纯 JSON 类型。 */
#define HA_CHUNK_KIND_ABSENT 0
#define HA_CHUNK_KIND_NULL 1
#define HA_CHUNK_KIND_STRING 2
#define HA_CHUNK_KIND_OBJECT 3
#define HA_CHUNK_KIND_ARRAY 4
#define HA_CHUNK_KIND_OTHER 5 /* 数字 / 布尔 */
/* ha_sse_chunk_locate 返回码。 */
#define HA_CHUNK_OK 0 /* 定位成功,可用快速路径 */
#define HA_CHUNK_FALLBACK -1 /* 需回退 Go:重复键 / 畸形 / 顶层非对象 /
* 多 choices / 缓冲不足 */
#define HA_CHUNK_TYPE_FAIL -2 /* 与 Go 一致的「整块作废」(类型不符) */
typedef struct {
ha_span span; /* 原始值 span(未解码,指向 data) */
int kind; /* HA_CHUNK_KIND_* */
} ha_chunk_slot;
typedef struct {
ha_chunk_slot slot[HA_CHUNK_SLOT_COUNT];
/* choices 数组本身的 span(choices_count>0 时有效) */
ha_span choices_span;
/* choices[0] 的 span(choices_count==1 时有效) */
ha_span choice0_span;
/* 解码/反转义结果(写入 sbuf,以 [off,len) 表示;kind 非字符串时为 (0,0)) */
size_t content_off; size_t content_len;
size_t reasoning_off; size_t reasoning_len;
size_t finish_off; size_t finish_len;
int has_choices; /* choices 是否存在且非 null */
int choices_kind; /* ABSENT / NULL / ARRAY */
int choices_count; /* 元素个数(>1 时调用方必须回退,见下) */
int choice0_kind; /* ABSENT / NULL / OBJECT */
} ha_chunk_out;
/*
* 一次调用定位整块解析所需的全部字段。
*
* data/len : SSE chunk 原始字节(不需要 NUL 结尾)
* out : 输出(调用方持有;C 只在本调用内写它)
* sbuf/scap : 解码输出缓冲(content / reasoning_content / finish_reason)
* sused : 出参,缓冲区实际用量
*
* 返回 HA_CHUNK_OK / HA_CHUNK_FALLBACK / HA_CHUNK_TYPE_FAIL。
*
* ★ 调用方**必须**检查 choices_count:Go 侧是 `[]struct`,Unmarshal 会解析
* **全部**元素,而本层只取 [0](协议约定)。若元素 >1,本层无法保证
* 其余元素也能被 Go 解析(它们可能有类型错误)⇒ 必须回退。
* 本函数在 choices_count>1 时**直接返回 FALLBACK**,不给调用方犯错的机会。
*/
int ha_sse_chunk_locate(const char *data, size_t len, ha_chunk_out *out,
char *sbuf, size_t scap, size_t *sused);
#ifdef __cplusplus
}
#endif
#endif /* HA_SSE_H */