Description
Zero-initialising a 32-byte struct through a byref that the JIT can see is really the address of a Vector256<ulong> local (Unsafe.As<Vector256<ulong>, S>(ref v) = default;) produces wrong code on x64:
- With optimizations (FullOpts,
TieredCompilation=0, or AggressiveOptimization) the JIT emits vmovq ymm0, rax. There is no VEX.256 encoding of vmovq, so the CPU raises #UD and the process dies with ExecutionEngineException: Illegal instruction (.NET 10) or a fail-fast InternalError from RhThrowHwEx (.NET 9, which emits vmovd ymm0, rax).
- At Tier0 / MinOpts (the default tiered configuration) the same method fails to compile at all:
InvalidProgramException: Common Language Runtime detected an invalid program. The Tier0 disassembly stops right after the first instruction of the block.
The same pattern with a Vector128<ulong> local and a 16-byte struct also throws InvalidProgramException at Tier0; with optimizations it happens to emit a legal vmovq xmm0, rax, which zero-extends and so produces the right value by accident.
Writing the zero as a vector (v = Vector256<ulong>.Zero; or v = default;) compiles to vxorps and works in every mode. Found while writing an A/B harness that reinterprets Vector256<ulong> locals as a four-limb UInt256 (the pattern in the harness was res = default; inside an inlined callee whose out UInt256 res argument was such a reinterpreted local).
Reproduction Steps
Single file console app, net10.0, default project settings (dotnet new console, replace Program.cs, AllowUnsafeBlocks not needed):
using System;
using System.Runtime.CompilerServices;
using System.Runtime.Intrinsics;
struct Limbs
{
public ulong u0, u1, u2, u3;
}
static class Program
{
[MethodImpl(MethodImplOptions.NoInlining)]
static Vector256<ulong> ZeroThroughStruct()
{
Unsafe.SkipInit(out Vector256<ulong> r);
Unsafe.As<Vector256<ulong>, Limbs>(ref r) = default;
return r;
}
static void Main() => Console.WriteLine(ZeroThroughStruct());
}
dotnet run -c Release # Tier0: InvalidProgramException
DOTNET_TieredCompilation=0 dotnet run -c Release # FullOpts: illegal instruction
DOTNET_TieredCompilation=0 DOTNET_JitDisasm=ZeroThroughStruct dotnet run -c Release # shows vmovq ymm0, rax
NoInlining is only there so the method shows up under DOTNET_JitDisasm on its own; without it the same code is inlined into Main and fails the same way.
Expected behavior
The method returns <0, 0, 0, 0> in every tier. The store should be emitted as a vector zero (vxorps ymm0, ymm0, ymm0 + vmovups), or, if the JIT prefers to keep the struct view, as scalar/xmm stores, exactly as it already does when AVX is disabled (DOTNET_EnableAVX=0 gives xorps xmm0, xmm0 and two movups, and runs correctly).
Actual behavior
Default configuration (tiering on), .NET 10.0.11:
Unhandled exception. System.InvalidProgramException: Common Language Runtime detected an invalid program.
at Program.ZeroThroughStruct()
at Program.Main()
DOTNET_JitDisasm shows the Tier0 compile stopping after the first instruction of the block (listed twice, once per attempt, no Total bytes of code):
; Assembly listing for method Program:ZeroThroughStruct():System.Runtime.Intrinsics.Vector256`1[ulong] (Tier0)
; Emitting BLENDED_CODE for generic X64 + VEX + EVEX on Windows
; Tier0 code
...
G_M000_IG01:
push rbp
sub rsp, 48
lea rbp, [rsp+0x30]
vxorps xmm4, xmm4, xmm4
vmovdqu ymmword ptr [rbp-0x30], ymm4
mov qword ptr [rbp+0x10], rcx
G_M000_IG02:
xor eax, eax
DOTNET_TieredCompilation=0, .NET 10.0.11:
Fatal error.
System.ExecutionEngineException: Illegal instruction: Attempted to execute an instruction code not defined by the processor.
at Program.ZeroThroughStruct()
; Assembly listing for method Program:ZeroThroughStruct():System.Runtime.Intrinsics.Vector256`1[ulong] (FullOpts)
; Emitting BLENDED_CODE for generic X64 + VEX + EVEX on Windows
G_M000_IG02:
xor eax, eax
vmovq ymm0, rax
vmovups ymmword ptr [rcx], ymm0
mov rax, rcx
G_M000_IG03:
vzeroupper
ret
; Total bytes of code 18
Same result with DOTNET_EnableAVX512=0 and with DOTNET_EnableAVX2=0 (as long as AVX is on, the JIT still moves the Vector256 as one ymm register). With DOTNET_EnableAVX=0 the output is correct.
.NET 9.0.19, same source: Tier0 throws InvalidProgramException: The JIT compiler encountered invalid IL code or an internal limitation.; FullOpts emits vmovd ymm0, rax and the process ends with Process terminated. InternalError from System.Runtime.EH.RhThrowHwEx.
Regression?
Not a regression between 9 and 10: .NET 9.0.19 fails the same two ways (with vmovd instead of vmovq in the optimized code). Not tested on .NET 8.
Known Workarounds
Zero the local as a vector rather than through the reinterpreted struct view: r = Vector256<ulong>.Zero; (or r = default;) compiles to vxorps and works in every tier. Equivalently, keep the local as the struct type and reinterpret it as a Vector256 afterwards. Both were verified with the repro.
Configuration
- .NET 10.0.11 runtime (commit e2f47b0110ed922f21a1522da67279133ce28f32), SDK 10.0.400; also .NET 9.0.19.
- Windows 11 Pro for Workstations, 10.0.26220.
- x64, AMD Ryzen 9 9950X (AVX-512 capable). Reproduces with
DOTNET_EnableAVX512=0 and DOTNET_EnableAVX2=0; does not reproduce with DOTNET_EnableAVX=0, so it needs a VEX-encoded 32-byte move.
- Not tested on other OSes or on ARM64.
Other information
The IL is ldloca r; call Unsafe.As<Vector256<ulong>, Limbs>; initobj Limbs. After the Unsafe.As is folded the JIT is looking at a block zero-init of a 32-byte struct whose destination is a TYP_SIMD32 local. The optimized codegen looks like the init was retyped into a store of an integer zero constant into the SIMD32 local and then lowered with the GPR-to-XMM move (movd/movq) sized by the destination register, which is only valid for 4- and 8-byte destinations; the Tier0 failure is presumably the same retyped tree tripping a check in the un-optimized path. The Vector128 variant taking the same route explains why it "works" there: vmovq xmm, r64 zero-extends to 128 bits.
Related pattern that may be the same root cause but is not yet minimised: in a larger method, an inlined callee that first wrote such a reinterpreted local with a 32-byte default and then stored two individual 8-byte fields into it caused the FullOpts compile to abort after codegen (truncated JitDisasm listing) and fall back to MinOpts (visible with DOTNET_JitDisasmSummary=1); the field stores on their own compile fine.
Description
Zero-initialising a 32-byte struct through a byref that the JIT can see is really the address of a
Vector256<ulong>local (Unsafe.As<Vector256<ulong>, S>(ref v) = default;) produces wrong code on x64:TieredCompilation=0, orAggressiveOptimization) the JIT emitsvmovq ymm0, rax. There is no VEX.256 encoding ofvmovq, so the CPU raises #UD and the process dies withExecutionEngineException: Illegal instruction(.NET 10) or a fail-fastInternalErrorfromRhThrowHwEx(.NET 9, which emitsvmovd ymm0, rax).InvalidProgramException: Common Language Runtime detected an invalid program.The Tier0 disassembly stops right after the first instruction of the block.The same pattern with a
Vector128<ulong>local and a 16-byte struct also throwsInvalidProgramExceptionat Tier0; with optimizations it happens to emit a legalvmovq xmm0, rax, which zero-extends and so produces the right value by accident.Writing the zero as a vector (
v = Vector256<ulong>.Zero;orv = default;) compiles tovxorpsand works in every mode. Found while writing an A/B harness that reinterpretsVector256<ulong>locals as a four-limbUInt256(the pattern in the harness wasres = default;inside an inlined callee whoseout UInt256 resargument was such a reinterpreted local).Reproduction Steps
Single file console app,
net10.0, default project settings (dotnet new console, replace Program.cs,AllowUnsafeBlocksnot needed):NoInliningis only there so the method shows up underDOTNET_JitDisasmon its own; without it the same code is inlined intoMainand fails the same way.Expected behavior
The method returns
<0, 0, 0, 0>in every tier. The store should be emitted as a vector zero (vxorps ymm0, ymm0, ymm0+vmovups), or, if the JIT prefers to keep the struct view, as scalar/xmm stores, exactly as it already does when AVX is disabled (DOTNET_EnableAVX=0givesxorps xmm0, xmm0and twomovups, and runs correctly).Actual behavior
Default configuration (tiering on), .NET 10.0.11:
DOTNET_JitDisasmshows the Tier0 compile stopping after the first instruction of the block (listed twice, once per attempt, noTotal bytes of code):DOTNET_TieredCompilation=0, .NET 10.0.11:Same result with
DOTNET_EnableAVX512=0and withDOTNET_EnableAVX2=0(as long as AVX is on, the JIT still moves theVector256as one ymm register). WithDOTNET_EnableAVX=0the output is correct..NET 9.0.19, same source: Tier0 throws
InvalidProgramException: The JIT compiler encountered invalid IL code or an internal limitation.; FullOpts emitsvmovd ymm0, raxand the process ends withProcess terminated. InternalErrorfromSystem.Runtime.EH.RhThrowHwEx.Regression?
Not a regression between 9 and 10: .NET 9.0.19 fails the same two ways (with
vmovdinstead ofvmovqin the optimized code). Not tested on .NET 8.Known Workarounds
Zero the local as a vector rather than through the reinterpreted struct view:
r = Vector256<ulong>.Zero;(orr = default;) compiles tovxorpsand works in every tier. Equivalently, keep the local as the struct type and reinterpret it as aVector256afterwards. Both were verified with the repro.Configuration
DOTNET_EnableAVX512=0andDOTNET_EnableAVX2=0; does not reproduce withDOTNET_EnableAVX=0, so it needs a VEX-encoded 32-byte move.Other information
The IL is
ldloca r; call Unsafe.As<Vector256<ulong>, Limbs>; initobj Limbs. After theUnsafe.Asis folded the JIT is looking at a block zero-init of a 32-byte struct whose destination is a TYP_SIMD32 local. The optimized codegen looks like the init was retyped into a store of an integer zero constant into the SIMD32 local and then lowered with the GPR-to-XMM move (movd/movq) sized by the destination register, which is only valid for 4- and 8-byte destinations; the Tier0 failure is presumably the same retyped tree tripping a check in the un-optimized path. TheVector128variant taking the same route explains why it "works" there:vmovq xmm, r64zero-extends to 128 bits.Related pattern that may be the same root cause but is not yet minimised: in a larger method, an inlined callee that first wrote such a reinterpreted local with a 32-byte
defaultand then stored two individual 8-byte fields into it caused the FullOpts compile to abort after codegen (truncatedJitDisasmlisting) and fall back to MinOpts (visible withDOTNET_JitDisasmSummary=1); the field stores on their own compile fine.