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
This commit is contained in:
JianFeeeee
2026-09-26 10:41:52 +08:00
parent 7748ec450e
commit 7a1322c97f
5 changed files with 742 additions and 190 deletions

View File

@ -278,3 +278,396 @@ int ha_sse_arg_string(const ha_span *val, char *out, size_t cap, size_t *outlen)
*outlen = n;
return 1;
}
/* ==================================================================== */
/* 批量定位:一次调用返回全部字段 */
/* ==================================================================== */
static int scan_first_and_count(const ha_span *arr, ha_span *first, int *count);
static int chunk_choice_dispatch(ha_span choice, ha_chunk_out *out);
static int chunk_delta_dispatch(ha_span delta, ha_chunk_out *out, int *dup);
static void slot_reset(ha_chunk_slot *s) {
s->span.p = NULL;
s->span.len = 0;
s->kind = HA_CHUNK_KIND_ABSENT;
}
static void chunk_out_reset(ha_chunk_out *o) {
size_t i;
for (i = 0; i < (size_t)HA_CHUNK_SLOT_COUNT; i++) {
slot_reset(&o->slot[i]);
}
o->content_off = 0; o->content_len = 0;
o->reasoning_off = 0; o->reasoning_len = 0;
o->finish_off = 0; o->finish_len = 0;
o->choices_span.p = NULL;
o->choices_span.len = 0;
o->has_choices = 0;
o->choices_kind = HA_CHUNK_KIND_ABSENT;
o->choices_count = 0;
o->choice0_kind = HA_CHUNK_KIND_ABSENT;
}
/* 键名比较:大小写不敏感(struct 字段语义)。
* ★ 为什么不直接用 ha_json_key_eq:那个接收 ha_span,而这里要按
* 已知长度比较(省掉 strlen)—— 且必须与 Go 对 struct 字段的匹配一致。 */
static int ci_eq(const char *p, const char *name, size_t n) {
size_t i;
for (i = 0; i < n; i++) {
if (sse_lower((unsigned char)p[i]) != sse_lower((unsigned char)name[i])) {
return 0;
}
}
return 1;
}
static int kind_of(const ha_span *v) {
if (v == NULL || v->p == NULL || v->len == 0) {
return HA_CHUNK_KIND_ABSENT;
}
switch (v->p[0]) {
case '"': return HA_CHUNK_KIND_STRING;
case '{': return HA_CHUNK_KIND_OBJECT;
case '[': return HA_CHUNK_KIND_ARRAY;
case 'n': return HA_CHUNK_KIND_NULL;
default: return HA_CHUNK_KIND_OTHER;
}
}
/* 把字符串值解码进 sbuf 的 [off,off+len)。返回 0 失败(空间不足/语法错)。 */
static int emit_decoded(ha_span val, char *sbuf, size_t scap, size_t *off,
size_t *outlen) {
ha_json_scan sc;
ha_span raw;
size_t n;
*off = 0;
*outlen = 0;
ha_json_scan_init(&sc, val.p, val.len);
if (!ha_json_scan_string(&sc, &raw)) {
return 0;
}
/* 上界检查:每个输入字节最坏变 3 字节 U+FFFD */
if (scap < raw.len * 3u + 4u) {
return 0;
}
n = ha_json_decode_string_into(raw, sbuf, scap);
if (n == (size_t)-1) {
return 0;
}
*off = 0;
*outlen = n;
return 1;
}
/*
* 顶层单趟分派:遍历成员表一次,按名字分派到对应槽位。
* 顶层键(choices/usage)是 **struct 字段** ⇒ 大小写不敏感。
* 返回 0 = 正常(即使有重复键,dup 由调用方检查);-1 = 畸形。
*/
static int chunk_top_dispatch(ha_span root, ha_chunk_out *out, int *dup) {
ha_json_members m;
ha_span k, v;
ha_span el_tmp;
int r;
*dup = 0;
if (!ha_json_members_init(&m, root.p, root.len)) {
return -1;
}
while (ha_json_members_next(&m, &k, &v)) {
int t = kind_of(&v);
if (k.len == 7 && ci_eq(k.p, "choices", 7)) {
if (out->choices_kind != HA_CHUNK_KIND_ABSENT) {
*dup = 1; /* 重复键:字段级合并语义 ⇒ 交回 Go */
}
out->has_choices = (t != HA_CHUNK_KIND_ABSENT &&
t != HA_CHUNK_KIND_NULL) ? 1 : 0;
out->choices_kind = t;
out->choices_span = v;
if (t == HA_CHUNK_KIND_ARRAY) {
/* 一趟同时得出「首元素 span」与「元素个数」。
* ★ 初版为了拿个数先把整个数组扫一遍、再调 ha_sse_arr_first
* 重新扫第二遍 —— 而单趟成员遍历实测 107ns,两趟就是白扔 100ns+。
*/
if (scan_first_and_count(&v, &el_tmp, &out->choices_count) != 0) {
return -1;
}
if (out->choices_count > 0) {
out->choice0_span = el_tmp;
}
}
continue;
}
if (k.len == 5 && ci_eq(k.p, "usage", 5)) {
if (out->slot[HA_CHUNK_SLOT_USAGE].kind != HA_CHUNK_KIND_ABSENT) {
*dup = 1;
}
out->slot[HA_CHUNK_SLOT_USAGE].span = v;
out->slot[HA_CHUNK_SLOT_USAGE].kind = t;
continue;
}
/* 其余顶层键(id/object/created/model/system_fingerprint…)一律忽略。
* ★ Go 侧 struct 未声明 ⇒ 忽略;没有「类型不符」的可能。 */
}
r = ha_json_members_complete(&m) ? 0 : -1;
return r;
}
/* 一趟取数组的首元素 span 与元素个数。
* 返回 0 成功;非 0 表示数组畸形。
* ★ 超过 2 个元素即停止计数并置 *count = 2(调用方一律回退),
* 这样超大数组不会白扫 —— 而 Go 侧那种输入压根不该走快速路径。 */
static int scan_first_and_count(const ha_span *arr, ha_span *first, int *count) {
ha_json_scan sc;
int n = 0;
first->p = NULL;
first->len = 0;
ha_json_scan_init(&sc, arr->p, arr->len);
(void)ha_json_scan_ws(&sc);
if (ha_json_scan_eof(&sc) || sc.s[sc.i] != '[') {
return -1;
}
sc.i++;
for (;;) {
size_t start;
(void)ha_json_scan_ws(&sc);
if (ha_json_scan_eof(&sc) || sc.s[sc.i] == ']') {
break;
}
start = sc.i;
if (!ha_json_skip(&sc)) {
return -1;
}
if (n == 0) {
first->p = arr->p + start;
first->len = sc.i - start;
}
n++;
if (n >= 2) {
/* 已知 >1:调用方必然回退,无需继续扫 */
*count = 2;
return 0;
}
(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;
}
*count = n;
return 0;
}
/* choice0 内的单趟分派:delta + finish_reason(struct 字段 ⇒ CI)。
* 返回 0 正常;非 0 = 畸形或重复键。 */
static int chunk_choice_dispatch(ha_span choice, ha_chunk_out *out) {
ha_json_members cm;
ha_span ck, cv;
if (!ha_json_members_init(&cm, choice.p, choice.len)) {
return -1;
}
while (ha_json_members_next(&cm, &ck, &cv)) {
int t = kind_of(&cv);
if (ck.len == 5 && ci_eq(ck.p, "delta", 5)) {
if (out->slot[HA_CHUNK_SLOT_DELTA].kind != HA_CHUNK_KIND_ABSENT) {
return -1; /* 重复键 */
}
out->slot[HA_CHUNK_SLOT_DELTA].span = cv;
out->slot[HA_CHUNK_SLOT_DELTA].kind = t;
continue;
}
if (ck.len == 13 && ci_eq(ck.p, "finish_reason", 13)) {
if (out->slot[HA_CHUNK_SLOT_FINISH_REASON].kind != HA_CHUNK_KIND_ABSENT) {
return -1;
}
out->slot[HA_CHUNK_SLOT_FINISH_REASON].span = cv;
out->slot[HA_CHUNK_SLOT_FINISH_REASON].kind = t;
continue;
}
}
return ha_json_members_complete(&cm) ? 0 : -1;
}
/* delta 内单趟分派。delta 是 **struct** ⇒ 字段名大小写不敏感。 */
static int chunk_delta_dispatch(ha_span delta, ha_chunk_out *out, int *dup) {
ha_json_members m;
ha_span k, v;
*dup = 0;
if (!ha_json_members_init(&m, delta.p, delta.len)) {
return -1;
}
while (ha_json_members_next(&m, &k, &v)) {
int t = kind_of(&v);
if (k.len == 7 && ci_eq(k.p, "content", 7)) {
if (out->slot[HA_CHUNK_SLOT_CONTENT].kind != HA_CHUNK_KIND_ABSENT) {
*dup = 1;
}
out->slot[HA_CHUNK_SLOT_CONTENT].span = v;
out->slot[HA_CHUNK_SLOT_CONTENT].kind = t;
continue;
}
if (k.len == 17 && ci_eq(k.p, "reasoning_content", 17)) {
if (out->slot[HA_CHUNK_SLOT_REASONING].kind != HA_CHUNK_KIND_ABSENT) {
*dup = 1;
}
out->slot[HA_CHUNK_SLOT_REASONING].span = v;
out->slot[HA_CHUNK_SLOT_REASONING].kind = t;
continue;
}
if (k.len == 10 && ci_eq(k.p, "tool_calls", 10)) {
if (out->slot[HA_CHUNK_SLOT_TOOL_CALLS].kind != HA_CHUNK_KIND_ABSENT) {
*dup = 1;
}
out->slot[HA_CHUNK_SLOT_TOOL_CALLS].span = v;
out->slot[HA_CHUNK_SLOT_TOOL_CALLS].kind = t;
continue;
}
}
return ha_json_members_complete(&m) ? 0 : -1;
}
int ha_sse_chunk_locate(const char *data, size_t len, ha_chunk_out *out,
char *sbuf, size_t scap, size_t *sused) {
ha_span root;
int dup = 0;
ha_span el, v;
if (out == NULL || sused == NULL) {
return HA_CHUNK_FALLBACK;
}
chunk_out_reset(out);
*sused = 0;
if (data == NULL || len == 0) {
return HA_CHUNK_FALLBACK;
}
root.p = data;
root.len = len;
/* 顶层必须是「恰好一个」良构对象(含尾部残留检查) */
if (!ha_sse_root_object(&root)) {
return HA_CHUNK_FALLBACK;
}
if (chunk_top_dispatch(root, out, &dup) != 0) {
return HA_CHUNK_FALLBACK;
}
if (dup) {
return HA_CHUNK_FALLBACK; /* §5.1 字段级合并 */
}
/* ---- choices[0] ---- */
if (out->choices_kind == HA_CHUNK_KIND_ARRAY) {
if (out->choices_count > 1) {
/* Go 侧会解析**全部**元素;本层只认 [0],其余元素可能类型不符
* 而让 Go 整块作废 ⇒ 无法保证等价,必须回退。 */
return HA_CHUNK_FALLBACK;
}
if (out->choices_count == 0) {
out->choice0_kind = HA_CHUNK_KIND_ABSENT;
} else {
if (ha_sse_arr_first(&out->choices_span, &el) != 1) {
return HA_CHUNK_FALLBACK;
}
out->choice0_kind = kind_of(&el);
if (out->choice0_kind != HA_CHUNK_KIND_OBJECT) {
/* Go 侧是 []struct:元素非对象 ⇒ 整块作废 */
return HA_CHUNK_TYPE_FAIL;
}
/* 元素内的 delta / finish_reason(struct 字段 ⇒ CI),**一趟**取完。
* ★ 初版这里对 choices 数组做了「数个数 + 取首元素」两趟、
* 又在 choice0 内单独跑一趟 members —— 合计 3 趟。
*/
{
if (chunk_choice_dispatch(el, out) != 0) {
return HA_CHUNK_FALLBACK;
}
}
}
}
/* ---- delta 内分派 ---- */
if (out->slot[HA_CHUNK_SLOT_DELTA].kind == HA_CHUNK_KIND_OBJECT) {
v = out->slot[HA_CHUNK_SLOT_DELTA].span;
if (chunk_delta_dispatch(v, out, &dup) != 0) {
return HA_CHUNK_FALLBACK;
}
if (dup) {
return HA_CHUNK_FALLBACK;
}
} else if (out->slot[HA_CHUNK_SLOT_DELTA].kind == HA_CHUNK_KIND_OTHER) {
/* delta 非对象:Go 侧 Unmarshal 到 struct 会失败 */
return HA_CHUNK_TYPE_FAIL;
}
/* ---- content:字符串直接解码;文本数组走 stringify ---- */
{
ha_chunk_slot *cs = &out->slot[HA_CHUNK_SLOT_CONTENT];
if (cs->kind == HA_CHUNK_KIND_STRING) {
if (!emit_decoded(cs->span, sbuf, scap, &out->content_off,
&out->content_len)) {
return HA_CHUNK_FALLBACK;
}
*sused = out->content_len;
} else if (cs->kind == HA_CHUNK_KIND_ARRAY) {
size_t n = 0;
if (!ha_sse_stringify(&cs->span, sbuf, scap, &n)) {
/* 需 json.Marshal 重新编码(§5.2)⇒ 交回 Go */
return HA_CHUNK_FALLBACK;
}
out->content_off = 0;
out->content_len = n;
*sused = n;
} else if (cs->kind == HA_CHUNK_KIND_OBJECT ||
cs->kind == HA_CHUNK_KIND_OTHER) {
/* 对象/数字/布尔 ⇒ stringifyContent 走 json.Marshal(§5.2) */
return HA_CHUNK_FALLBACK;
}
/* NULL / ABSENT ⇒ content=""(与 Go 的 stringifyContent(nil) 一致) */
}
/* ---- reasoning_content:Go 侧是 **string**(强类型) ----
* 若是 string 则解码;若是 null/absent ⇒ "";其它类型 ⇒ 整块作废。 */
{
ha_chunk_slot *rs = &out->slot[HA_CHUNK_SLOT_REASONING];
if (rs->kind == HA_CHUNK_KIND_STRING) {
if (!emit_decoded(rs->span, sbuf + *sused, scap - *sused,
&out->reasoning_off, &out->reasoning_len)) {
return HA_CHUNK_FALLBACK;
}
out->reasoning_off += *sused;
*sused += out->reasoning_len;
} else if (rs->kind != HA_CHUNK_KIND_ABSENT &&
rs->kind != HA_CHUNK_KIND_NULL) {
return HA_CHUNK_TYPE_FAIL; /* 与 Go 的 Unmarshal 失败一致 */
}
}
/* ---- finish_reason:Go 侧是 *string ---- */
{
ha_chunk_slot *fs = &out->slot[HA_CHUNK_SLOT_FINISH_REASON];
if (fs->kind == HA_CHUNK_KIND_STRING) {
if (!emit_decoded(fs->span, sbuf + *sused, scap - *sused,
&out->finish_off, &out->finish_len)) {
return HA_CHUNK_FALLBACK;
}
out->finish_off += *sused;
*sused += out->finish_len;
} else if (fs->kind != HA_CHUNK_KIND_ABSENT &&
fs->kind != HA_CHUNK_KIND_NULL) {
return HA_CHUNK_TYPE_FAIL;
}
}
return HA_CHUNK_OK;
}