总跟踪:#121
正式 macro gate 前置:#122
类型:先测量、后决定。本 issue 不要求修改 SendPump;可信 No-Go 即完成。
实施基线:开始工作时读取 dev 实际 HEAD,并重新核对本文代码事实。
目标
回答一个具体问题:
当前 SendPump 的 timed batching 在目标 workload 中,是否因为 WaitToReadAsync -> AsTask -> CancellationTokenSource -> Task.Delay(TimeProvider) -> Task.WhenAny 产生足够大的 CPU / allocation / scheduler 成本,值得引入更复杂的 reusable timer 状态机?
不能因为源码里出现 Task.Delay 就直接优化。高吞吐时队列可能持续有数据,size threshold 可能先触发,timer slow path 实际很少发生。
本 issue 的合法结果:
No-Go: timer path 很少/不是热点/候选收益不足 -> 记录证据,保留现实现
Go: timer coordination 已归因且达到门槛 -> 另开具体 implementation issue
当前 dev 必须先理解的实现
当前 RpcSession.SendPump:
- 一个 Channel,多 producer,单 reader pump;
- queue message 是 owned frame;
- frame bytes 通过
_queuedBytes + _maxQueuedBytes 做 hard byte admission;
- Channel 本身虽然是 unbounded message queue,但业务 bytes 有明确 hard bound;不要在本 issue 顺手重写 queue architecture;
LowLatency:每 frame flush;
Balanced:size threshold,max latency 为 0;
Throughput:当前约 64 KiB threshold + 1 ms max latency;
- custom flush options 可进入 timed batching;
- batch deadline 从 batch 第一个 frame 建立;
- force-flush / low-latency / size-threshold 可以在 timer 前 flush。
TimedBatch 队列 drain 后当前逻辑大致是:
waitToRead = _queue.Reader.WaitToReadAsync(sessionToken)
if completed synchronously:
return result
pendingRead = waitToRead.AsTask()
loop:
remaining = deadline - now
using delayCancellation = new CancellationTokenSource()
delayTask = Task.Delay(remaining, timeProvider, delayCancellation.Token)
winner = await Task.WhenAny(pendingRead, delayTask)
if data wins:
cancel delay
return pendingRead result
if timer wins:
flush batch
因此潜在成本至少包括:
- ValueTask -> Task 转换;
- per-wait CTS;
- Task.Delay timer object/task;
- Task.WhenAny coordination;
- cancel/dispose;
- timer callback/scheduler wakeup。
但这些只有进入 timed wait时才重要。
第一性原理
1. Batching 的价值来自少 flush,不来自 timer 本身
SendPump 的目标函数不是“最少 Task”,而是:
保持 frame 顺序 + bounded memory + bounded latency
同时减少昂贵 PipeWriter.FlushAsync 次数
如果移除 timer allocation 却增加 flush/s 或 queue delay,可能整体更慢。
2. 必须测“发生频率 × 单次成本”
一个 slow path 即使单次分配很多,只要每秒只触发几十次,对 100k RPC/s 可能无关紧要。
因此必须同时知道:
timed waits / sec
timer-fired flushes / sec
size flushes / sec
force flushes / sec
frames / batch
bytes / batch
3. 单 reader 和 frame order 是不可交换的不变量
任何 candidate 都不能让 timer callback 自己 flush PipeWriter,也不能让第二个 reader消费 frame。
只有 pump reader 能决定 frame write/flush 顺序。
4. Timer wakeup 必须属于一个 batch lifecycle
旧 callback 如果在 batch A 已经 size-flush 后才到达,绝不能唤醒 batch B。候选必须有 generation/epoch。
5. max latency 是从第一帧开始的 deadline
后续 frame 到达不能无限重置 max-latency timer,否则 throughput 模式可能永久不 flush。
6. 优化必须减少总成本
如果 reusable timer 省 allocation,但引入:
- 每 frame 额外 CAS;
- 第二个 channel;
- lock;
- cache line contention;
- more flushes;
必须按 end-to-end 数据决定,而不是按“allocation 少了”决定。
建议分支和 commit
测量分支:
feature/sendpump-timed-batching-evidence
推荐 commit:
bench: isolate send pump timed batching
perf-test: classify send pump flush reasons
bench: profile send pump timer coordination
perf-test: add formal send pump workload matrix
docs: record send pump timer go-no-go evidence
如果需要 throwaway prototype:
experiment: wake send pump with reusable timer generation
第 6 个 commit 不满足门槛就 revert,不要和 benchmark commit 混合。
Step 0:重新确认当前代码和行为
开始前:
git checkout dev
git pull --ff-only
git rev-parse HEAD
搜索并记录:
rg "class SendPump|WaitForMoreUntilDeadlineAsync|Task\.WhenAny|Task\.Delay|FlushAndReleaseAsync" src test
如果 dev 已经改成 reusable timer 或不再存在上述机制,停止按本文实现,先更新 issue 实际假设。
baseline:
dotnet build Sharplink.slnx -c Release -v minimal
dotnet test --project test/SharpLink.UnitTests/SharpLink.UnitTests.csproj -c Release
特别确认 SendPumpTests 当前全部通过。
Step 1:先建立不带 instrumentation 的隔离 benchmark
目标是让 timer 发生频率可控,而不是先改生产代码。
建议使用真实 RpcSession/SendPump 路径 + 可控 test PipeWriter。
PipeWriter 至少支持三种:
ImmediateFlushWriter FlushAsync 立即完成
ControlledSlowWriter 测试控制 FlushAsync 何时完成
CountingWriter 记录 flush count/bytes,不额外分配 hot path
不要在 benchmark 每 frame new TCS。Slow writer 的同步原语在 setup 预创建/复用。
必测 workload
Producer count
Frame payload
32 B / 256 B / 4 KiB / 16 KiB / 64 KiB
Profiles
LowLatency
Balanced
Throughput
custom TimedBatch
Arrival pattern
Continuous // queue 很少空,预期 size flush 主导
Bursty // burst 后 idle,timer 更可能触发
Sparse // 每个 frame 间隔 > batch delay
ThresholdEdge // batch bytes 刚好低于/达到 threshold
Force flush
记录:
- frames/s;
- bytes/s;
- flush/s;
- frames/flush;
- bytes/flush;
- producer enqueue latency;
- end-to-flush queue delay;
- allocated B/frame、B/batch;
- CPU;
- P50/P99 flush latency。
Step 2:分类 flush reason,但 instrumentation 不得污染正式 timing
需要知道每个 batch 为什么 flush:
Force
LowLatency
SizeThreshold
TimerDeadline
QueueCompleted/Shutdown
TransportTerminal
还需要:
timedWaitStarted
timedWaitDataWon
timedWaitTimerWon
pendingReadWasSync
推荐做法
可以在 evidence branch 加 temporary diagnostic counters/hooks,但:
- diagnostic run 用它统计频率;
- formal timing run 必须关闭或移除这些 counters;
- 不允许每 frame 加 process-global Interlocked 然后拿结果判断 2%-5% 性能。
如果需要在 production type 内加 hook,优先 nullable internal/test-only callback,在 null 时只有一个 predictable branch;最终实现 PR 前再决定是否删除。
更好的方案是能从 dedicated benchmark/test writer 推导的计数尽量放 benchmark 侧。
Step 3:Profiler 归因
选择至少一个真实 timer-heavy workload,例如:
Throughput
small frames
bursty/sparse producer
c32/c128
使用 allocation profiler/trace 回答:
CancellationTokenSource B/s;
Task.Delay/timer B/s;
Task.WhenAny B/s;
ValueTask.AsTask B/s;
- timer callback CPU;
- thread-pool scheduling/context switches;
PipeWriter.FlushAsync 本身 CPU/等待占比。
必须把 coordination 成本和真正的 FlushAsync 成本分开。
如果 FlushAsync 占绝大多数,而 coordination <1%-2%,大概率 No-Go。
Step 4:正式 macro matrix
#122 完成后,用 formal recorder 跑真实 RPC。
至少:
transport
平台稳定支持时补 UDS/NamedPipe;不需要为了矩阵完整在不稳定平台阻塞。
profile
LowLatency
Balanced
Throughput
payload
concurrency
1 / 32 / 128 / 512(机器能够稳定承载时)
输出
- QPS;
- CPU/op;
- alloc B/op;
- P50/P99/P99.9;
- flush/s;
- failures;
- send queue ResourceExhausted;
- process CPU/thread count/context switch(能稳定采集时)。
正式 base/head 同机交替 >=5 轮。
Go / No-Go 立项条件
Go
至少满足下面一种,并且 profiler 能把收益归因到 timed coordination:
- timer/CTS/WhenAny/AsTask coordination 占目标 workload CPU 或 allocation >=5%;
- throwaway reusable-timer prototype在目标 Throughput/TimedBatch workload带来稳定 >=5% throughput/CPU 改善;
- managed allocation有显著下降,并同时使真实 workload P99/CPU 有可重复改善。
同时必须满足:
- LowLatency 不稳定回退 >3%;
- Balanced 不稳定回退 >3%;
- frame/order/error count 完全正确。
No-Go
以下任一成立就停止:
- timed wait 很少进入;
- size/force flush 占绝大多数;
- coordination <目标总 CPU 约3%;
- prototype只改善 microbenchmark,真实 RPC在噪声内;
- allocation下降但 CPU/P99 不改善;
- candidate复杂度明显增加且收益 <5%。
No-Go 时保留 benchmark/evidence,不创建 implementation issue。
如果 Go:推荐的第一候选实现
只有 Go 后创建新 issue 再实现。下面是实现手册,不是本测量 issue 的必做项。
设计目标
消除每 batch:
CTS + Task.Delay + Task.WhenAny
同时保持:
- 一个 pump reader;
- 同一个 frame queue 顺序;
- timer callback不直接碰 PipeWriter;
- first-frame deadline;
- stale callback隔离。
推荐 candidate:pump-owned reusable ITimer + generation + same-queue control marker
比“timer callback直接 flush”更容易保持顺序。
概念上把 queue item 改为 internal struct union:
SendPumpMessage
Kind = Frame | BatchDeadline
Frame
Generation
正常 producer:
TryWrite(FrameMessage(frame))
batch 第一个 frame:
generation++;
- 保存
activeBatchGeneration;
- 用 pump 生命周期唯一
ITimer.Change(maxLatency) arm。
Timer callback:
capture armedGeneration
queue.Writer.TryWrite(BatchDeadline(armedGeneration))
Pump 单 reader读到 deadline marker:
if marker.Generation != activeBatchGeneration:
ignore stale marker
else if pending batch exists:
flush
为什么 same queue marker 合理
- timer callback不写 PipeWriter;
- frame 和 flush boundary 由同一个 reader决定;
- marker 前已成功进入 queue 的 frame自然进入旧 batch;
- marker 后的 frame进入下一 batch;
- generation处理“size flush后旧 timer callback迟到”。
具体实现可以不用这个方案,但替代方案必须同样证明无 stale wakeup/双 reader/order regression。
Candidate 的关键状态
至少明确:
ITimer _batchTimer
long _nextBatchGeneration
long _armedBatchGeneration
bool _timerArmed
谁创建:SendPump constructor。
谁拥有:SendPump。
何时 arm:TimedBatch 中 pending 从 0 -> 1。
何时 disarm:
- force flush;
- size flush;
- timer flush;
- queue completed;
- shutdown/fault。
何时 dispose:pump terminal finally,exactly once。
Timer callback只做非常小的事情:读取 generation + enqueue marker。不要在 callback 做 FlushAsync、list manipulation、buffer return、logging-heavy work。
必须解决的 stale callback 场景
写 deterministic test:
Batch A first frame -> arm generation 10
Batch A reaches size threshold -> disarm + flush
Batch B starts -> arm generation 11
旧 generation 10 callback 此时才执行
=> marker 10 必须被忽略,不能提前 flush B
仅调用 timer.Change(Infinite) 不够;已经排队的 callback 仍可能执行,所以必须有 generation。
Frame order correctness
测试 writer 捕获实际 wire bytes/frame IDs。
覆盖:
producer1 F1 F3
producer2 F2 F4
只要求顺序等于 SendPump queue 接受的顺序,不要求跨 producer有业务自定义顺序。
candidate 不得让 marker 自己出现在 wire 中。
Slow PipeWriter
当 pump 正在 FlushAsync:
- producer仍可按现有 byte capacity enqueue;
- timer callback可能发生;
- 不能第二次并发 FlushAsync;
- marker在 queue 中由 pump恢复后处理;
- stale generation应被忽略。
测试需要可控 FlushAsync barrier,而不是 sleep。
Shutdown / fault
覆盖:
- timer armed 时
Stop();
- timer callback与 Stop race;
- timer marker已经 queued 时 transport fault;
- FlushAsync fault;
- session cancellation;
- queue drain 后 timer dispose;
- callback不得在 disposed/reused pump lifecycle产生副作用。
当前 SendPump不是 pooled object,但 timer仍要有清晰 owner/terminal。
validation switch
初始 experiment 可使用 internal-only switch,例如:
SharpLink.Experimental.ReusableSendPumpTimer
默认 off,只用于同一 binary A/B 和 rollout 验证。
但最终实现 issue 合并前必须明确决定:
- correctness/perf 已稳定 -> 删除 experiment switch,仅保留新路径;或
- 确有短期 operational rollback需求 -> 保留 internal switch并记录删除期限/issue。
不要永久维护两个 batching state machine。
Candidate 性能验收
目标 workload:
- Throughput / custom TimedBatch throughput或CPU改善 >=5%;
- timed-wait allocation显著下降;
- LowLatency P99回退 <=3%;
- Balanced P99/throughput回退 <=3%;
- batch delay:第一 frame 到 flush 不应超过 configured max latency + 已明确的平台/scheduler预算;
- flush/s 不出现无法解释的增长;
- 所有 transport failures = 0。
立即回退条件
出现任一项直接 revert candidate:
- 一次 stale callback 提前 flush新 batch;
- frame 重排/丢失/重复;
- timer callback直接并发访问 PipeWriter;
- shutdown timer/task leak;
- send queue byte accounting underflow/overshoot;
- LowLatency P99连续两轮超过门槛并在交替测试可复现;
- Throughput收益不足立项目标;
- 为修 candidate 需要改 wire/protocol semantics。
本测量 issue 完成定义
目标
回答一个具体问题:
不能因为源码里出现
Task.Delay就直接优化。高吞吐时队列可能持续有数据,size threshold 可能先触发,timer slow path 实际很少发生。本 issue 的合法结果:
当前
dev必须先理解的实现当前
RpcSession.SendPump:_queuedBytes+_maxQueuedBytes做 hard byte admission;LowLatency:每 frame flush;Balanced:size threshold,max latency 为 0;Throughput:当前约 64 KiB threshold + 1 ms max latency;TimedBatch 队列 drain 后当前逻辑大致是:
因此潜在成本至少包括:
但这些只有进入 timed wait时才重要。
第一性原理
1. Batching 的价值来自少 flush,不来自 timer 本身
SendPump 的目标函数不是“最少 Task”,而是:
如果移除 timer allocation 却增加 flush/s 或 queue delay,可能整体更慢。
2. 必须测“发生频率 × 单次成本”
一个 slow path 即使单次分配很多,只要每秒只触发几十次,对 100k RPC/s 可能无关紧要。
因此必须同时知道:
3. 单 reader 和 frame order 是不可交换的不变量
任何 candidate 都不能让 timer callback 自己 flush
PipeWriter,也不能让第二个 reader消费 frame。只有 pump reader 能决定 frame write/flush 顺序。
4. Timer wakeup 必须属于一个 batch lifecycle
旧 callback 如果在 batch A 已经 size-flush 后才到达,绝不能唤醒 batch B。候选必须有 generation/epoch。
5. max latency 是从第一帧开始的 deadline
后续 frame 到达不能无限重置 max-latency timer,否则 throughput 模式可能永久不 flush。
6. 优化必须减少总成本
如果 reusable timer 省 allocation,但引入:
必须按 end-to-end 数据决定,而不是按“allocation 少了”决定。
建议分支和 commit
测量分支:
推荐 commit:
bench: isolate send pump timed batchingperf-test: classify send pump flush reasonsbench: profile send pump timer coordinationperf-test: add formal send pump workload matrixdocs: record send pump timer go-no-go evidence如果需要 throwaway prototype:
experiment: wake send pump with reusable timer generation第 6 个 commit 不满足门槛就 revert,不要和 benchmark commit 混合。
Step 0:重新确认当前代码和行为
开始前:
搜索并记录:
如果
dev已经改成 reusable timer 或不再存在上述机制,停止按本文实现,先更新 issue 实际假设。baseline:
dotnet build Sharplink.slnx -c Release -v minimal dotnet test --project test/SharpLink.UnitTests/SharpLink.UnitTests.csproj -c Release特别确认
SendPumpTests当前全部通过。Step 1:先建立不带 instrumentation 的隔离 benchmark
目标是让 timer 发生频率可控,而不是先改生产代码。
建议使用真实
RpcSession/SendPump 路径 + 可控 testPipeWriter。PipeWriter至少支持三种:不要在 benchmark 每 frame new TCS。Slow writer 的同步原语在 setup 预创建/复用。
必测 workload
Producer count
Frame payload
Profiles
Arrival pattern
Force flush
记录:
Step 2:分类 flush reason,但 instrumentation 不得污染正式 timing
需要知道每个 batch 为什么 flush:
还需要:
推荐做法
可以在 evidence branch 加 temporary diagnostic counters/hooks,但:
如果需要在 production type 内加 hook,优先 nullable internal/test-only callback,在 null 时只有一个 predictable branch;最终实现 PR 前再决定是否删除。
更好的方案是能从 dedicated benchmark/test writer 推导的计数尽量放 benchmark 侧。
Step 3:Profiler 归因
选择至少一个真实 timer-heavy workload,例如:
使用 allocation profiler/trace 回答:
CancellationTokenSourceB/s;Task.Delay/timer B/s;Task.WhenAnyB/s;ValueTask.AsTaskB/s;PipeWriter.FlushAsync本身 CPU/等待占比。必须把 coordination 成本和真正的 FlushAsync 成本分开。
如果 FlushAsync 占绝大多数,而 coordination <1%-2%,大概率 No-Go。
Step 4:正式 macro matrix
#122 完成后,用 formal recorder 跑真实 RPC。
至少:
transport
平台稳定支持时补 UDS/NamedPipe;不需要为了矩阵完整在不稳定平台阻塞。
profile
payload
concurrency
输出
正式 base/head 同机交替 >=5 轮。
Go / No-Go 立项条件
Go
至少满足下面一种,并且 profiler 能把收益归因到 timed coordination:
同时必须满足:
No-Go
以下任一成立就停止:
No-Go 时保留 benchmark/evidence,不创建 implementation issue。
如果 Go:推荐的第一候选实现
只有 Go 后创建新 issue 再实现。下面是实现手册,不是本测量 issue 的必做项。
设计目标
消除每 batch:
同时保持:
推荐 candidate:pump-owned reusable
ITimer+ generation + same-queue control marker比“timer callback直接 flush”更容易保持顺序。
概念上把 queue item 改为 internal struct union:
正常 producer:
batch 第一个 frame:
generation++;activeBatchGeneration;ITimer.Change(maxLatency)arm。Timer callback:
Pump 单 reader读到 deadline marker:
为什么 same queue marker 合理
具体实现可以不用这个方案,但替代方案必须同样证明无 stale wakeup/双 reader/order regression。
Candidate 的关键状态
至少明确:
谁创建:SendPump constructor。
谁拥有:SendPump。
何时 arm:TimedBatch 中 pending 从 0 -> 1。
何时 disarm:
何时 dispose:pump terminal finally,exactly once。
Timer callback只做非常小的事情:读取 generation + enqueue marker。不要在 callback 做 FlushAsync、list manipulation、buffer return、logging-heavy work。
必须解决的 stale callback 场景
写 deterministic test:
仅调用
timer.Change(Infinite)不够;已经排队的 callback 仍可能执行,所以必须有 generation。Frame order correctness
测试 writer 捕获实际 wire bytes/frame IDs。
覆盖:
只要求顺序等于 SendPump queue 接受的顺序,不要求跨 producer有业务自定义顺序。
candidate 不得让 marker 自己出现在 wire 中。
Slow PipeWriter
当 pump 正在
FlushAsync:测试需要可控
FlushAsyncbarrier,而不是 sleep。Shutdown / fault
覆盖:
Stop();当前 SendPump不是 pooled object,但 timer仍要有清晰 owner/terminal。
validation switch
初始 experiment 可使用 internal-only switch,例如:
默认 off,只用于同一 binary A/B 和 rollout 验证。
但最终实现 issue 合并前必须明确决定:
不要永久维护两个 batching state machine。
Candidate 性能验收
目标 workload:
立即回退条件
出现任一项直接 revert candidate:
本测量 issue 完成定义