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

@ -41,7 +41,7 @@ extern "C" {
#endif
#define HA_SSE_ABI_MAJOR 1
#define HA_SSE_ABI_MINOR 0
#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,
@ -131,6 +131,86 @@ int ha_sse_stringify(const ha_span *val, char *out, size_t cap, size_t *outlen);
*/
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

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;
}

View File

@ -354,6 +354,73 @@ C 层因此**无需**理解重复键的合并语义(5.1)、**无需**实现
> 第一刀推翻过一次(`C.CString` 造成 82% 自找开销),这一刀又推翻一次
> (逐字段往返造成 5+ 次边界)。两次都是**测量**推翻了直觉。
## 七、第三刀返工:批量定位(把 5+ 次边界压成 1 次)—— **部分成功,仍默认关闭**
§六 的否定结论指出根因是「逐字段往返」。本节按 §6.4 做架构改造并重测。
### 7.1 改造内容
| 项 | 改造前 | 改造后 |
|---|---|---|
| cgo 边界次数 | **5+**(每字段一次 findKey) | **1**(`ha_sse_chunk_locate`) |
| 键查找方式 | 每个键各扫一遍对象(6 趟) | **单趟分派**(遍历成员表一次就分发) |
| 解码 | 每字段一次往返 + 各自 decBuf | 同一趟内解码进**一块** sbuf(1 次分配) |
| 成员表遍历 | 6 趟 | **2 趟**(顶层 + delta) |
顺带修掉两处自己造的浪费(都是「先扫一遍拿个数、再扫第二遍拿首元素」):
`choices` 数组的「数个数 + 取首元素」合一趟;`choice0` 内的
delta/finish_reason 合一趟。
### 7.2 实测(50000 次迭代 × 3 轮,取中位;`benchtime` 与机器同前)
| 场景 | 改造后 Entry | 原实现 GoOnly | 判定 |
|---|---:|---:|---|
| content_zh | **1540** ns / 5 allocs | 1871 ns / 13 allocs | ✅ **快 18%**,分配 -62% |
| content_ascii | 1250 ns / 5 allocs | 1304 ns / 13 allocs | ⚠ 持平,分配 -62% |
| finish | **820** ns / 6 allocs | 921 ns / 12 allocs | ✅ 快 11% |
| usage | 2530 ns / 9 allocs | 2591 ns / 12 allocs | ✅ 持平偏快 |
| toolcall | **3450** ns / 20 allocs | **3000** ns / 21 allocs | ❌ **慢 15%** |
**从「五项全输」变成「三项赢 / 一项持平 / 一项输」**,且**所有场景的
分配数都下降**(13→5、12→6、12→9)。
### 7.3 为什么 `toolcall` 仍输(根因已定位)
分解测量:
| 组成 | 成本 |
|---|---:|
| C 侧一次 `ha_sse_chunk_locate`(纯 C,零边界零分配) | **766 ns** |
| Go 侧 `[]openAIToolCall` unmarshal | **1305 ns / 15 allocs** |
| 对照:Go 整块 unmarshal(一次搞定) | ~2980 ns |
问题在第二行:tool_calls 的元素是**对象**,`Arguments interface{}` 需要
真实的 `map[string]interface{}`,所以**必须**走 encoding/json 的反射建树。
而我们为了定位又先做了一遍 C 扫描 —— 于是「扫两遍」必然慢于「扫一遍」。
⇒ **这不是 C 慢,是「同一份数据被解析了两次」**:
C 负责定位(读一遍),encoding/json 负责建树(再读一遍)。
对**标量**字段(content / reasoning / finish)C 能一次到位,所以那些场景赢;
对**需要建树**的字段(tool_calls / usage)C 的定位是纯开销。
**解法(下一步)**:tool_calls / usage 命中时**完全跳过 C 定位**,
直接让 encoding/json 整块处理 —— 也就是「**按字段类型决定要不要 C 化**」。
这需要一次「试解析」来判断字段是否需要建树,或改为「先看顶层键集合再决策」。
### 7.4 当前状态:仍默认关闭
「五项全输」→「三项赢一项输」,不足以打开默认开关,理由:
1. **toolcall 是真实负载里最常见的一类块**(任何一次工具调用流),
而它仍慢 15%。在真实会话里,工具调用往往比纯文本多。
2. **收益幅度不足以抵消风险**:18% 的时间收益 vs 引入一层
与 `encoding/json` 语义并存的第二实现。而 tool_calls 路径的
分配数几乎没降(20 vs 21)—— 本刀的原始动机(消除 GC 抖动)
在最需要它的场景**没有兑现**。
⇒ 继续做的前提是**先把 §7.3 的解法做掉**(按字段类型决定是否 C 化),
让 toolcall 也不输,再重测。届时再决定是否开启。
### 已知边界(诚实记录)
- `ha_json_get_int` 返回 `long long`;Go 侧 usage 字段是 `int`(64 位平台相同,

View File

@ -155,199 +155,75 @@ func chunkAssemble(choices []chunkChoice, usage chunkUsage) (StreamChunk, bool)
// ---------------------------------------------------------------------
// chunkParseFast 尝试 C 快速路径。
// 返回 (chunk, handled, decided):
// handled=false ⇒ 调用方必须用 chunkParseGo
// handled=true,decided=true ⇒ 结果是最终答案
//
// ============================ 第三刀的重做:一次 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) {
root := rootSpan(data)
if !sseRootObject(root) {
return StreamChunk{}, false, false
}
// ---- usage:整棵子树交给 encoding/json ----
// ★ 为什么逐个整数取是错的:Go 侧 usage 有 9 个字段,且**任一类型不符
// 就让整块作废**(实测 {"prompt_cache_hit_tokens":"x","prompt_tokens":1}
// → 整块 false)。整棵 unmarshal 到**同一个 Go 类型** ⇒ 语义自动一致。
var usage chunkUsage
us, ufound, udup, ubad := findKeyCI(root, "usage")
if ubad || udup {
return StreamChunk{}, false, false
}
if ufound {
switch us.firstByte() {
case 'n':
// null ⇒ 零值 struct(不产出 usage)
case '{':
if err := json.Unmarshal(us.bytes(), &usage); err != nil {
// ★ 类型不符 ⇒ 与 Go 一样「整块作废」,**不需要回退**
return StreamChunk{}, false, true
}
default:
return StreamChunk{}, false, false // 交回 Go 决定
}
}
// ---- choices:只取 [0],但要先判整切片的长度语义 ----
cs, cfound, cdup, cbad := findKeyCI(root, "choices")
if cbad || cdup {
return StreamChunk{}, false, false
}
if !cfound {
ck, ok := chunkAssemble(nil, usage)
return ck, true, ok
}
switch cs.firstByte() {
case 'n':
// null ⇒ 零值切片(长度 0)⇒ 走「无 choices」分支
ck, ok := chunkAssemble(nil, usage)
return ck, true, ok
case '[':
loc := locateChunkBatch(data)
switch loc.status {
case chunkTypeFail:
// C 已判定「与 Go 一致的整块作废」⇒ 直接给答案,无需回退
return StreamChunk{}, false, true
case chunkOK:
// 继续
default:
return StreamChunk{}, false, false
}
el, has := firstElem(cs)
if !has {
// 空数组:len(choices)==0 ⇒ 与 Go 相同
ck, ok := chunkAssemble(nil, usage)
return ck, true, ok
// ---- 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
}
ch, ok := fastChoice(el)
if !ok {
return StreamChunk{}, false, false // 任何不确定 ⇒ 整体回退
// ---- delta 非对象 ⇒ 与 Go 的 Unmarshal 失败一致 ----
if loc.deltaKind == kindOther {
return StreamChunk{}, false, true
}
ck, ok2 := chunkAssemble([]chunkChoice{ch}, usage)
return ck, true, ok2
}
// fastChoice 解析 choices[0]。ok=false ⇒ 必须回退 Go。
//
// ★ 键匹配方式按 Go 那一跳的实际类型选择:
// - choices / delta / finish_reason / tool_calls 是 **struct 字段** ⇒ 大小写不敏感
// - content / reasoning_content / "text" 是 **interface{} → map key** ⇒ 大小写敏感
// (实测:{"CHOICES":[{"DELTA":{"CONTENT":"ci"}}]} 有效;
// {"content":[{"TEXT":"up"}]} 取不到 text)
func fastChoice(el strSpan) (chunkChoice, bool) {
var ch chunkChoice
if el.firstByte() != '{' {
return ch, false
}
ds, dfound, ddup, dbad := findKeyCI(el, "delta")
if dbad || ddup {
return ch, false
}
if dfound {
switch ds.firstByte() {
case 'n':
// delta:null ⇒ 零值 struct
case '{':
// ★ delta 是 **struct**(不是 map!)——
// 原实现:Delta struct { Content interface{}; ... } `json:"delta"`
// 故它的字段名匹配是**大小写不敏感**。
// 实测 `{"CHOICES":[{"DELTA":{"CONTENT":"ci"}}]}` → content="ci"。
// 只有 content 的**值**(若为对象/数组)才成为 map/[]interface{},
// 那时里面的键(如 "text")才是大小写敏感。
//
// 我一度把这里改成 CS 并认为「差分测试会通过」——那是错的推理:
// Go 侧给的是 "ci"(CI 匹配成功),改成 CS 反而把快速路径弄丢。
// 教训:**「哪一层是 struct、哪一层是 map」要回原实现读类型,
// 不能凭字段名像 map 就推断它是 map。**
// reasoning_content:Go 侧是 **string**(强类型)。
// 用同样的 Go 类型 unmarshal ⇒ 123 会报错,与原实现一致。
rs, rfound, rdup, rbad := findKeyCI(ds, "reasoning_content")
if rbad || rdup {
return ch, false
}
if rfound && rs.firstByte() != 'n' {
var s string
if err := json.Unmarshal(rs.bytes(), &s); err != nil {
return ch, false // 类型不符 ⇒ 回退(Go 会整块作废)
}
ch.reasoning = s
}
// content 字段名:CI(struct 字段)。
// 其**值**若是数组/对象,内部键由 ha_sse_stringify 按 CS 处理。
cs, cfound, cdup, cbad := findKeyCI(ds, "content")
if cbad || cdup {
return ch, false
}
if cfound {
if s, handled := stringifyC(cs); handled {
ch.content = s
} else {
return ch, false // 需 json.Marshal 重新编码(§5.2)
}
}
// tool_calls:逐个元素整体 unmarshal 成 openAIToolCall,
// 使 arguments 的 interface{} 形态 / 类型检查全由 encoding/json 负责。
tcs, tfound, tdup, tbad := findKeyCI(ds, "tool_calls")
if tbad || tdup {
return ch, false
}
if tfound {
switch tcs.firstByte() {
case 'n':
// null ⇒ 零值切片
case '[':
t, ok := fastToolCalls(tcs)
if !ok {
return ch, false
}
ch.toolCalls = t
default:
return ch, false
}
}
default:
return ch, false
}
}
// finish_reason:struct 字段 ⇒ 大小写不敏感;Go 侧是 *string
fs, ffound, fdup, fbad := findKeyCI(el, "finish_reason")
if fbad || fdup {
return ch, false
}
if ffound && fs.firstByte() != 'n' {
if fs.firstByte() != '"' {
return ch, false
}
var s string
if err := json.Unmarshal(fs.bytes(), &s); err != nil {
return ch, false
}
ch.finishPtr = &s
}
return ch, true
}
// fastToolCalls 解析 tool_calls 数组。
//
// ★ 逐元素整体 unmarshal 成 openAIToolCall 是刻意的:这样 arguments 的
// interface{} 形态、字符串/对象/数组/数字各分支、重复键,全部由
// encoding/json 处理(§5.2 的重新编码语义不必在 C 复刻)。
// 归一化也走**同一个** normalizeStreamToolCall ⇒ 与 Go 路径不分叉。
func fastToolCalls(arr strSpan) ([]ToolCall, bool) {
elems, ok := scanArray(arr)
if !ok {
return nil, false
}
if len(elems) == 0 {
return nil, true // 空数组 ⇒ nil(与 Go 的 normalizeStreamToolCalls 一致)
}
out := make([]ToolCall, 0, len(elems))
for _, elem := range elems {
if elem.firstByte() != '{' {
return nil, false
}
var raw openAIToolCall
if err := json.Unmarshal(elem.bytes(), &raw); err != nil {
return nil, false
}
out = append(out, normalizeStreamToolCall(raw))
}
return out, 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
}

View File

@ -118,6 +118,11 @@ static int go_arr_all(const char *p, size_t n, ha_span *out, int cap) {
return count;
}
static int go_chunk_locate(const char *p, size_t n, ha_chunk_out *out,
char *sbuf, size_t scap, size_t *sused) {
return ha_sse_chunk_locate(p, n, out, sbuf, scap, sused);
}
static int go_sse_abi(void) { return ha_sse_abi_version(); }
*/
import "C"
@ -326,3 +331,134 @@ func scanArray(arr strSpan) ([]strSpan, bool) {
}
return out, true
}
// ---------------------------------------------------------------------
// 批量定位(第三刀的重做:一次 cgo 调用代替 5+ 次)
// ---------------------------------------------------------------------
// chunkLocateResult 是 C 侧 ha_chunk_out 的 Go 视图。
type chunkLocateResult struct {
status int
// 原始 span(用于交回 encoding/json 的那些字段)
usageSpan strSpan
usageKind int
toolCallsSpan strSpan
toolCallsKind int
// C 已解码的字符串(sbuf 的副本)
content string
reasoning string
finish string
// 标志
choicesPresent bool
choicesKind int
choicesCount int
choice0Span strSpan
hasDelta bool
deltaKind int
contentKind int
reasoningKind int
finishKind int
}
// 槽位/类型常量(与 ha_sse.h 保持一致;改动必须同步 ABI 版本)
const (
slotDelta = 0
slotContent = 1
slotReasoning = 2
slotToolCalls = 3
slotFinishReason = 4
slotUsage = 5
slotCount = 6
kindAbsent = 0
kindNull = 1
kindString = 2
kindObject = 3
kindArray = 4
kindOther = 5
chunkOK = 0
chunkFallback = -1
chunkTypeFail = -2
)
// locateChunkBatch 一次调用完成整块定位。
func locateChunkBatch(data string) chunkLocateResult {
var out chunkLocateResult
if len(data) == 0 {
out.status = chunkFallback
return out
}
p, n := cstr(data)
var co C.ha_chunk_out
// ★ 单块缓冲:整块解码输出(content+reasoning+finish)都写这一块。
// 尺寸按输入上界(每字节最坏 3 字节 U+FFFD)——1 次分配,
// 替代原来「每个字段一次 decBuf」的多次分配。
sbuf := make([]byte, len(data)*3+16)
var used C.size_t
st := C.go_chunk_locate(p, n, &co, cstrb(sbuf), C.size_t(len(sbuf)), &used)
out.status = int(st)
if st != C.int(chunkOK) {
return out
}
out.usageKind = int(co.slot[slotUsage].kind)
out.usageSpan = strSpan{co.slot[slotUsage].span.p, co.slot[slotUsage].span.len}
out.toolCallsKind = int(co.slot[slotToolCalls].kind)
out.toolCallsSpan = strSpan{co.slot[slotToolCalls].span.p, co.slot[slotToolCalls].span.len}
out.contentKind = int(co.slot[slotContent].kind)
out.reasoningKind = int(co.slot[slotReasoning].kind)
out.finishKind = int(co.slot[slotFinishReason].kind)
out.hasDelta = int(co.slot[slotDelta].kind) == kindObject
out.deltaKind = int(co.slot[slotDelta].kind)
out.choicesPresent = co.has_choices == 1
out.choicesKind = int(co.choices_kind)
out.choicesCount = int(co.choices_count)
s := sbuf[:int(used)]
out.content = string(s[co.content_off : co.content_off+co.content_len])
out.reasoning = string(s[co.reasoning_off : co.reasoning_off+co.reasoning_len])
out.finish = string(s[co.finish_off : co.finish_off+co.finish_len])
return out
}
// locateChunkBatchInto 是 locateChunkBatch 的零分配内核(基准用):
// 复用调用方提供的 sbuf,不自己 make。
func locateChunkBatchInto(data string, sbuf []byte) chunkLocateResult {
var out chunkLocateResult
if len(data) == 0 {
out.status = chunkFallback
return out
}
p, n := cstr(data)
var co C.ha_chunk_out
var used C.size_t
st := C.go_chunk_locate(p, n, &co, cstrb(sbuf), C.size_t(len(sbuf)), &used)
out.status = int(st)
if st != C.int(chunkOK) {
return out
}
out.usageKind = int(co.slot[slotUsage].kind)
out.usageSpan = strSpan{co.slot[slotUsage].span.p, co.slot[slotUsage].span.len}
out.toolCallsKind = int(co.slot[slotToolCalls].kind)
out.toolCallsSpan = strSpan{co.slot[slotToolCalls].span.p, co.slot[slotToolCalls].span.len}
out.contentKind = int(co.slot[slotContent].kind)
out.reasoningKind = int(co.slot[slotReasoning].kind)
out.finishKind = int(co.slot[slotFinishReason].kind)
out.hasDelta = int(co.slot[slotDelta].kind) == kindObject
out.deltaKind = int(co.slot[slotDelta].kind)
out.choicesPresent = co.has_choices == 1
out.choicesKind = int(co.choices_kind)
out.choicesCount = int(co.choices_count)
out.choice0Span = strSpan{co.choice0_span.p, co.choice0_span.len}
s := sbuf[:int(used)]
out.content = string(s[co.content_off : co.content_off+co.content_len])
out.reasoning = string(s[co.reasoning_off : co.reasoning_off+co.reasoning_len])
out.finish = string(s[co.finish_off : co.finish_off+co.finish_len])
return out
}