Skip to content

[perf][flow-control] Reduce per-stream SendState/ReceiveState allocations #93

Description

@SunSi12138

目标:验证 per-stream state object 是否真是热点;按 Receive → Send 两阶段最小化优化

当前 SendState / ReceiveState 是 class,每个新 flow-controlled stream 会创建 state object。但“存在 new”不等于“值得优化”。

固定流程:先隔离 state-object allocation → 排除 Dictionary resize / waiter → 先做简单的 ReceiveState 实验 → 再做需要 lifecycle generation 的 SendState 实验 → 真实流压测 → 不达标撤回。

当前核对基线:main = 201b1621cc706ee221e10ac20947af5fc62b087e。执行时必须记录实际 git rev-parse HEAD


1. 已核对的当前实现

核心文件:

  • src/SharpLink.Runtime/StreamFlowController.cs
  • src/SharpLink.Runtime/RpcSession.cs
  • test/SharpLink.UnitTests/Runtime/StreamFlowControllerTests.cs
  • test/SharpLink.Benchmarks/RuntimeHotPathBenchmarks.cs
  • test/SharpLink.StreamLoadTest

当前 controller 持有:

Dictionary<StreamKey, SendState> _sendStates
Dictionary<StreamKey, ReceiveState> _receiveStates
LinkedList<CreditWaiter> _waiters

当前 state:

SendState class:
    long Credit
    bool Completed
    Exception? AbortException

ReceiveState class:
    long Credit
    long PendingConsumed
    bool Completed

SendState 生命周期

  • AcquireSendCreditAsync 第一次看到 key 时 AddSendStatenew SendState(_streamWindow)
  • state 可能在 stream completion 后作为 tombstone 留在 dictionary,直到最后 in-flight WindowUpdate 把 credit 恢复到 _streamWindow
  • _activeSendStreamCount_sendStates.Count 有不同语义:completed tombstone 不算 active,但仍占 negotiated stream capacity;
  • capacity 满且只是 tombstone pressure 时,最多保留一个等待 state-capacity 的 CreditWaiter

ReceiveState 生命周期

  • AcceptReceived 第一次看到 key 时 new ReceiveState(_streamWindow)
  • RecordConsumed 累计 per-stream / connection consumed credit;
  • stream threshold 或 connection threshold 时发 WindowUpdate;
  • FlushConsumed 把 state 标 completed,credit 全返后删除。

2. 一个必须先理解的关键 correctness 点:SendState 当前有“对象身份”语义

AcquireSendCreditAsync 在第一个 lock 内拿到 SendState? state,如果不能立即 reserve,会释放 lock 后调用:

AcquireContendedSendCreditAsync(key, expectedState, ...)

第二次拿 lock 时,如果同 key 已存在,会做:

expectedState != null && !ReferenceEquals(existingState, expectedState)
    => stream closed

这个 ReferenceEquals 不是多余的。

它防止以下 ABA-like lifecycle:

  1. 第一次 lookup 得到 stream lifecycle A 的 state;
  2. 释放 lock;
  3. A complete/remove;
  4. 同一个 (requestId, streamId) key 被新 lifecycle B 创建;
  5. 原调用重新拿 lock。

当前 class identity 能识别“key 相同,但已经不是同一个 state 生命周期”。

如果直接把 SendState 改成 struct 并删除这条 identity check,会改变并发语义。禁止这样做。


3. 第一性原理拆分假设

必须分别验证:

  • H1:短生命周期 receive stream 的 ReceiveState object allocation 是可观 B/stream 成本。
  • H2:短生命周期 send stream 的 SendState object allocation 是可观 B/stream 成本。
  • H3:把 state inline 到 Dictionary entry 后,节省的 heap allocation 大于更大的 Dictionary entry / value-copy / ref-mutation 成本。
  • H4:优化不能把无 contention fast path 变成 CollectionsMarshal 误用、额外 hash lookup 或锁内复杂度热点。

Receive 和 Send 必须分别做 candidate。不要一次性把两个 class 都改掉,然后无法归因回退。


4. Phase 0:baseline correctness

先执行:

dotnet build Sharplink.slnx -c Release -v minimal
dotnet test --project test/SharpLink.UnitTests/SharpLink.UnitTests.csproj -c Release
dotnet run -c Release --project test/SharpLink.IntegrationTests

必须保持 StreamFlowControllerTests 当前已有语义:

  • 1-byte window block/restore;
  • oversized item borrow once / exact repay;
  • waiter cancellation / connection close;
  • FIFO connection credit;
  • stream-credit blocked head 不阻塞其他 eligible stream;
  • receive half-window batching;
  • connection threshold flush 多 stream pending credit;
  • failed/completed send tombstone 接受 late WindowUpdate;
  • tombstone capacity backpressure;
  • pending replacement waiter bound;
  • ReturnUnsentCredit;
  • unknown WindowUpdate → ProtocolViolation;
  • AbortSendStreams;
  • rejected StreamComplete 释放 slot。

5. Phase 1:先证明 allocation 真来自 state object

扩展 FlowControlHotPathBenchmarks 或新增独立 benchmark class。

5.1 Send no-wait workload

固定场景:

create/use stream key
AcquireSendCreditAsync
ApplyWindowUpdate exact credit
CompleteSendStream

确保 credit 足够、_waiters.Count == 0,不要触发 CreditWaiter

参数:

  • items per stream:1 / 4 / 64
  • active streams:1 / 8 / 32 / 128
  • encoded bytes:固定小值,例如 32

5.2 Receive workload

AcceptReceived
RecordConsumed / threshold flush
FlushConsumed

同样覆盖 1 / 4 / 64 items1 / 8 / 32 / 128 streams

5.3 长生命周期控制组

一个 stream 处理大量 items,state 只创建一次。

如果 long-lived stream B/item 已接近 0,而 short stream B/stream 很高,才支持本 issue 的假设。

5.4 排除 Dictionary resize

第一次字典扩容会分配 entry/bucket arrays,不能把它算成 state object。

正式测量前:

  • 用足够多 stream warm-up controller/dictionary capacity;
  • 然后完成/remove states;
  • 再测新的 key;
  • profiler allocation stack 必须区分 new SendState/ReceiveStateDictionary.Resize

不要通过直接给 production dictionary EnsureCapacity(maxConcurrentStreams) 来“清理 benchmark”,那本身是另一个 memory tradeoff 实验。

5.5 排除 waiter path

baseline state-allocation benchmark 不允许 exhausted credit,因为 CreditWaiter 当前会创建:

  • CreditWaiter
  • TaskCompletionSource<bool>
  • LinkedListNode
  • cancellation registration / async Task path

这些不属于本 issue 第一阶段。


6. Phase 1 停止条件

如果 allocation stack 显示主要成本是:

  • Dictionary resize;
  • CreditWaiter
  • caller async state machine;
  • stream dispatcher;

SendState/ReceiveState 只占很小部分,则停止本 issue,不要为了两个 object 强改状态机。


7. Phase 2A:优先只转换 ReceiveState

ReceiveState 风险更低,因为它没有跨 lock 保存 object identity。

候选:

private struct ReceiveState
{
    public long Credit;
    public long PendingConsumed;
    public bool Completed;
}

7.1 最大陷阱:Dictionary.TryGetValue 返回 struct copy

当前 class 代码:

_tryGet -> state
state.Credit -= bytes

改成 struct 后如果保持同样写法,修改只发生在 local copy,dictionary 内状态不会更新。

禁止“只把 class 改 struct,编译过就算完成”。

7.2 推荐 mutation 规则

_gate 已经持有的情况下,可以使用 BCL CollectionsMarshal 获取 Dictionary value ref,但必须严格遵守 ref lifetime:

  • ref 只在持有 _gate 时使用;
  • ref 不得逃逸方法;
  • ref 存活期间 不能对同一个 dictionary 做 Add/Remove/Clear/可能 resize 的操作;
  • 需要 remove 时,先从 ref 读出 shouldRemove,结束 ref 使用,再 _receiveStates.Remove(key)

概念例:

lock gate:
    if key missing:
        capacity check
        add default/new state

    ref state = ref GetValueRef...
    mutate credit/pending
    shouldRemove = ...
    // stop using ref
    if shouldRemove: Remove(key)

7.3 TakePendingCredit

当前 helper 接收 class reference。改 struct 后必须是:

TakePendingCredit(ref ReceiveState state)

否则 pending count 清零只改 local copy。

7.4 FlushPendingConnectionCredit

当前方法 foreach _receiveStates 并直接 mutate pair.Value class。

struct 后不能 mutate foreach copy。推荐:

  1. 遍历 _receiveStates.Keys
  2. 对当前 key 使用 CollectionsMarshal.GetValueRefOrNullRef
  3. 只 mutate value,不在 ref 存活时改变 dictionary structure;
  4. completed keys 继续收集到当前已有的 List<StreamKey>? completed
  5. iteration 完成后统一 Remove。

不要在 foreach 里 _receiveStates[key] = state 并假设 Dictionary enumeration version 一定允许;不要依赖未明确约定的实现细节。

7.5 Phase 2A gate

Receive-only candidate 单独测:

  • B/receive-stream;
  • ns/item;
  • connection-threshold flush;
  • 128 active streams。

如果 allocation 降低很小或 CPU/entry footprint 回退明显,撤回 Receive struct,Send 不再继续。


8. Phase 2B:SendState struct 必须增加 lifecycle generation

只有 H2 成立且 Receive experiment 证明 inline state 值得做,才继续。

候选概念:

private struct SendState
{
    public long Credit;
    public long Generation;
    public bool Completed;
    public Exception? AbortException;
}

private long _nextSendStateGeneration;

generation 在 _gate 下创建新 SendState 时递增;只要求同一个 controller 生命周期内不会正常重复。

8.1 替代 ReferenceEquals

第一次 AcquireSendCreditAsync lookup/create 后,不再把 SendState? expectedState 传到 slow path,而是传:

expectedGeneration

约定:

  • 0 表示第一次 lock 没有绑定到已有/新 state(例如 tombstone capacity 满);
  • 非 0 表示调用已经观察到某个确切 lifecycle。

第二次 lock:

if key exists:
    if expectedGeneration != 0 && state.Generation != expectedGeneration:
        throw stream-closed
else:
    if expectedGeneration != 0:
        throw stream-closed
    if capacity available:
        create new state with new generation

这才等价于当前 class identity guard。

必须新增 deterministic test:在第一次 lookup 和 slow-path reacquire 之间 remove old state + recreate same key,旧 operation 必须拒绝,不能操作新 lifecycle。

8.2 所有 SendState mutation 都必须 ref-safe

至少核对这些方法:

  • AcquireSendCreditAsync
  • AcquireContendedSendCreditAsync
  • ApplyWindowUpdate
  • ReturnUnsentCredit
  • CompleteSendStream
  • AbortSendStreams
  • AddSendState(可能需要改成 add/ref helper)
  • Reserve
  • AdmitWaiters

Reserve 必须变成 ref SendState 语义;否则 credit 扣减会丢失。

8.3 Add/Remove 与 ref lifetime

例如 ApplyWindowUpdate

ref state = dictionary value
mutate
shouldRemove = state.Completed && state.Credit == streamWindow
stop using ref
if shouldRemove: Remove(key)
ready = AdmitWaiters()

不要持有 dictionary value ref 再调用 AdmitWaiters(),因为后者可能 Add 新 state 并触发 resize。

8.4 AbortSendStreams

当前 foreach pair.Value class 可直接 mutate;struct 后必须重写。

推荐:

  • 遍历 keys;
  • 对 matching requestId 获取 ref 并 mutate;
  • completed remove keys 收集到 list;
  • iteration 后 Remove;
  • 再处理 waiters;
  • AdmitWaiters()

不能边持 ref 边 remove。

8.5 Exception reference cleanup

SendState 即使是 struct,Dictionary entry 仍可能持有 AbortException reference。

必须保持:

  • state remove 后 exception 可释放;
  • controller Complete_sendStates.Clear()
  • aborted tombstone 的保留时长与当前一致;
  • 不为了减少 allocation 把 exception message/code 丢掉。

9. CollectionsMarshal 是候选实现工具,不是优化目标

不要因为 issue 文本提到它就强制使用。

如果简单 copy-update-writeback 能正确覆盖某个不在 enumeration 中的路径,可以先 benchmark;如果 extra hash lookup 可忽略,简单代码优先。

但以下路径需要特别小心:

  • foreach mutation;
  • Reserve 多次修改;
  • Add 后立即拿 state;
  • tombstone remove。

只有在 correctness 清晰时使用 CollectionsMarshal

如果 ref-safety 让代码变得难以审计,宁可撤回 struct 方案。性能优化不能降低状态机可验证性。


10. 备选方案只有在 struct 失败后再试

state object reuse/pool

不作为第一候选,因为它会引入:

  • state pool 自身同步;
  • AbortException reference cleanup;
  • send tombstone 生命周期;
  • ABA/reuse identity;
  • bounded retention。

如果 struct 因 Dictionary value size / copy cost 明显回退,且 state allocation 又确实是大热点,才单独设计 pool experiment。

不要在同一个 PR 同时 struct + pool + waiter optimization。


11. 必须新增的 correctness tests

ReceiveState

  1. first AcceptReceived 创建 state;
  2. exact stream credit exhaustion;
  3. connection credit exhaustion;
  4. RecordConsumed 小于 threshold;
  5. 达 stream threshold flush;
  6. 达 connection threshold 时 flush 多 stream;
  7. FlushConsumed 后 pending credit exactly once;
  8. completed + partial outstanding credit 保留 state;
  9. credit 全返后 remove;
  10. duplicate consumed/overflow → ProtocolViolation;
  11. 128 receive states 容量边界。

SendState

  1. first Acquire 创建 state + generation;
  2. fast reserve;
  3. credit exhaustion → waiter;
  4. WindowUpdate restore;
  5. duplicate/overflow WindowUpdate;
  6. CompleteSendStream with full credit → immediate remove;
  7. Complete with outstanding credit → tombstone;
  8. late WindowUpdate tombstone → remove;
  9. ReturnUnsentCredit exactly once;
  10. AbortSendStreams reclaim connection credit;
  11. abort + completion idempotence;
  12. max concurrent active limit;
  13. tombstone pressure + one replacement waiter;
  14. canceled replacement waiter slot reuse;
  15. shared connection credit across multiple streams;
  16. same key lifecycle ABA test:old expected generation 不能命中新 state;
  17. generation 不因 remove/re-add 被复用;
  18. Complete(exception) 清空 states/waiters。

并发

保留 stress,并增加 barrier 协调:

  • Acquire slow path 在 lock gap 中 Complete/Remove/Recreate same key;
  • ApplyWindowUpdate 与 CompleteSendStream;
  • AbortSendStreams 与 pending waiter;
  • receive RecordConsumed / FlushConsumed 顺序边界。

12. 性能门禁

baseline/candidate 同机交替至少 5 轮。

目标指标

  • B/stream
  • B/item
  • ns/stream
  • ns/item
  • throughput
  • CPU
  • lock contention
  • GC
  • Dictionary retained memory(至少观察 process heap/working set,避免 heap object 少了但 entry 变大很多)。

预先固定判定

  • short stream B/stream 必须看到能归因到 state object 的明显下降;
  • long-lived stream 不应有实质回退,因为它原本只分配一次 state;
  • 1 active stream fast path不应稳定回退 >2%;
  • 32/128 active stream throughput/lock contention 不应稳定恶化 >5%;
  • 如果 state allocation 降低,但 Dictionary 更大的 inline value 导致 CPU/cache 明显变差,撤回;
  • waiter-heavy workload 不要求本 issue 变快,但不能因 state refactor 明显变慢。

13. 真实 StreamLoadTest

至少:

  • c2s
  • s2c
  • duplex
  • 短 stream:1 / 4 / 16 items
  • 中等:64 / 256
  • 长 stream:1024+
  • concurrency:1 / 8 / 32 / 128
  • TCP + SharedMemory;平台允许时 UDS / NamedPipe。

短流是主要目标;长流是 regression control。

记录 P50/P95/P99、streams/s、items/s、allocation、GC、CPU、failures。


14. commit / rollback 顺序

推荐严格拆分:

  1. bench: isolate stream flow state allocations
  2. perf: inline receive flow state
  3. test: cover receive struct mutation paths
  4. perf: add send state lifecycle generation
  5. perf: inline send flow state
  6. test: cover send lifecycle ABA and tombstones

不要把 Receive + Send 一次提交。

任何阶段:

  • allocation 没按预期下降;
  • deterministic test 失败;
  • same-key lifecycle guard 无法等价;
  • contention/cache 回退超门槛;

git revert 对应 candidate。

不允许通过删 tombstone test、放宽 unknown WindowUpdate、取消 generation check 来救性能数字。


15. 最终验收

只有数据证明有价值的部分需要合并。最终必须满足:

  • allocation stack 已证明 state object 是热点;
  • Dictionary resize / waiter 已排除;
  • ReceiveState mutation 全部持久化,无 struct-copy bug;
  • SendState 保留当前 object identity 等价语义,通过 generation 防 same-key lifecycle ABA;
  • stream/connection 双重 credit 不变;
  • completed tombstone 不变;
  • late WindowUpdate 不变;
  • AbortSendStreams 不变;
  • ReturnUnsentCredit 不变;
  • max stream/tombstone pressure 不变;
  • receive threshold / connection threshold flush 不变;
  • ProtocolViolation/ResourceExhausted/ConnectionClosed 分类不变;
  • Release unit/integration + NativeAOT smoke 通过;
  • 短流真实 workload 有稳定收益;
  • 长流和高并发无实质回退。

如果最终只是“少了两个 object”,但总 CPU/latency/内存没有变好:撤回,保留 class state。

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