diff --git a/csrc/include/ha_sse.h b/csrc/include/ha_sse.h index 31cccf6..431f808 100644 --- a/csrc/include/ha_sse.h +++ b/csrc/include/ha_sse.h @@ -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 diff --git a/csrc/src/ha_sse.c b/csrc/src/ha_sse.c index 0371e6b..8fac022 100644 --- a/csrc/src/ha_sse.c +++ b/csrc/src/ha_sse.c @@ -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; +} diff --git a/docs/zh/c-core/sse-codec-c.md b/docs/zh/c-core/sse-codec-c.md index 4a20696..18c88ec 100644 --- a/docs/zh/c-core/sse-codec-c.md +++ b/docs/zh/c-core/sse-codec-c.md @@ -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 位平台相同, diff --git a/internal/agent/api/codec_chunkfast_c.go b/internal/agent/api/codec_chunkfast_c.go index fe2656f..c412ee6 100644 --- a/internal/agent/api/codec_chunkfast_c.go +++ b/internal/agent/api/codec_chunkfast_c.go @@ -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 } diff --git a/internal/agent/api/codec_streamchunk_c.go b/internal/agent/api/codec_streamchunk_c.go index 4bc2bc1..29724e8 100644 --- a/internal/agent/api/codec_streamchunk_c.go +++ b/internal/agent/api/codec_streamchunk_c.go @@ -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 +}