Repository navigation
Description
Note
This issue body (analysis, reduction, and suggested fix) was drafted by GitHub Copilot CLI based on a JIT-fuzzer crash and verified by me.
Description
On osx.arm64.Checked the JIT asserts during Lowering nodeinfo:
Assertion failed '(rotateAmount >= 0) && (rotateAmount <= (wrapAmount - 1))'
in 'Program:Fn(uint,uint):uint' during 'Lowering nodeinfo'
File: src/coreclr/jit/lowerarmarch.cpp:339
The assert sits in Lowering::IsContainableUnaryOrBinaryOp, ROL/ROR branch:
// src/coreclr/jit/lowerarmarch.cpp:316-339
if (childNode->OperIs(GT_ROL, GT_ROR))
{
// Find "a op (b rotate cns)"
if (childNode->gtGetOp1()->isContained()) return false;
GenTree* rotateAmountNode = childNode->gtGetOp2();
if (!rotateAmountNode->IsCnsIntOrI()) return false;
const ssize_t wrapAmount = (static_cast<ssize_t>(genTypeSize(childNode)) * BITS_PER_BYTE);
assert((wrapAmount == 32) || (wrapAmount == 64));
// Rotation is circular, so normalize to [0, wrapAmount - 1]
ssize_t rotateAmount = rotateAmountNode->AsIntCon()->IconValue() % wrapAmount;
assert((rotateAmount >= 0) && (rotateAmount <= (wrapAmount - 1))); // <-- FIRES
...
}% in C++ returns the sign of the dividend for negative dividends, so any
negative IconValue() makes the modulo result negative, tripping the assert.
The intent is mod-and-normalize-to-non-negative; the implementation only
performs mod.
Configurations
- arm64: assert fires on
FullOptsandTier1+PGO(any optimized tier) - x64: not affected (this lowering path is arm64-only)
- arm64 +
DOTNET_JITMinOpts=1: not affected (Lowering containment skipped)
Repro (63 lines, FullOpts, no warmup needed)
Program.cs:
// Reduced from a JIT-fuzzer (ReifyCs) trial, seed 8100021572.
// Triggers JIT assert on osx.arm64.Checked under FullOpts:
// src/coreclr/jit/lowerarmarch.cpp:339
using System;
using System.Numerics;
using System.Runtime.CompilerServices;
public static class Program
{
private static volatile uint Input_p0 = 1;
private static volatile uint Input_p1 = 1;
private static volatile uint Sink;
[MethodImpl(MethodImplOptions.NoInlining)]
public static uint Fn(uint p0, uint p1)
{
unchecked
{
uint v1, v3, v5, v6, v8, v11, v15, v16, v17, v21 = 0, v22, v26, v28;
uint v33 = 0, v40, v44;
int v2, v4, v7, v9, v10, v12, v29;
v1 = p0 % p1;
v2 = BitOperations.LeadingZeroCount(v1); v3 = (uint)v2;
v4 = BitOperations.LeadingZeroCount(v1); v5 = (uint)v4;
v6 = BitOperations.RotateLeft(p0, 0);
v7 = BitOperations.IsPow2(0x80000000u) ? 1 : 0; v8 = (uint)v7;
v9 = (p1 < p0) ? 1 : 0;
if (v9 == 0) {
v17 = v8 + v3;
v22 = Math.Min(0xFFFFFFFEu, 0xFFFFFFFFu);
return v22;
}
v10 = (v1 > p1) ? 1 : 0; v11 = (uint)v10;
v12 = (v11 == v3) ? 1 : 0;
v15 = v6 + 0x12345u; v16 = v11 + p1;
v26 = BitOperations.RotateLeft(0xFFFFFFFFu, (int)v21);
v28 = p1 ^ 3u;
v29 = (v15 <= v16) ? 1 : 0;
if (v29 != 0) {
v17 = v8 + v3;
v33 = (uint)BitOperations.TrailingZeroCount(v16);
return v33 ^ v5;
}
// unreached at runtime; v33 = 0 (default-init) reaches Lowering.
v40 = BitOperations.RotateLeft(p0, (int)(0x7FFFFFFEu % v33));
v44 = v40 ^ v5;
return v44;
}
}
public static int Main()
{
Sink = Fn(Input_p0, Input_p1);
Console.WriteLine($"{(uint)Sink:X8}");
return 0;
}
}Build + run against a Checked CoreCLR Core_Root on arm64:
$ dotnet build -c Release
$ DOTNET_TieredCompilation=0 $CORE_ROOT/corerun bin/Release/<tfm>/Program.dll
Assert failure ... 'lowerarmarch.cpp:339'
Investigation notes
The IL contains one problem rotate site:
v40 = BitOperations.RotateLeft(p0, (int)(0x7FFFFFFEu % v33));inside a block that is dead at runtime for the test inputs. v33 is
default-initialized to 0 because its only writer is in a sibling branch
that is not on the executed path.
The expression (int)(0x7FFFFFFEu % v33) is not a constant in the
source. Some combination of morph / value-numbering / assertion-prop in
optimized codegen folds this rotate-amount expression into a constant by
the time Lowering runs, and the folded constant has the int-sign bit set
(i.e. it is observed as negative ssize_t). The % wrapAmount then yields
a negative value and the assert catches it.
The same assert can fire from any call to IsContainableUnaryOrBinaryOp
where:
- The child is
GT_ROLorGT_ROR - The child's op2 is a
CNS_INTafter folding - The constant value is negative when interpreted as
ssize_t
Callers include ContainCheckBinary (any binary parent of a ROL/ROR on
arm64), ContainCheckCompare, the GT_NEG/GT_NOT containment paths.
Suggested fix
Normalize the modulo result to [0, wrapAmount - 1]:
ssize_t rotateAmount = rotateAmountNode->AsIntCon()->IconValue() % wrapAmount;
if (rotateAmount < 0) { rotateAmount += wrapAmount; }
assert((rotateAmount >= 0) && (rotateAmount <= (wrapAmount - 1)));(For the GT_ROL → GT_ROR conversion later in the same function, the
wrapAmount - rotateAmount expression is already correct once
rotateAmount is non-negative.)
Blame
The asserting line was introduced by commit 3d0da2cc4303 (Tanner Gooding,
2024-04-15) in src/coreclr/jit/lowerarmarch.cpp. It has been latent
~2 years and only fires when fold / value-numbering produces a negative
IconValue for the rotate amount.
Discovery
Found via JIT fuzzing (ReifyCs stage2_eh profile, ~42k trials). Two
distinct seeds (8100021572, 8100039393) hit this same assert,
indicating the bug is repeatedly reachable from random
BitOperations.RotateLeft/Right + % varname shapes.
category
- area-CodeGen-coreclr
- arch-arm64
Activity
dotnet-policy-service commentedon Jun 11, 2026
Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch
See info in area-owners.md if you want to be subscribed.
tannergooding commentedon Jun 11, 2026
Hmmm.... We abandon direct importation if
op2is not a constant: https://github.com/dotnet/runtime/blob/main/src/coreclr/jit/importercalls.cpp#L6373 (technically an unnecessary pessimization, we can import this just fine for non-constants provided we maskcns2) and explicitly mask up front if it is a constant.So this must be hitting inlining + the general-purpose pattern recognition instead, which should've masked the shift amounts itself (C# always emits such a mask for shifts, to ensure well-defined behavior). -- The inlined codegen would be
(value << offset) | (value >> (32 - offset));, which C# emits as:This means something else must be removing the
& 0x1Ffrom one of the shifts and so it sounds, overall, like maybe a bad interplay between%and& cnsin some scenario?tannergooding commentedon Jun 11, 2026
That is to say, strictly speaking the assert here is correct. We should never encounter a rotate, particularly not from
BitOperations.Rotate, where the shift amount could be outside the range[0, sizeof(T) * 8 - 1](31 for int, 63 for long).Our pattern recognition for arbitrary user code and general intrinsic handling should always be masking the constant up front or otherwise have been from a pattern that includes masking of the shift amount.
If we reach lowering and we have otherwise, then we either didn't do the right IR normalization somewhere (bug) or we stripped the masking that IL had illegally (we have a bad transform in the JIT).
JulieLeeMSFT commentedon Jun 16, 2026
@dhartglassMSFT, PTAL.
CC @a74nh @SwapnilGaikwad
Fix for out of range rotate amount during lower (#131909)
Fix for out of range rotate amount during lower (dotnet#131909)