Repository navigation
NO-GO (closed without merge): specialize simple 1:1 Unary logical lifetime (#741) - #750
SunSi12138 wants to merge 24 commits into
Conversation
… wrapper A plain non-retryable unary call currently keeps its logical RPC lifetime active with an `async` wrapper (`AwaitLogicalInvocationAsync`) whose only job is to run `Interlocked.Decrement(ref _activeLogicalInvocations)` in a `finally`. Every truly suspended logical call therefore allocates one compiler-generated state-machine box. Measured on dev 56c643c (TCP and SharedMemory, LoadTest, interleaved reps): the wrapper costs a constant **136 B per logical call**, independent of concurrency and transport, and its share of total per-RPC allocation grows with concurrency (TCP 15.9% at c1 -> 37.7% at c128; SharedMemory 10.4% -> 37.5%) because every other cost amortises while this one does not. The cost is per logical call, not per physical attempt (136 B at 1, 2 and 3 attempts). Sync-completed, already-faulted and already-cancelled invocations cost 0, and plain OneWay costs 0 because its ValueTask completes synchronously. This change specialises only the statically known 1:1 shape - plain unary, no telemetry scope, no interceptor pipeline, no endpoint admission policy, and no enabled retry generation. For that shape the release rides the pending table's already-existing exactly-once completion hook (IPendingCallCompletionObserver), so no object is created at all. Every other shape keeps the existing wrapper unchanged, including the whole retry path. Measured on the PR branch vs a clean dev build (6 interleaved reps): c B/op dev -> PR QPS p99 p999 Gen0 1 856.1 -> 720.2 (-15.9%) -2.2% +1.6% +2.5% 17 -> 14 32 377.4 -> 241.1 (-36.1%) +2.6% -4.0% -54.7% 142 -> 92 128 361.4 -> 225.0 (-37.7%) +2.5% -8.1% -13.1% 270 -> 172 Allocation, Gen0 and tail latency improve repeatably; the QPS delta is within noise at 6 reps (p ~ 0.20) and is not claimed as significant. Correctness: the full unit suite passes with the specialisation active (1854/1854), including the drain and retry guardrails. Exactly-once release is argued from the registered/not-registered boundary rather than `ValueTask.IsCompleted`, which is unsound here because an attempt can complete (and fire its observer) while registration is still in flight. CompleteRegistrationIfDisposed is additionally made non-throwing: the call is already published at that point, so its terminal transition cannot be rolled back, and a throwing terminal observer must not surface as a registration failure that a caller would compensate for by releasing a second time. Refs #741
SunSi12138
left a comment
There was a problem hiding this comment.
Review result: changes needed.
The performance evidence for the static 1:1 specialization is convincing enough to keep pursuing this direction, but the current ownership transfer is not yet exactly-once.
Two correctness issues are blocking: (1) attemptRegistered is set only after PendingCalls.Rent() returns even though the pending slot/observer ownership transfers earlier, so a post-publication registration failure can release the logical counter twice; and (2) the shape observer decrements the logical count before the returned operation is made terminal, which breaks the existing ISharpLinkClientDrainInspector contract used by multi-cluster graceful retirement.
CompleteRegistrationIfDisposed also catches the whole authoritative TryComplete() transition. That suppresses failures from operation completion, owner cleanup, pool/capacity accounting, etc., not just completion-observer diagnostics; please isolate only the specific callback that is allowed to fail.
Current validation is also incomplete: PR Fast run 36089835555 fails formatting because PendingRequestTable.cs and SharpLinkClient.Invokers.cs gained a BOM, so build/unit/generator/load-test steps never ran. The branch is also behind current dev; after the ownership fixes, remove the BOMs, update to current dev, add the dedicated specialized-path/drain/registration-race tests listed in the PR body, and rerun the current validation matrix.
| // Kept as a no-op so this signal can never release a logical invocation. | ||
| } | ||
|
|
||
| public void OnPendingCallCompleted(in PendingCallCompletion completion) |
There was a problem hiding this comment.
[P2] Releasing here happens before the caller's ValueTask is made terminal. PendingRequestTable.CompleteTakenCall invokes the completion observer before RpcRequestOperation.CompleteResponse/SetError.
_activeLogicalInvocations is the ISharpLinkClientDrainInspector.ActiveCallCount used by multi-cluster replace/remove graceful drain, and the existing ownership contract defines it through completion of the user-visible ValueTask. With this ordering there is a real window where the count is 0, retirement starts StopAsync(), and the caller's unary ValueTask is still pending.
Please keep the zero-allocation specialization but move this release to a boundary after the operation has entered its terminal state. RpcRequestOperation already uses RunContinuationsAsynchronously = true, so publishing the operation result first does not require synchronously running user continuation code. Add a deterministic drain regression that pauses between the two terminal steps.
There was a problem hiding this comment.
Fixed by moving only the shape observer behind the operation terminal publication: IPendingCallPostOperationObserver runs after RpcRequestOperation.CompleteResponse/SetError, while admission/attempt observers retain prior ordering. DrainInspectorMustNotReachZeroBeforeUnaryOperationBecomesTerminal pauses in the post-operation hook, awaits the user-visible unary result while the hook remains blocked, asserts the drain counter is still one, then releases the hook and checks a single transition to zero.
Integrate latest dev into #750; transfer observer and admission ownership at slot CAS, defer shape release until the unary operation is terminal, and restore authoritative completion errors. Cover normal, failed, cancelled, timed registration and paused-drain paths.
…nability LOC budgets Extract synchronous Rent overloads into a focused partial file without changing publication ownership; remove redundant invoker comments.
… telemetry listeners
…e owner callback failures
…ning Unary operation Finalize the registration barrier before cleanup even if the owner throws, and use TryComplete for timer/owner registration errors without swallowing authoritative terminal exceptions.
…ing pending table debt Keep the registration barrier, failure cleanup and timer exception boundary together in a focused partial class below existing LOC allowance.
…tiCluster graceful retirement
… terminal-error propagation
…zed Unary owner Terminalize post-publication registration errors and return the failed pooled Unary operation to its caller. Preserve stream and authoritative owner/Dispose exceptions, signal pre-MarkRegistered waiters, and isolate registration handling in its own partial class.
…-registration faults
… authoritative cleanup
…nup without replaying failing clock Protect cancellation/Dispose race against pooled PendingCall reuse and avoid another TimeProvider.GetTimestamp exception while terminalizing the orphaned operation.
…ows again during cleanup
…d to immutable request ID A terminal call may return its pooled PendingCall after MarkRegistered, so use the saved id, deadline and cancellation token rather than reading mutable pooled fields.
Incorporate #753's transport/read-state work without changing the focused client pending-registration repair.
|
Follow-up on the post-publication registration-failure / graceful-drain P2 (reported after the initial Ready-for-review transition): Fixed in the current branch (head
Regression coverage: Merged the newest |
Measure plain idempotent Unary and enabled/forced/exhausted retry over TCP and SharedMemory on exact dev and PR sources. Pin equal benchmark code, balance AB/BA execution, record physical attempts and tail latency/CPU/allocations, and include A/A noise controls.
…rver construction ReplaceService is a builder-only API; inject the same opt-in test service before Build for both TCP and SharedMemory, then run equal source on base and candidate.
… JSON metadata Build native probes without BDN or unrelated reflection-heavy runners. Keep the same generated Add RPC, retry injection, metrics and source on exact base/head, with source-generated JSON output.
#741 corrected-head full-RPC performance evidence — executedRun: https://github.com/SunSi12138/SharpLink/actions/runs/38033967753 (JIT PASS, NativeAOT PASS) Both revisions used an identical-source generated Unary RPC harness on the same runner per mode, with balanced AB/BA order and candidate-vs-candidate A/A controls. Actual retry work was verified (1/2/3 physical attempts for first-success / forced-once / exhausted). The raw metrics and pairing files were uploaded as Actions artifacts. Repeatable allocation win on the corrected client: plain TCP Unary saves 135–137 B/op in JIT and 136 B/op in NativeAOT; SharedMemory c128 saves 134 B/op JIT / 135 B/op NativeAOT. Retry-first and forced-retry scenarios have approximately zero allocation delta. Do not merge on QPS evidence alone: the hosted-runner throughput and far-tail results are noisy (A/A throughput noise up to roughly 4.8%). JIT plain TCP c32 gave −3.2% QPS / +6.5% CPU, retry-first c128 −4.8% QPS / +4.8% CPU, and plain TCP c128 had a large P999 outlier. These differ from NativeAOT and other JIT concurrency results; they require targeted independent replication before asserting the strict 2–3% retry non-regression or a significant throughput gain. I updated the PR description with the JIT/NativeAOT table and marked the execution of both outstanding performance suites complete, while leaving a separate performance sign-off item open for those noisy regressions. |
…rypoint Restore BenchmarkDotNet Program.cs to avoid injecting a new, unpinned runner reference into unrelated base-worktree A/B workflows. Build the same minimal dedicated Issue741 harness with PublishAot disabled for JIT and enabled for NativeAOT.
FINAL DISPOSITION (2026-10-10): NO-GO — CLOSED WITHOUT MERGE.
The static 1:1 Unary logical async-wrapper specialization in this PR is not being adopted. The ~134–139 B/op reduction for eligible plain suspended Unary calls is real and repeatable in JIT/NativeAOT; however, it applies only to a narrow call shape. The implementation materially expands PendingCall registration/completion ownership, timer-failure and graceful-drain handling. The corresponding full-RPC throughput, CPU and P999 evidence is noisy and mixed, and the risk/maintenance cost outweighs the demonstrated whole-workload benefit.
Final decision: keep the existing production
AwaitLogicalInvocationAsyncwrapper and do not merge this PR. See #741's final No-Go assessment for detailed reasons. The A/B experiments, source and race-regression tests are retained in this closed PR as research, not as proposed changes todev.The following content preserves the previous exploratory evidence and implementation notes. Earlier
ALLOCATION GO,MERGE HOLD, checklists and prior review-ready wording are superseded by this final No-Go decision. Running further benchmarks is not a prerequisite for closing this implementation.Historical review-phase introduction (superseded by the final No-Go above). The corrected-head JIT and NativeAOT A/B data below remain valid as allocation evidence, but not sufficient justification to merge.
Latest performance conclusion — corrected implementation (2026-10-10)
Measured outcome: ordinary suspended Unary saves about 134–139 managed B/op in BOTH JIT and NativeAOT, consistent with removal of the 136 B logical async wrapper. The retry-enabled full-RPC test was also executed, including first-attempt success, exactly one forced retry, backoff and exhausted retry; no matching 136 B allocation penalty was found on those fallback paths. The benchmark validates physical attempt counts, not merely enabled retry settings.
Historical per-dimension assessment (not the final merge decision):
Evidence: #741 balanced full-RPC JIT/NativeAOT A/B, both PASS (6 JIT pairs, 4 NativeAOT pairs, A/A controls; raw JSON, paired tables and build artifacts), and performance interpretation in PR discussion.
The published A/B measurements compare base
eb99fe887cf2129d9b88441245ca0a4a6406b6c2with measured headfecd0eef645d940ec8311af16a7f2deb496c7151. Current PR head77a087817fd12323efb1d73f1589f51bdd80cacaonly isolates the benchmark runner to restore other CI workflows; it does not change the measured client implementation. Exact-head CI/A/B is rerunning after that harness-only change; retain this provenance until its new run completes. Full metric table, limitations and historical measurements appear below.Scope
For a plain Unary call with one physical attempt, no telemetry scope, no interceptors and no endpoint-admission observer, reuse a cached per-client completion observer instead of allocating an outer
AwaitLogicalInvocationAsyncstate-machine box for each suspended call. Retry-capable paths, telemetry, interceptors, admission, OneWay, ClientStreaming and streaming enumerators retain their prior logical-call wrapper/ownership.Correctness changes from review (2026-10-10)
PendingRequestTable.RentpropagatesregistrationPublishedfrom the successful slot CAS, beforeOnRegisteredcan throw (e.g. from a custom deadline timer). The caller refunds its admission reservation and decrements the logical owner inline only when publication never occurred. A published call retains exactly-one terminal ownership even whenRentthrows afterwards.IPendingCallPostOperationObserverfires only afterRpcRequestOperation.CompleteResponse/SetErrorhas entered its terminal state. Existing attempt/admission observers keep their prior ordering. The shape observer does not release the drain count while the user's UnaryValueTaskis still pending.CompleteRegistrationIfDisposed'sTryComplete. A terminal-owner cleanup/invariant exception must remain visible; capacity release is still protected by the table's existing completionfinally/error path.devintegrated. The branch is up to date againstdevand the change was kept within existing maintainability LOC budgets by extracting the synchronousRentoverloads to a partial file. UTF-8 BOM regressions were also removed.Follow-up P2: published registration failure and graceful retirement
The first ownership fix prevented double release, but a failing timer/owner registration still left a CAS-published pending slot attached to an already failed logical call until the connection stopped. This follow-up uses the pending table's authoritative terminal transition in the exceptional registration path, before returning a failed Unary operation.
OnRegisteredalways signalsMarkRegisteredfromfinally, even when the pre-barrier owner callback throws; cancellation/Dispose may therefore wait safely for the registration barrier.CompleteFailedRegistrationclaims the same slot by its captured immutable request ID, not a potentially pooled/reusedPendingCall.Id, and completes throughCompleteTakenCall. It does not resample an application-providedTimeProviderthat may already be throwing.ValueTask, so its post-operation observer releases logical ownership promptly and the operation is reclaimed when observed.CompleteRegistrationIfDisposed, or a broken terminal owner are not converted into successful registration.Deterministic regressions now cover pre-/post-
MarkRegisteredfailures, a timer that also poisons later timestamp reads, concurrent Dispose waiting for the registration barrier, terminal-owner exceptions, streaming failure propagation, and a realMultiCluster.RemoveClusterAsyncretirement with an 8 s grace period that must finish within 2 s rather than wait out the grace period.Focused regressions
SharpLinkClientLogicalShapeTests: single-attempt success / remote fault / user cancellation; suspended-call drain visibility and deterministic pause after result publication but before logical decrement; a deadline timer throwing after CAS; no double release on later stop. Tests explicitly invoke the specialization to be independent of process-wide telemetry listeners.PendingRequestTablePostOperationObserverTests: post-operation observer sees terminal success/failure; a deterministic Dispose vs published-registration race with a throwing terminal owner verifies that cleanup failure is propagated, the terminal owner is called once and capacity/slots are released.Validation provenance
fecd0eef645d: [NO-GO / Performance] Remove client logical-invocation async wrapper only when lifecycle and throughput evidence justify it #741 JIT and NativeAOT full-RPC A/B both completed successfully (run 38033967753); PR Fast, CodeQL and pending/codec also passed.77a087817fd12323efb1d73f1589f51bdd80caca: restored the shared BenchmarkDotNet entrypoint and moved the [NO-GO / Performance] Remove client logical-invocation async wrapper only when lifecycle and throughput evidence justify it #741 JIT probe to the dedicated AOT-compatible project so existing performance workflows can build their exact-base controls. This changes the test harness / CI wiring only, not client hot-path code. Current-head workflows have been triggered again; use PR checks for the definitive latest status.Full #741 JIT/NativeAOT measurements — measured head fecd0ee (2026-10-10)
eb99fe887cf2129d9b88441245ca0a4a6406b6c2; exact candidate:fecd0eef645d940ec8311af16a7f2deb496c7151.Interpretation. Across both modes the ordinary Unary specialization saves about 134–139 managed B/op with no corresponding allocation increase in the retry-first/forced-retry paths. Throughput and tail-latency deltas are substantially noisier: same-binary A/A QPS controls registered approximately −3.6% (JIT retry-once c32) and +4.8% (NativeAOT plain c32). Do not claim a statistically significant QPS gain or blanket non-regression from a green evidence workflow. Specifically, JIT TCP plain c32 showed −3.2% QPS/+6.5% CPU, JIT retry-first c128 showed −4.8% QPS/+4.8% CPU, and JIT TCP plain c128 P999 showed +251.6% in the short hosted-runner samples. These outliers merit independent, targeted replication before performance merge approval.
Historical performance motivation (original candidate on dev 56c643c, Apple M4)
Wrapper cost is a constant 136 B per logical call, independent of concurrency and transport, and its allocation share grows with concurrency:
Per logical call, not per attempt (136 B at 1, 2 and 3 attempts). 0 B for sync-completed / already-faulted / already-cancelled invocations, and 0 B for plain OneWay.
PR branch vs a clean dev build, 6 interleaved reps:
Allocation, Gen0 and tail latency improve repeatably. The QPS delta is within noise at 6 reps (p ≈ 0.20) and is not claimed as significant.
The measurements above predate the review fixes and the integration of the newer
devbase. They explain why the specialization was attempted; they are not a fresh throughput/latency result for the current PR head. No statistically significant QPS improvement is claimed.Historical review checklist (superseded; no further work on this candidate)
TryCompleteexception suppression.Changed files
src/SharpLink.Client/SharpLinkClient.LogicalScope.cssrc/SharpLink.Client/SharpLinkClient.Invokers.cssrc/SharpLink.Client/PendingRequestTable.cssrc/SharpLink.Client/PendingRequestTable.Rent.cssrc/SharpLink.Client/PendingRequestTable.Registration.cstest/SharpLink.UnitTests/Client/SharpLinkClientLogicalShapeTests.cstest/SharpLink.UnitTests/Runtime/PendingRequestTablePostOperationObserverTests.csFINAL DISPOSITION: NO-GO. This PR is closed without merge; existing logical lifetime wrapper remains the production implementation.