Repository navigation
perf(abstractions): shrink RpcMethodDescriptor from 40 to 32 bytes - #752
Conversation
`RpcMethodDescriptor` is a `readonly record struct` passed **by value** through every
client invocation boundary — the generated proxy, `IRpcChannel`, the call-control
preamble, the retry selector and the unary/streaming cores. It measured 40 bytes.
The width came from `TimeSpan? MethodTimeout`, whose `Nullable<TimeSpan>` form occupies
16 bytes (8-byte value + presence + padding) and pushed the struct past the 32-byte
boundary:
long ContractId + long MethodId + Nullable<TimeSpan> + int + byte + byte = 38 -> 40
Storing the timeout as a tick count with a negative sentinel removes 8 bytes:
long ContractId + long MethodId + long ticks + int + byte + byte = 30 -> 32
The public surface is unchanged: `MethodTimeout` still reads and writes `TimeSpan?`, and
record equality is preserved because the tick field is a 1:1 encoding of the property.
One non-obvious constraint is documented inline: the backing field must be **declared**
ahead of the `int ClientStreamCount` and the `byte` members. The struct uses sequential
layout, so declaring the `long` after them re-introduces the padding and the descriptor
stays at 40 bytes no matter how the timeout is stored.
This is a layout/ABI-tightening change. Measured on the issue #734 ablation, the 40 -> 32
transition produced no resolvable throughput change (the by-value cost is dominated by the
call and interface dispatch rather than the copy width), so it is proposed for descriptor
compactness and not as a speed-up.
Validation:
- `Unsafe.SizeOf<RpcMethodDescriptor>() == 32` (was 40).
- `SharpLink.UnitTests`: 1854/1854 pass with only this change applied on top of `dev`,
including `RpcMethodDescriptorTests`, which pins flag packing, both `Deconstruct`
shapes and a `with { HasClientStreams = false }` record copy.
Refs #734
SunSi12138
left a comment
There was a problem hiding this comment.
Review result: changes needed.
The 40 -> 32 byte layout goal looks viable, but the current negative-sentinel encoding is not semantically equivalent to TimeSpan?.
In particular, default(RpcMethodDescriptor) now exposes MethodTimeout == TimeSpan.Zero because the backing field defaults to 0, whereas the existing public type exposes null. Negative TimeSpan values are also collapsed to null, which bypasses the existing client-side invalid-timeout validation and changes record equality semantics.
This can still be kept at 32 bytes: use one spare _flags bit as the nullable presence bit and keep the raw ticks in _methodTimeoutTicks. That preserves default, null/zero/negative/positive values and equality without adding width.
Please also add regression coverage for the exact 32-byte layout plus default/null/zero/negative/positive round-trips and equality. The current test only asserts Unsafe.SizeOf<RpcMethodDescriptor>() <= 48; the PR currently contains no test change matching the == 32 / Marshal.SizeOf == 32 claims.
Finally, this PR is still based on 56c643cd...; current dev is 667baa79..., so the branch is now 17 commits behind. The existing PR Fast / Package Smoke / CodeQL results are the original September runs. After fixing the encoding, please update to current dev and rerun the current validation matrix.
Use a spare flag bit for MethodTimeout presence and retain signed raw ticks. Add exact 32-byte layout, default, signed timeout, and record equality tests. Merge current dev into PR #752 to refresh validation.
|
P3 follow-up addressed in |
Refs #734
What changes
RpcMethodDescriptordrops from 40 to 32 bytes by storing rawMethodTimeoutticks in alongand using a spare_flagsbit for nullable presence instead ofNullable<TimeSpan>.Why the width matters
The descriptor is passed by value through every client invocation boundary: the generated proxy,
IRpcChannel,ResolveCallControlForInvocation,InvokeUnaryWithOptionalRetryAsync, the retry attempt, and the unary / client-streaming / streaming cores.The non-obvious part
The backing field must be declared ahead of the
int ClientStreamCountandbytemembers. The struct uses sequential layout, so declaring thelongafter them re-introduces the padding and the descriptor stays at 40 bytes regardless of how the timeout is stored. That constraint is documented inline; without it the change looks like it does nothing.Compatibility
MethodTimeoutstill reads and writesTimeSpan?; the property accessors keep their signatures, so this is binary compatible.nullfrom zero, while null canonicalizes ticks to zero. The compiler-generatedEquals/GetHashCoderemain consistent with the originalTimeSpan?values.default(RpcMethodDescriptor)still hasMethodTimeout == null;null, zero, positive, and negativeTimeSpanvalues round-trip without loss. The parameterless-[Timeout]case (HasMethodTimeout == true,MethodTimeout == null) is unaffected._flagsbits before assigningMethodTimeout, allowing theinitaccessor to preserve the presence bit.Honest scope note
This is proposed as an ABI/layout tightening, not a speed-up. On the issue #734 ablation, the 40 → 32 transition produced no resolvable throughput change (+0.35 ns/call, i.e. inside the noise): the by-value cost is dominated by the call and interface dispatch rather than the copy width. Reviewers who want measured wins only should take #751 instead.
Validation
RpcMethodDescriptorTestsnow asserts exactUnsafe.SizeOf<RpcMethodDescriptor>() == 32andMarshal.SizeOf<RpcMethodDescriptor>() == 32(previously only<= 48).TimeSpan.MinValue/MaxValue) round-trip regression cases; both constructor andwithinitializer paths, record equality/hash code, and other flag bits are checked.CombinedWithFlagUpdatesMustPreserveNonNullMethodTimeoutto cover combinedwithupdates of other_flagsbits while a positive or negativeMethodTimeoutstays non-null and unchanged; the original combinedwithtest now directly assertschanged.MethodTimeout == timeout.devat072a3a13fca0aa9186d2db44e38e5fc846969b07indd80dfc4c8d207a4e4d2a6b40b0c20792a3f4903, with a test-only follow-up at75e1908aff9538241e5e09e06c347dde010ffc8f. PR Fast, PR Extended, PR Package Smoke, and CodeQL passed ondd80dfc4; PR Fast, PR Package Smoke, and CodeQL were automatically retriggered for the follow-up.