Skip to content

[arm64] JIT assert at lowerarmarch.cpp:339 — rotateAmount can be negative after % #129298

Closed

Description

@AndyAyersMS

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 FullOpts and Tier1+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:

  1. The child is GT_ROL or GT_ROR
  2. The child's op2 is a CNS_INT after folding
  3. 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

  1. added
    area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI
    on Jun 11, 2026
  2. dotnet-policy-service commented on Jun 11, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch
    See info in area-owners.md if you want to be subscribed.

  3. tannergooding commented on Jun 11, 2026

    @tannergooding
    Member

    Hmmm.... We abandon direct importation if op2 is 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 mask cns2) 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:

            IL_0000: ldarg.0
            IL_0001: ldarg.1
            IL_0002: ldc.i4.s 31
            IL_0004: and
            IL_0005: shl
            IL_0006: ldarg.0
            IL_0007: ldc.i4.s 32
            IL_0009: ldarg.1
            IL_000a: sub
            IL_000b: ldc.i4.s 31
            IL_000d: and
            IL_000e: shr.un
            IL_000f: or
            IL_0010: ret
    

    This means something else must be removing the & 0x1F from one of the shifts and so it sounds, overall, like maybe a bad interplay between % and & cns in some scenario?

  4. tannergooding commented on Jun 11, 2026

    @tannergooding
    Member

    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).

  5. removed
    untriagedNew issue has not been triaged by the area owner
    on Jun 16, 2026
  6. added this to the 11.0.0 milestone on Jun 16, 2026
  7. JulieLeeMSFT commented on Jun 16, 2026

    @JulieLeeMSFT
    Member
  8. added
    Priority:2Work that is important, but not critical for the release
    on Jul 9, 2026
  9. added a commit that references this issue on Aug 10, 2026
    5e6e2bb
  10. added a commit that references this issue on Aug 11, 2026
    4de0d63
  11. locked and limited conversation to collaborators on Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Priority:2Work that is important, but not critical for the releasearea-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions