From d752bcc24072d72fc64dd70ed852d02d0f5f76ce Mon Sep 17 00:00:00 2001 From: SunSi12138 Date: Fri, 25 Sep 2026 11:31:44 +0800 Subject: [PATCH 1/2] perf(abstractions): shrink RpcMethodDescriptor from 40 to 32 bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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` form occupies 16 bytes (8-byte value + presence + padding) and pushed the struct past the 32-byte boundary: long ContractId + long MethodId + Nullable + 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() == 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 --- src/SharpLink.Abstractions/IRpcChannel.cs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Abstractions/IRpcChannel.cs b/src/SharpLink.Abstractions/IRpcChannel.cs index 7d66542e5..f92500e34 100644 --- a/src/SharpLink.Abstractions/IRpcChannel.cs +++ b/src/SharpLink.Abstractions/IRpcChannel.cs @@ -68,8 +68,23 @@ public RpcMethodDescriptor( public long ContractId { get; init; } /// Gets the stable generated method identifier. public long MethodId { get; init; } + // Declared ahead of the 4-byte and 1-byte members below. The struct uses sequential layout, + // so a long declared after them would be padded back to an 8-byte boundary and the descriptor + // would stay 40 bytes wide however the timeout were stored. + private readonly long _methodTimeoutTicks; + /// Gets the explicit method timeout, or to use the client default. - public TimeSpan? MethodTimeout { get; init; } + /// + /// Persisted as a tick count with a negative sentinel rather than as + /// of . The nullable form occupies 16 bytes and + /// padded the descriptor to 40; one 8-byte field keeps it at 32. The descriptor is passed by + /// value through every client invocation boundary, so its width is on the hot path. + /// + public TimeSpan? MethodTimeout + { + get => _methodTimeoutTicks < 0 ? null : TimeSpan.FromTicks(_methodTimeoutTicks); + init => _methodTimeoutTicks = value?.Ticks ?? NoMethodTimeoutTicks; + } /// Gets the number of client-stream parameters owned by the request. public int ClientStreamCount { get; init; } /// Gets the generated invocation shape. @@ -105,6 +120,8 @@ public bool ResponseNullable init => _flags = SetFlag(_flags, ResponseNullableFlag, value); } + private const long NoMethodTimeoutTicks = -1; + private readonly byte _flags; private static byte SetFlag(byte flags, byte flag, bool value) From 75e1908aff9538241e5e09e06c347dde010ffc8f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:56:15 +0800 Subject: [PATCH 2/2] test(abstractions): preserve timeout through combined with flag updates --- .../Abstractions/RpcMethodDescriptorTests.cs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/test/SharpLink.UnitTests/Abstractions/RpcMethodDescriptorTests.cs b/test/SharpLink.UnitTests/Abstractions/RpcMethodDescriptorTests.cs index 34256b26d..3914af58e 100644 --- a/test/SharpLink.UnitTests/Abstractions/RpcMethodDescriptorTests.cs +++ b/test/SharpLink.UnitTests/Abstractions/RpcMethodDescriptorTests.cs @@ -36,9 +36,54 @@ public async Task FlagsMustRemainPackedAndBothDeconstructionShapesMustRemainComp await Assert.That(oldTimeout).IsEqualTo(timeout); await Assert.That(streamCount).IsEqualTo(2); await Assert.That(changed.ResponseNullable || changed.HasClientStreams).IsFalse(); + await Assert.That(changed.MethodTimeout).IsEqualTo(timeout); await Assert.That(changed.HasResponsePayload && changed.HasMethodTimeout && changed.IsIdempotent).IsTrue(); } + [Test] + public async Task CombinedWithFlagUpdatesMustPreserveNonNullMethodTimeout() + { + foreach (var timeout in new[] { TimeSpan.FromSeconds(3), TimeSpan.FromTicks(-42) }) + { + var original = new RpcMethodDescriptor( + 11, 22, RpcMethodKind.DuplexStreaming, + HasResponsePayload: true, + HasClientStreams: true, + HasMethodTimeout: true, + MethodTimeout: timeout, + IsIdempotent: true, + ClientStreamCount: 2, + ResponseNullable: true); + + var updated = original with + { + HasResponsePayload = false, + HasClientStreams = false, + HasMethodTimeout = false, + IsIdempotent = false, + ResponseNullable = false + }; + + await Assert.That(updated.MethodTimeout).IsEqualTo(timeout); + await Assert.That(updated.MethodTimeout.HasValue).IsTrue(); + await Assert.That(updated.HasResponsePayload || updated.HasClientStreams + || updated.HasMethodTimeout || updated.IsIdempotent || updated.ResponseNullable).IsFalse(); + await Assert.That(original.MethodTimeout).IsEqualTo(timeout); + + var restored = updated with + { + HasResponsePayload = true, + HasClientStreams = true, + HasMethodTimeout = true, + IsIdempotent = true, + ResponseNullable = true + }; + + await Assert.That(restored.MethodTimeout).IsEqualTo(timeout); + await Assert.That(restored).IsEqualTo(original); + } + } + [Test] public async Task DefaultDescriptorMustHaveNoMethodTimeout() {