mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 13:23:03 +00:00
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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user