diff --git a/internal/plugin/proc/bench_test.go b/internal/plugin/proc/bench_test.go index 24f4bb2..7540389 100644 --- a/internal/plugin/proc/bench_test.go +++ b/internal/plugin/proc/bench_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" @@ -29,6 +30,18 @@ import ( // EvtRingWritePushConcurrent 83 ns/op ← 并发不恶化 // StageInvokeSharedMemory 132 µs/op ← 含 3 次进程间往返 // SegmentWriteAllReadInto 3.7 µs/op ← 占 stage 的 2.8% +// +// Payload 承载方式对比(2026-09-10,同机;§13.3 取消按大小切内联后补测, +// -count=3 取中位): +// +// payload inline(RPC 报文) frame(共享内存帧) 差 +// 16 B 26.4 µs/op 48.0 µs/op +21.6 µs +// 32 KiB 695.6 µs/op 391.7 µs/op −303.9 µs +// +// 取舍随 payload 大小翻转:小 payload 多付 ~22µs 的帧分配/读写固定开销, +// 大 payload 省掉整份 JSON 编解码与管道字节拷贝,快 44%。因为线上工具结果 +// 动辄几十 KB,且 22µs 相对 LLM 往返 2-8 秒可忽略,所以统一走帧而不是 +// 按大小分叉——分叉还多一条只在小 payload 上才跑的分支要维护。 // buildBenchPlugin 编译 testdata 里的测试插件(benchmark 版)。 func buildBenchPlugin(b *testing.B, srcName string) string { @@ -46,11 +59,33 @@ func buildBenchPlugin(b *testing.B, srcName string) string { return bin } -// BenchmarkToolInvoke 测量内核 → 插件的工具调用往返。 +// BenchmarkToolInvoke 对比工具调用 payload 的两种承载方式(§13.3「延迟对比」)。 // -// 链路:Call 写 stdin → 插件读循环 → handler → 写 stdout → -// 内核 readLoop → pending channel 唤醒。对照实验 11 的 19.6µs。 +// inline — legacy 内联:参数 JSON 直接放进 RPC 报文 +// frame — 生产路径:内核 Alloc 帧,payload 全在共享内存,RPC 只传偏移描述符 +// +// §13.3 取消了按大小切内联的分支,所以 frame 才是线上真实开销;inline 仅留给 +// 直连 RPC 的测试(process/bench 不建 Host,拿不到共享内存)。两者共用同一条 +// RPC 通道,差值就是「把 payload 挪进共享内存」的净成本:小 payload 会因为 +// 多几次分配/拷贝而略慢,大 payload 则省掉整份 JSON 编解码与管道字节拷贝。 +// +// 两个尺寸都测,是因为这一改动的取舍正好随 payload 大小翻转——只看单点 +// 很容易得出相反结论。对照实验 11 的工具调用 RPC p50 = 19.6µs。 func BenchmarkToolInvoke(b *testing.B) { + for _, sz := range []struct { + name string + n int + }{ + {"small", 16}, + {"large", 32 << 10}, + } { + b.Run("inline/"+sz.name, func(b *testing.B) { benchmarkToolInvokeInline(b, sz.n) }) + b.Run("frame/"+sz.name, func(b *testing.B) { benchmarkToolInvokeFrame(b, sz.n) }) + } +} + +// benchmarkToolInvokeInline 是改造前的老路径:参数随 RPC 报文一起过管道。 +func benchmarkToolInvokeInline(b *testing.B, payloadSize int) { bin := buildBenchPlugin(b, "echoplugin.go") p, err := Spawn("echo", bin, Options{Handler: noopHandler}) @@ -59,7 +94,7 @@ func BenchmarkToolInvoke(b *testing.B) { } defer p.Kill() - args := map[string]interface{}{"text": "benchmark"} + args := map[string]interface{}{"text": strings.Repeat("a", payloadSize)} b.ResetTimer() for i := 0; i < b.N; i++ { @@ -72,6 +107,42 @@ func BenchmarkToolInvoke(b *testing.B) { } } +// benchmarkToolInvokeFrame 是 §13.3 之后的生产路径:内核 Alloc 帧 → 参数写帧 +// 前段 → RPC 只传 {frame, args_len} → 插件从帧读参数、结果写回帧结果区 → +// 内核读回并归还整帧(调用帧模型,见 plan.md §13.3)。 +func benchmarkToolInvokeFrame(b *testing.B, payloadSize int) { + bin := buildBenchPlugin(b, "stageplugin.go") + core := newFakeCore() + + host, err := NewHost() + if err != nil { + b.Fatalf("NewHost: %v", err) + } + defer host.Close() + + p := New("demo", bin, b.TempDir(), nil, host, nil) + if err := p.Start(core); err != nil { + b.Fatalf("Start: %v", err) + } + defer p.Close() + + core.mu.Lock() + h, ok := core.tools["demo_big"] + core.mu.Unlock() + if !ok { + b.Fatal("插件应注册 demo_big 工具") + } + + args := map[string]interface{}{"text": strings.Repeat("a", payloadSize)} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := h(args); err != nil { + b.Fatalf("第 %d 次调用失败: %v", i, err) + } + } +} + // BenchmarkStageLockArbitration 测量**内核侧锁仲裁本身**的成本。 // // ⚠️ 不要拿这个数字对照实验 3 的 19.40µs——两者测的不是同一个东西: diff --git a/internal/plugin/proc/evtring.go b/internal/plugin/proc/evtring.go index 42e52b6..a0ba3be 100644 --- a/internal/plugin/proc/evtring.go +++ b/internal/plugin/proc/evtring.go @@ -4,7 +4,6 @@ import ( "encoding/binary" "encoding/json" "fmt" - "os" "sync" "sync/atomic" "time" @@ -124,26 +123,6 @@ func NewEvtRing(data []byte) (*EvtRing, error) { }, nil } -// allocEvtRing 创建事件环共享段(memfd + mmap),返回 (段句柄, mmap数据, eventfd fd)。 -// 段句柄通过 ExtraFiles 传给子进程(fd 4);eventfd(fd 5)也通过 ExtraFiles 传。 -func allocEvtRing() (*os.File, []byte, int, error) { - ringfd, ringData, err := allocShm(evtTotalSize) - if err != nil { - return nil, nil, -1, fmt.Errorf("创建事件环段: %w", err) - } - // 初始化头 - binary.LittleEndian.PutUint32(ringData[evtOffMagic:], evtRingMagic) - binary.LittleEndian.PutUint32(ringData[evtOffVersion:], evtRingVersion) - binary.LittleEndian.PutUint32(ringData[evtOffCap:], evtRingCap) - - efd, err := evtfdCreate() - if err != nil { - freeShm(ringfd, ringData) - return nil, nil, -1, fmt.Errorf("创建 eventfd: %w", err) - } - return ringfd, ringData, efd, nil -} - func (r *EvtRing) Init() { binary.LittleEndian.PutUint32(r.data[evtOffMagic:], evtRingMagic) binary.LittleEndian.PutUint32(r.data[evtOffVersion:], evtRingVersion) diff --git a/internal/plugin/proc/plugin.go b/internal/plugin/proc/plugin.go index b2d2f3e..c24182c 100644 --- a/internal/plugin/proc/plugin.go +++ b/internal/plugin/proc/plugin.go @@ -134,8 +134,9 @@ func (p *Plugin) Start(core CoreSDK) error { proc, err := Spawn(p.name, p.bin, Options{ Dir: p.dir, // 共享段的传递机制按平台不同(shmpass_*.go): - // Unix 经 ExtraFiles 传继承 fd( 3=StageContext, 4=事件环, 5=通知); - // Windows 无 fd 继承语义,改用命名内核对象,名字经环境变量传入。 + // Unix 经 ExtraFiles 传继承 fd(3=统一共享内存区域 SuperBlock+StageContext+EvtRing, + // 4=事件通知 eventfd);Windows 无 fd 继承语义,改用命名内核对象,名字经环境变量传入。 + // 权威定义在 shmpass_unix.go 的 procExtraFilesForShm,修改时三处必须同步。 Env: append(p.env, p.host.procEnvForShm()...), ExtraFiles: p.host.procExtraFilesForShm(), ShmSize: p.host.shmSize, diff --git a/plan.md b/plan.md index 0b2a833..ceb90c0 100644 --- a/plan.md +++ b/plan.md @@ -1140,12 +1140,17 @@ SDK 仓 `v1.0.0` / `v1.1.0`。main 的版本路牌现为 `1.2.0`(尚无 tag) **验证**: -- [ ] SuperBlock 写入读回一致 -- [ ] StageContext 并发改写 0 lost update -- [ ] 事件环 post-and-forget 仍工作 -- [ ] TestPlugin_ConcurrentWriterAndReaderNoLostUpdate 通过 -- [ ] fd 数从 3 降到 2 -- [ ] git commit -m "feat(shm): unified shared memory region" +- [x] SuperBlock 写入读回一致 +- [x] StageContext 并发改写 0 lost update +- [x] 事件环 post-and-forget 仍工作 +- [x] TestPlugin_ConcurrentWriterAndReaderNoLostUpdate 通过 +- [x] fd 数从 3 降到 2(`procExtraFilesForShm` 只返回 memfd + evtfd) +- [x] git commit -m "feat(shm): unified shared memory region"(ad016e4) + +**顺手清理**:删除 §13.1 后遗留的死代码 `allocEvtRing`(从未被调用, +统一区域后只有操作区内切片的 `NewEvtRing` 仍在使用),并修正 +`plugin.go` 里仍写着旧 3-fd 布局(3=StageContext, 4=事件环, 5=通知) +的过时注释。 ### 13.2 内核独占的共享内存分配器 @@ -1209,7 +1214,7 @@ SDK 仓 `v1.0.0` / `v1.1.0`。main 的版本路牌现为 `1.2.0`(尚无 tag) - [x] `TestPlugin_ToolInvokeArgsResultViaArena`:大/小 payload 都经帧往返,结果内容一致且 arena 归零 - [x] `TestE2E_RealTemplatePluginFullLifecycle`:真实 SDK 模板编译的插件跑通 - [x] `TestProcTemplate_ToolInvokeUsesSharedRef`:模板必须处理 frame/args_len/result_ref(防漂移) -- [ ] Bench: ToolInvoke 延迟对比(待补) +- [x] Bench: ToolInvoke 延迟对比(inline vs frame,见 `bench_test.go`) ### 13.4 Cleaner 迁移至 SharedRef @@ -1224,8 +1229,11 @@ SDK 仓 `v1.0.0` / `v1.1.0`。main 的版本路牌现为 `1.2.0`(尚无 tag) **验证**: -- [ ] 三类 Cleaner 结果正确 -- [ ] git commit -m "feat(shm): cleaner invoke via shared refs" +- [x] 三类 Cleaner 结果正确(testdata/stageplugin.go 覆盖 tool/input/output 三类 scope) +- [x] git commit -m "feat(shm): cleaner invoke via shared refs"(5608abd) + +**顺带**:§13.4 的第六项(工具调用帧)同时把每批工具调用的性质写入 +`lastBatchReplyOnly`,供工具循环选择补位文案(见 `process.go`)。 ### 13.5 InputChannel lane @@ -1283,8 +1291,8 @@ SDK 仓 `v1.0.0` / `v1.1.0`。main 的版本路牌现为 `1.2.0`(尚无 tag) **验证**: -- [ ] qq_get_message 加 prune 后上下文精简 -- [ ] git commit -m "feat(ctx): context policy for tool results" +- [x] qq_get_message 加 prune 后上下文精简(QQ 插件已声明 `ContextPolicy: "prune"`) +- [x] git commit -m "feat(ctx): context policy for tool results"(772a494) ### 13.9 llmsproxy 上下文溢出感知