JIT: redundant struct-copy traffic and initialization
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
### Description
The Windows x64 JIT emits intermediate stack traffic when copying structs whose fields are already held in registers. The example below models completion processing with packed sequence/status views and native/managed buffer descriptors. It exhibits repeated write-backs, loads of bits already available in registers, and a narrow store immediately followed by a wider overlapping load when constructing a by-value argument.
There are also redundant-looking initialization and extension operations. This is a code-generation/performance issue; the example produces the expected results. No throughput benchmark is included.
The transport and telemetry methods are small stand-ins. `NoInlining` preserves the call boundaries, and multiple consumers give the scalar fields uses across calls. The result is an audit total checked by the harness.
### Configuration
- dotnet/runtime main at `3c4631e63b1de4308e2965b149992b992c9f5318`.
- Windows x64 Checked JIT with a matching Checked runtime and CoreLib.
- Windows 11 Pro for Workstations Insider Preview, build 26220.
- AMD Ryzen 9 9950X; the captured JIT output uses VEX/EVEX instructions.
- .NET 11 compiler and preview 7 supporting libraries/reference assemblies.
- Optimized compilation; `DOTNET_TieredCompilation=0`, `DOTNET_ReadyToRun=0`.
- No forced physical promotion, JIT stress or PGO in the assembly capture.
### Reproduction
Put the following source in a console application targeting .NET 11, with `AllowUnsafeBlocks=true`, `Optimize=true` and `LangVersion=preview`.
```csharp
using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
public static unsafe class CompletionExample
{
[StructLayout(LayoutKind.Explicit, Size = 24)]
public struct Completion
{
[FieldOffset(0)] public long Sequence;
[FieldOffset(0)] public int Ticket;
[FieldOffset(4)] public int Epoch;
[FieldOffset(8)] public long Timestamp;
[FieldOffset(16)] public long Bytes;
}
[StructLayout(LayoutKind.Explicit, Size = 24)]
public struct Status
{
[FieldOffset(0)] public byte WireCode;
[FieldOffset(0)] public sbyte SignedCode;
[FieldOffset(8)] public long Timestamp;
[FieldOffset(16)] public long Bytes;
}
private static long s_audit, s_trace, s_checksum;
// Called while the native completion's data buffer is pinned.
[MethodImpl(MethodImplOptions.NoInlining)]
public static long ProcessCompletion(long sequence, int wireCode, nint buffer) =>
PublishCompletion(sequence) + DecodeStatus(wireCode) + ReadBuffer(buffer);
[MethodImpl(MethodImplOptions.NoInlining)]
private static long PublishCompletion(long sequence)
{
Completion current = ReadCompletion(sequence);
current.Sequence++;
Trace(current.Sequence);
Count(current.Sequence);
Checksum(current.Sequence);
Publish(current);
Completion snapshot = current;
Trace(snapshot.Ticket);
Count(snapshot.Ticket);
Checksum(snapshot.Ticket);
Trace(snapshot.Epoch);
Count(snapshot.Epoch);
Checksum(snapshot.Epoch);
Publish(current);
return current.Sequence + snapshot.Ticket + snapshot.Epoch;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static int DecodeStatus(int wireCode)
{
Status status = ReadStatus(wireCode);
status.WireCode = (byte)wireCode;
int audit = Trace(status.WireCode) + Count(status.WireCode) + Checksum(status.WireCode);
Status decoded = status;
return audit + Trace(decoded.SignedCode) + Count(decoded.SignedCode) + Checksum(decoded.SignedCode);
}
[StructLayout(LayoutKind.Sequential)]
private struct NativeBuffer
{
public nint Address, Length, Capacity, Owner, Generation;
}
[StructLayout(LayoutKind.Sequential)]
private ref struct ManagedBuffer
{
public ref int First;
public nint Length, Capacity, Owner, Generation;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static int ReadBuffer(nint address)
{
NativeBuffer native = DescribeBuffer(address);
native.Address = address;
Trace(native.Address);
Count(native.Address);
Checksum(native.Address);
ManagedBuffer managed = default;
Unsafe.CopyBlockUnaligned(ref Unsafe.As(ref managed),
ref Unsafe.As(ref native), (uint)Unsafe.SizeOf());
Trace(ref managed.First);
Count(ref managed.First);
Checksum(ref managed.First);
NativeBuffer outgoing = default;
Unsafe.CopyBlockUnaligned(ref Unsafe.As(ref outgoing),
ref Unsafe.As(ref managed), (uint)Unsafe.SizeOf());
Trace(outgoing.Address);
Count(outgoing.Address);
Checksum(outgoing.Address);
return *(int*)outgoing.Address;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static Completion ReadCompletion(long sequence) =>
new Completion { Sequence = sequence, Timestamp = 1234, Bytes = 4096 };
[MethodImpl(MethodImplOptions.NoInlining)]
private static Status ReadStatus(int code) =>
new Status { WireCode = (byte)code, Timestamp = 1234, Bytes = 4096 };
[MethodImpl(MethodImplOptions.NoInlining)]
private static NativeBuffer DescribeBuffer(nint address) =>
new NativeBuffer { Address = address, Length = 1, Capacity = 1, Owner = 0, Generation = 1 };
[MethodImpl(MethodImplOptions.NoInlining)]
private static void Publish(Completion value) => s_audit ^= value.Sequence + value.Timestamp + value.Bytes;
[MethodImpl(MethodImplOptions.NoInlining)]
private static void Trace(long value) => s_trace = value;
[MethodImpl(MethodImplOptions.NoInlining)]
private static void Count(long value) => s_audit += value;
[MethodImpl(MethodImplOptions.NoInlining)]
private static void Checksum(long value) => s_checksum = unchecked((s_checksum * 31) ^ value);
[MethodImpl(MethodImplOptions.NoInlining)]
private static int Trace(int value) { s_trace = value; return value; }
[MethodImpl(MethodImplOptions.NoInlining)]
private static int Count(int value) { s_audit += value; return value; }
[MethodImpl(MethodImplOptions.NoInlining)]
private static int Checksum(int value) { s_checksum = unchecked((s_checksum * 31) ^ value); return value; }
[MethodImpl(MethodImplOptions.NoInlining)]
private static void Trace(ref int value) => s_trace = value;
[MethodImpl(MethodImplOptions.NoInlining)]
private static void Count(ref int value) => s_audit += value;
[MethodImpl(MethodImplOptions.NoInlining)]
private static void Checksum(ref int value) => s_checksum = unchecked((s_checksum * 31) ^ value);
public static int Main()
{
int payload = 42;
foreach (long sequence in new[] { 0L, -1L, 0x1234567887654321L, long.MaxValue })
foreach (int code in new[] { 0, 127, 128, 255, -1 })
{
long next = unchecked(sequence + 1);
long expected = unchecked(next + (int)next + (int)(next >> 32) + 3 * (byte)code + 3 * (sbyte)code + payload);
if (ProcessCompletion(sequence, code, (nint)(&payload)) != expected)
return 1;
}
return 100;
}
}
```
The pointer passed by `Main` refers to a stack local and remains valid throughout processing. A caller using a managed-array buffer would need to keep it pinned throughout the call. The descriptor reinterpretation is intended to model low-level interop.
The harness checks 20 sequence/status combinations, including signed-byte boundaries, a sequence with a nonzero upper half and signed overflow. Success is **exit code 100**. It passes both normally and with `DOTNET_GCStress=0xC` on the JIT identified above.
To capture the relevant methods using that runtime's `corerun`:
```powershell
$env:DOTNET_TieredCompilation = '0'
$env:DOTNET_ReadyToRun = '0'
$env:DOTNET_GCStress = $null
$env:DOTNET_JitStress = $null
$env:DOTNET_JitStressModeNames = $null
$env:DOTNET_JitDisasm = 'CompletionExample:PublishCompletion CompletionExample:DecodeStatus CompletionExample:ReadBuffer'
$env:DOTNET_JitStdOutFile = Join-Path $PWD 'completion.asm'
& /corerun.exe /CompletionExample.dll
$LASTEXITCODE # Expected: 100
```
### Observed assembly
These are the complete instruction listings for the three relevant methods. Instruction bytes and compiler bookkeeping are omitted; the `;` explanations are annotations. The orchestration and telemetry/transport callees are not shown.
#### PublishCompletion
```asm
G_M20490_IG01: ;; offset=0x0000
push rdi
push rsi
push rbx
sub rsp, 80
vxorps xmm4, xmm4, xmm4 ; Prepare to clear a return buffer that ReadCompletion fully defines.
vmovdqu xmmword ptr [rsp+0x38], xmm4 ; Clear 16 bytes of that return buffer.
xor eax, eax ; Prepare to clear the remaining 8 bytes.
mov qword ptr [rsp+0x48], rax ; Clear the remaining 8 bytes.
mov rdx, rcx
G_M20490_IG02: ;; offset=0x001B
lea rcx, [rsp+0x38]
call [CompletionExample:ReadCompletion(long):CompletionExample+Completion]
mov rbx, qword ptr [rsp+0x38]
inc rbx
mov rcx, rbx
call [CompletionExample:Trace(long)]
mov rcx, rbx
call [CompletionExample:Count(long)]
mov rcx, rbx
call [CompletionExample:Checksum(long)]
mov qword ptr [rsp+0x38], rbx ; Write the updated sequence to source storage for the argument copy.
vmovdqu xmm0, xmmword ptr [rsp+0x38] ; 16-byte load overlaps the preceding 8-byte store.
vmovdqu xmmword ptr [rsp+0x20], xmm0
mov rcx, qword ptr [rsp+0x48]
mov qword ptr [rsp+0x30], rcx
lea rcx, [rsp+0x20]
call [CompletionExample:Publish(CompletionExample+Completion)]
mov qword ptr [rsp+0x38], rbx ; Write the same sequence to the same slot again; its value has not changed.
mov esi, dword ptr [rsp+0x38] ; Ticket is already available in the low 32 bits of rbx.
mov edi, dword ptr [rsp+0x3C] ; Epoch is already available in the high 32 bits of rbx.
mov ecx, esi
call [CompletionExample:Trace(int):int]
mov ecx, esi
call [CompletionExample:Count(int):int]
mov ecx, esi
call [CompletionExample:Checksum(int):int]
mov ecx, edi
call [CompletionExample:Trace(int):int]
mov ecx, edi
call [CompletionExample:Count(int):int]
mov ecx, edi
call [CompletionExample:Checksum(int):int]
lea rcx, [rsp+0x38]
call [CompletionExample:Publish(CompletionExample+Completion)]
movsxd rax, esi
add rax, rbx
movsxd rcx, edi
add rax, rcx
G_M20490_IG03: ;; offset=0x00C3
add rsp, 80
pop rbx
pop rsi
pop rdi
ret
; Total code size: 203 bytes
```
#### DecodeStatus
```asm
G_M58447_IG01: ;; offset=0x0000
push rsi
push rbx
sub rsp, 56
mov ebx, ecx
G_M58447_IG02: ;; offset=0x0008
lea rcx, [rsp+0x20]
mov edx, ebx
call [CompletionExample:ReadStatus(int):CompletionExample+Status]
movzx rbx, bl ; Printed as rbx, but bytes 0F B6 DB encode a 32-bit destination.
mov ecx, ebx
call [CompletionExample:Trace(int):int]
mov esi, eax
mov ecx, ebx
call [CompletionExample:Count(int):int]
add esi, eax
mov ecx, ebx
call [CompletionExample:Checksum(int):int]
add esi, eax
mov byte ptr [rsp+0x20], bl ; Store the register value to memory for the signed view.
movsx rbx, byte ptr [rsp+0x20] ; Reload the same byte; the extended value is consumed as int.
mov ecx, ebx
call [CompletionExample:Trace(int):int]
add esi, eax
mov ecx, ebx
call [CompletionExample:Count(int):int]
add esi, eax
mov ecx, ebx
call [CompletionExample:Checksum(int):int]
add eax, esi
G_M58447_IG03: ;; offset=0x005E
add rsp, 56
pop rbx
pop rsi
ret
; Total code size: 101 bytes
```
#### ReadBuffer
```asm
G_M64715_IG01: ;; offset=0x0000
push rbx
sub rsp, 112
vxorps xmm4, xmm4, xmm4 ; Prepare to initialize the intermediate managed descriptor.
vmovdqu ymmword ptr [rsp+0x20], ymm4 ; Clear 32 bytes of the intermediate descriptor.
xor eax, eax ; Prepare to clear its remaining 8 bytes.
mov qword ptr [rsp+0x40], rax ; Clear the remaining 8 bytes.
mov rbx, rcx
G_M64715_IG02: ;; offset=0x0019
lea rcx, [rsp+0x48]
mov rdx, rbx
call [CompletionExample:DescribeBuffer(nint):CompletionExample+NativeBuffer]
mov rcx, rbx
call [CompletionExample:Trace(long)]
mov rcx, rbx
call [CompletionExample:Count(long)]
mov rcx, rbx
call [CompletionExample:Checksum(long)]
mov qword ptr [rsp+0x48], rbx ; Write native address to descriptor storage.
mov rbx, bword ptr [rsp+0x48] ; Reload the same address as a managed reference.
vmovups ymm0, ymmword ptr [rsp+0x50] ; Load metadata that the managed view never consumes.
vmovups ymmword ptr [rsp+0x28], ymm0 ; Copy that metadata into the intermediate descriptor.
mov rcx, rbx
call [CompletionExample:Trace(byref)]
mov rcx, rbx
call [CompletionExample:Count(byref)]
mov rcx, rbx
call [CompletionExample:Checksum(byref)]
mov bword ptr [rsp+0x20], rbx ; Write the managed reference back for the outgoing native view.
mov rcx, rbx
call [CompletionExample:Trace(long)]
mov rcx, rbx
call [CompletionExample:Count(long)]
mov rcx, rbx
call [CompletionExample:Checksum(long)]
mov eax, dword ptr [rbx]
G_M64715_IG03: ;; offset=0x0095
add rsp, 112
pop rbx
ret
; Total code size: 155 bytes
```
### Optimization opportunities
- `PublishCompletion` clears its non-GC return buffer immediately before a call that defines it. Can the return-buffer definition make that prolog initialization unnecessary?
- The sequence is written to the same source slot twice without a value change. Its low and high halves are then loaded from storage even though the whole value remains in `rbx`.
- Preparing the first by-value argument performs an eight-byte store followed immediately by an overlapping sixteen-byte load. This is an unfavorable store-forwarding pattern. Could the outgoing argument take the sequence from its register and the remaining fields from storage?
- `DecodeStatus` copies between byte and signed-byte views using a stack store/reload. The value is already in `bl`, and its consumers require only an `int`, so both the memory round-trip and the 64-bit sign-extension width look avoidable.
- `ReadBuffer` crosses between native-int and byref views through descriptor storage. Could that transfer remain in registers while preserving each local's GC classification and lifetime?
- The managed descriptor's metadata is copied even though it is never consumed. Eliminating that intermediate storage could also avoid initializing it. Its GC-containing storage must still be initialized correctly if the storage remains; this is not a request to omit required GC initialization.
Register extraction and memory loads can have different code-size and latency tradeoffs. These observations identify avoidable traffic and opportunities to investigate, rather than quantify an end-to-end speedup.
### Regression?
No comparison against an earlier release is included. This report documents the code emitted at the revision above.
Contributor guide
Research direction
Start by compiling and running the supplied CompletionExample harness under the stated Windows x64 Checked JIT configuration; success is exit code 100. Capture PublishCompletion, DecodeStatus, and ReadBuffer with the provided DOTNET_JitDisasm settings, compare the reported stack traffic and initialization with the source operations, and verify that any improvement preserves all 20 checks.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- compilers, performance
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100