Skip to content

[perf][sendpump] 证明 timed batching 的 timer coordination 是否值得优化 #125

Description

@SunSi12138

总跟踪:#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:

  1. bench: isolate send pump timed batching
  2. perf-test: classify send pump flush reasons
  3. bench: profile send pump timer coordination
  4. perf-test: add formal send pump workload matrix
  5. docs: record send pump timer go-no-go evidence

如果需要 throwaway prototype:

  1. 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

1 / 4 / 16 / 64

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

0%
1%
100%

记录:

  • 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

TCP
SharedMemory

平台稳定支持时补 UDS/NamedPipe;不需要为了矩阵完整在不稳定平台阻塞。

profile

LowLatency
Balanced
Throughput

payload

small unary
4 KiB
64 KiB

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:

  1. timer/CTS/WhenAny/AsTask coordination 占目标 workload CPU 或 allocation >=5%;
  2. throwaway reusable-timer prototype在目标 Throughput/TimedBatch workload带来稳定 >=5% throughput/CPU 改善;
  3. 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:

  1. generation++
  2. 保存 activeBatchGeneration
  3. 用 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

覆盖:

  1. timer armed 时 Stop()
  2. timer callback与 Stop race;
  3. timer marker已经 queued 时 transport fault;
  4. FlushAsync fault;
  5. session cancellation;
  6. queue drain 后 timer dispose;
  7. 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 完成定义

  • timed-wait发生频率已量化。
  • flush reason已分类,正式 timing不带污染性 counters。
  • timer/CTS/WhenAny/AsTask allocation/CPU已 profiler归因。
  • Continuous/Bursty/Sparse/ThresholdEdge 都有证据。
  • LowLatency/Balanced/Throughput 都有 control。
  • [perf][tooling] 修复负载测试延迟记录干扰并建立可信性能基线 #122 formal macro matrix完成。
  • 明确 Go 或 No-Go。
  • No-Go:记录停止原因并关闭,不建实现 issue。
  • Go:另开具体 reusable timer实现 issue,复制本文 generation/order/shutdown/rollback门禁。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions