Skip to content

perf(abstractions): shrink RpcMethodDescriptor from 40 to 32 bytes - #752

Merged
SunSi12138 merged 3 commits into
devfrom
perf/734-compact-rpc-method-descriptor
Oct 9, 2026
Merged

SunSi12138 merged 3 commits into
devfrom
perf/734-compact-rpc-method-descriptor

Conversation

@SunSi12138

@SunSi12138 SunSi12138 commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Refs #734

What changes

RpcMethodDescriptor drops from 40 to 32 bytes by storing raw MethodTimeout ticks in a long and using a spare _flags bit for nullable presence instead of Nullable<TimeSpan>.

// before: Nullable<TimeSpan> = 16 bytes, struct padded to 40
public TimeSpan? MethodTimeout { get; init; }

// after: signed ticks + nullable presence bit in an existing byte, struct fits in 32
private const byte HasMethodTimeoutValueFlag = 1 << 5;
private readonly long _methodTimeoutTicks;

public TimeSpan? MethodTimeout
{
    get => (_flags & HasMethodTimeoutValueFlag) != 0
        ? TimeSpan.FromTicks(_methodTimeoutTicks) : null;
    init
    {
        _methodTimeoutTicks = value.GetValueOrDefault().Ticks;
        _flags = SetFlag(_flags, HasMethodTimeoutValueFlag, value.HasValue);
    }
}

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.

long ContractId + long MethodId + Nullable<TimeSpan> + int + byte + byte = 38 -> 40
long ContractId + long MethodId + long ticks        + int + byte + byte = 30 -> 32

The non-obvious part

The backing field must be declared ahead of the int ClientStreamCount and byte members. The struct uses sequential layout, so declaring the long after 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

  • Public surface unchanged. MethodTimeout still reads and writes TimeSpan?; the property accessors keep their signatures, so this is binary compatible.
  • Record equality preserved. The presence bit distinguishes null from zero, while null canonicalizes ticks to zero. The compiler-generated Equals / GetHashCode remain consistent with the original TimeSpan? values.
  • default(RpcMethodDescriptor) still has MethodTimeout == null; null, zero, positive, and negative TimeSpan values round-trip without loss. The parameterless-[Timeout] case (HasMethodTimeout == true, MethodTimeout == null) is unaffected.
  • The constructor initializes the other _flags bits before assigning MethodTimeout, allowing the init accessor 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

  • RpcMethodDescriptorTests now asserts exact Unsafe.SizeOf<RpcMethodDescriptor>() == 32 and Marshal.SizeOf<RpcMethodDescriptor>() == 32 (previously only <= 48).
  • Added default/null/zero/negative/positive (including TimeSpan.MinValue/MaxValue) round-trip regression cases; both constructor and with initializer paths, record equality/hash code, and other flag bits are checked.
  • Added CombinedWithFlagUpdatesMustPreserveNonNullMethodTimeout to cover combined with updates of other _flags bits while a positive or negative MethodTimeout stays non-null and unchanged; the original combined with test now directly asserts changed.MethodTimeout == timeout.
  • Merged updated dev at 072a3a13fca0aa9186d2db44e38e5fc846969b07 in dd80dfc4c8d207a4e4d2a6b40b0c20792a3f4903, with a test-only follow-up at 75e1908aff9538241e5e09e06c347dde010ffc8f. PR Fast, PR Extended, PR Package Smoke, and CodeQL passed on dd80dfc4; PR Fast, PR Package Smoke, and CodeQL were automatically retriggered for the follow-up.

`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 SunSi12138 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/SharpLink.Abstractions/IRpcChannel.cs Outdated
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.
@SunSi12138
SunSi12138 marked this pull request as draft October 9, 2026 04:43
@SunSi12138
SunSi12138 marked this pull request as ready for review October 9, 2026 04:43

Copy link
Copy Markdown
Owner Author

P3 follow-up addressed in 75e1908aff95 (test-only): the existing combined with { ResponseNullable = false, HasClientStreams = false } test now asserts changed.MethodTimeout == timeout. A dedicated regression test additionally checks positive and negative non-null timeouts while toggling multiple other _flags bits off and back on; it verifies the timeout remains unchanged and the restored record equals the original. PR Fast / Package Smoke / CodeQL have been retriggered for the updated head.

@SunSi12138
SunSi12138 merged commit 676b2a3 into dev Oct 9, 2026
5 of 7 checks passed
@SunSi12138
SunSi12138 deleted the perf/734-compact-rpc-method-descriptor branch October 9, 2026 05:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant