From 1b906a667000d1ca5264546799e5e8cabdee602a Mon Sep 17 00:00:00 2001 From: Andy Ayers Date: Sun, 26 Apr 2026 13:57:20 -0700 Subject: [PATCH 01/11] JIT: coalesce constant-indexed bounds checks within a block Add a new phase `optBoundsCheckCoalesce` that runs before assertion prop, looking for sequences of bounds checks that can be collapsed into a single dominating check. For example: `a[0] + a[1] + a[2] + a[3]` produces four bounds checks with indices 0, 1, 2, 3 and the same length VN. The phase rewrites the first check index to 3 and marks the other three checks as "in bound" so they get removed during assertion prop. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/coreclr/jit/CMakeLists.txt | 1 + src/coreclr/jit/boundscheckcoalesce.cpp | 236 ++++++++++++++++++++++++ src/coreclr/jit/compiler.cpp | 4 + src/coreclr/jit/compiler.h | 1 + src/coreclr/jit/compphases.h | 1 + 5 files changed, 243 insertions(+) create mode 100644 src/coreclr/jit/boundscheckcoalesce.cpp diff --git a/src/coreclr/jit/CMakeLists.txt b/src/coreclr/jit/CMakeLists.txt index e7e69887486830..e5228ee28be3b4 100644 --- a/src/coreclr/jit/CMakeLists.txt +++ b/src/coreclr/jit/CMakeLists.txt @@ -97,6 +97,7 @@ set( JIT_SOURCES asyncanalysis.cpp bitset.cpp block.cpp + boundscheckcoalesce.cpp buildstring.cpp codegencommon.cpp codegenlinear.cpp diff --git a/src/coreclr/jit/boundscheckcoalesce.cpp b/src/coreclr/jit/boundscheckcoalesce.cpp new file mode 100644 index 00000000000000..98dc5a1136c40b --- /dev/null +++ b/src/coreclr/jit/boundscheckcoalesce.cpp @@ -0,0 +1,236 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// +// Bounds Check Coalescing +// +// Within a single block, when multiple GT_BOUNDS_CHECK nodes share the same +// length VN and use constant indices, only the bounds check with the largest +// constant index is actually needed. This pass finds such groups and: +// +// 1. Strengthens the FIRST bounds check in the group by replacing its +// constant index with the maximum constant index in the group. +// 2. Marks all other bounds checks in the group with GTF_CHK_INDEX_INBND +// so the existing assertion-prop COMMA handler removes them. +// +// Example: `a[0] + a[1] + a[2] + a[3]` produces four bounds checks with +// indices 0, 1, 2, 3 and the same length. We rewrite the first BC's index +// to 3 and tag the other three for removal. Forward assertion prop then +// drops them as redundant. +// +// Safety: +// * Strengthening is sound: if the new (stronger) check passes, all the +// original (weaker) checks would have passed too. If it fails, one of +// the original checks would have failed too -- both throw the same +// IndexOutOfRangeException. +// * We only coalesce bounds checks that are not separated by side effects +// (calls, indirect/heap stores, atomic ops, memory barriers, or stores +// to locals that are live in/out of an exception handler in a containing +// try region). Other bounds checks between members of the group are not +// barriers (they only throw IOOB, which is the same exception type our +// strengthened check throws). +// * We require all candidates in the group to have the same length VN +// and constant non-negative indices. The first BC's index must itself +// be a constant so it can be mutated in place. +// +// This phase runs before PHASE_ASSERTION_PROP_MAIN so that the existing +// forward direction of assertion prop sees the strengthened first BC and +// removes the marked-redundant later BCs. +// + +#include "jitpch.h" + +#ifdef _MSC_VER +#pragma hdrstop +#endif + +namespace +{ +struct BoundsCheckCandidate +{ + GenTreeBoundsChk* m_bc; + Statement* m_stmt; + ValueNum m_lenVN; + int m_offset; + int m_barrierCount; + + BoundsCheckCandidate(GenTreeBoundsChk* bc, Statement* stmt, ValueNum lenVN, int offset, int barrierCount) + : m_bc(bc) + , m_stmt(stmt) + , m_lenVN(lenVN) + , m_offset(offset) + , m_barrierCount(barrierCount) + { + } +}; + +//------------------------------------------------------------------------ +// IsSideEffectBarrier: check if a node blocks bounds check coalescing +// +// Returns true if a node may have a side effect that should prevent us from +// reordering an earlier bounds-check failure across it. +// +// Stores to tracked locals that are not live in/out of any exception handler +// are not barriers: they cannot be observed if a bounds-check failure is +// reordered to before them. +// +bool IsSideEffectBarrier(Compiler* comp, GenTree* node, bool blockIsInsideTry) +{ + if (node->IsCall()) + { + return true; + } + if (node->OperIs(GT_MEMORYBARRIER)) + { + return true; + } + if (node->OperIsAtomicOp()) + { + return true; + } + if (node->OperIsStore()) + { + if (!node->OperIsLocalStore()) + { + return true; + } + if (!blockIsInsideTry) + { + return false; + } + LclVarDsc const* const dsc = comp->lvaGetDesc(node->AsLclVarCommon()); + return !dsc->lvTracked || dsc->lvLiveInOutOfHndlr; + } + return false; +} +} // namespace + +//------------------------------------------------------------------------ +// optBoundsCheckCoalesce: Coalesce bounds checks within each block. +// +// Returns: +// Suitable phase status. +// +PhaseStatus Compiler::optBoundsCheckCoalesce() +{ + if (!doesMethodHaveBoundsChecks()) + { + JITDUMP("Method has no bounds checks\n"); + return PhaseStatus::MODIFIED_NOTHING; + } + + if (fgSsaPassesCompleted == 0) + { + return PhaseStatus::MODIFIED_NOTHING; + } + + bool modified = false; + CompAllocator alloc(getAllocator(CMK_AssertionProp)); + + // Per-block scratch state, reused across blocks. The candidates stack + // holds the "head" (first) candidate in each (barrierCount, lenVN) group; + // followers are tagged GTF_CHK_INDEX_INBND immediately and not retained. + // groupMap maps a packed (barrierCount, lenVN) key to the candidate index + // of the group head. + typedef JitHashTable, int> GroupMap; + ArrayStack candidates(alloc); + GroupMap groupMap(alloc); + + auto const makeKey = [](int barrierCount, ValueNum lenVN) -> UINT64 { + return (static_cast(static_cast(barrierCount)) << 32) | static_cast(lenVN); + }; + + for (BasicBlock* const block : Blocks()) + { + candidates.Reset(); + groupMap.RemoveAll(); + int barrierCount = 0; + bool const blockIsInsideTry = block->hasTryIndex(); + + for (Statement* const stmt : block->Statements()) + { + for (GenTree* const node : stmt->TreeList()) + { + if (IsSideEffectBarrier(this, node, blockIsInsideTry)) + { + barrierCount++; + continue; + } + + if (!node->OperIs(GT_BOUNDS_CHECK)) + { + continue; + } + + GenTreeBoundsChk* const bc = node->AsBoundsChk(); + if (bc->gtThrowKind != SCK_RNGCHK_FAIL) + { + continue; + } + + GenTree* const idx = bc->GetIndex(); + if (!idx->IsIntCnsFitsInI32()) + { + continue; + } + + int const offset = static_cast(idx->AsIntCon()->IconValue()); + if (offset < 0) + { + continue; + } + + ValueNum const lenVN = vnStore->VNConservativeNormalValue(bc->GetArrayLength()->gtVNPair); + if (lenVN == ValueNumStore::NoVN) + { + continue; + } + + UINT64 const key = makeKey(barrierCount, lenVN); + int headIndex; + if (!groupMap.Lookup(key, &headIndex)) + { + // First member of this group: record it as the head and keep it + // in the candidates stack so we can strengthen it later. + groupMap.Set(key, candidates.Height()); + candidates.Emplace(bc, stmt, lenVN, offset, barrierCount); + continue; + } + + // Follower: tag for forward assertion prop to splice out, and + // bump the head's running max offset. + BoundsCheckCandidate& head = candidates.BottomRef(headIndex); + JITDUMP("BC coalesce in " FMT_BB ": marking [%06u] (offset %d) as redundant of [%06u]\n", block->bbNum, + dspTreeID(bc), offset, dspTreeID(head.m_bc)); + bc->gtFlags |= GTF_CHK_INDEX_INBND; + if (offset > head.m_offset) + { + head.m_offset = offset; + } + } + } + + // Strengthen each group head whose recorded max exceeds its original + // index. Heads with no stronger follower are left alone -- existing + // forward assertion prop already handles equal-or-weaker followers. + for (int i = 0; i < candidates.Height(); i++) + { + BoundsCheckCandidate& head = candidates.BottomRef(i); + GenTreeIntCon* const idxCns = head.m_bc->GetIndex()->AsIntCon(); + int const original = static_cast(idxCns->IconValue()); + if (head.m_offset == original) + { + continue; + } + + JITDUMP("BC coalesce in " FMT_BB ": strengthen [%06u] offset %d -> %d (lenVN " FMT_VN ")\n", block->bbNum, + dspTreeID(head.m_bc), original, head.m_offset, head.m_lenVN); + + idxCns->SetIconValue(head.m_offset); + idxCns->gtVNPair.SetBoth(vnStore->VNForIntCon(head.m_offset)); + modified = true; + } + } + + return modified ? PhaseStatus::MODIFIED_EVERYTHING : PhaseStatus::MODIFIED_NOTHING; +} diff --git a/src/coreclr/jit/compiler.cpp b/src/coreclr/jit/compiler.cpp index a7e591163e109c..499fef17c5ee23 100644 --- a/src/coreclr/jit/compiler.cpp +++ b/src/coreclr/jit/compiler.cpp @@ -4807,6 +4807,10 @@ void Compiler::compCompile(void** methodCodePtr, uint32_t* methodCodeSize, JitFl if (doAssertionProp) { + // Coalesce groups of constant-indexed bounds checks. + // + DoPhase(this, PHASE_BOUNDS_CHECK_COALESCE, &Compiler::optBoundsCheckCoalesce); + // Assertion propagation // DoPhase(this, PHASE_ASSERTION_PROP_MAIN, &Compiler::optAssertionPropMain); diff --git a/src/coreclr/jit/compiler.h b/src/coreclr/jit/compiler.h index c5341a95bfc0e8..9d1cc14655bb31 100644 --- a/src/coreclr/jit/compiler.h +++ b/src/coreclr/jit/compiler.h @@ -7281,6 +7281,7 @@ class Compiler PhaseStatus optCloneLoops(); PhaseStatus optRangeCheckCloning(); + PhaseStatus optBoundsCheckCoalesce(); void optCloneLoop(FlowGraphNaturalLoop* loop, LoopCloneContext* context); PhaseStatus optUnrollLoops(); // Unrolls loops (needs to have cost info) bool optTryUnrollLoop(FlowGraphNaturalLoop* loop, bool* changedIR); diff --git a/src/coreclr/jit/compphases.h b/src/coreclr/jit/compphases.h index a40ecfd7fe932b..8cc24fd02239bc 100644 --- a/src/coreclr/jit/compphases.h +++ b/src/coreclr/jit/compphases.h @@ -102,6 +102,7 @@ CompPhaseNameMacro(PHASE_OPTIMIZE_VALNUM_CSES, "Optimize Valnum CSEs", CompPhaseNameMacro(PHASE_VN_COPY_PROP, "VN based copy prop", false, -1, false) CompPhaseNameMacro(PHASE_VN_BASED_INTRINSIC_EXPAND, "VN based intrinsic expansion", false, -1, false) CompPhaseNameMacro(PHASE_OPTIMIZE_BRANCHES, "Redundant branch opts", false, -1, false) +CompPhaseNameMacro(PHASE_BOUNDS_CHECK_COALESCE, "Coalesce bounds checks", false, -1, false) CompPhaseNameMacro(PHASE_ASSERTION_PROP_MAIN, "Assertion prop", false, -1, false) CompPhaseNameMacro(PHASE_RANGE_CHECK_CLONING, "Clone blocks with range checks", false, -1, false) CompPhaseNameMacro(PHASE_IF_CONVERSION, "If conversion", false, -1, false) From 8ef7a3cf5023aa2385341b296143abab5d9e0ced Mon Sep 17 00:00:00 2001 From: Andy Ayers Date: Mon, 27 Apr 2026 11:25:57 -0700 Subject: [PATCH 02/11] Address PR feedback: simplify barrier rule and drop redundant tagging - Use OperMayThrow + GTF_ORDER_SIDEEFF as the side-effect barrier rule instead of an ad-hoc list (memorybarrier/atomic). GT_BOUNDS_CHECK is exempted since IOOB is the same exception class our strengthened check throws. - Switch from block->hasTryIndex() to block->HasPotentialEHSuccs(this) for the EH-reachability test, matching usage elsewhere in the JIT. - Drop GTF_CHK_INDEX_INBND tagging on followers; strengthening only the head is enough -- forward assertion prop drops the followers. Add regression tests covering exception-ordering invariants: divide/NRE between BCs, IOOB on short arrays, and locals live into catch/finally handlers in a try block. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/coreclr/jit/boundscheckcoalesce.cpp | 63 +++++----- .../JIT/opt/RangeChecks/ElidedBoundsChecks.cs | 108 ++++++++++++++++++ 2 files changed, 142 insertions(+), 29 deletions(-) diff --git a/src/coreclr/jit/boundscheckcoalesce.cpp b/src/coreclr/jit/boundscheckcoalesce.cpp index 98dc5a1136c40b..eae938c632dd4c 100644 --- a/src/coreclr/jit/boundscheckcoalesce.cpp +++ b/src/coreclr/jit/boundscheckcoalesce.cpp @@ -6,17 +6,14 @@ // // Within a single block, when multiple GT_BOUNDS_CHECK nodes share the same // length VN and use constant indices, only the bounds check with the largest -// constant index is actually needed. This pass finds such groups and: -// -// 1. Strengthens the FIRST bounds check in the group by replacing its -// constant index with the maximum constant index in the group. -// 2. Marks all other bounds checks in the group with GTF_CHK_INDEX_INBND -// so the existing assertion-prop COMMA handler removes them. +// constant index is actually needed. This pass finds such groups and +// strengthens the FIRST bounds check in the group by replacing its constant +// index with the maximum constant index in the group. Forward assertion prop +// then drops the now-redundant later bounds checks. // // Example: `a[0] + a[1] + a[2] + a[3]` produces four bounds checks with // indices 0, 1, 2, 3 and the same length. We rewrite the first BC's index -// to 3 and tag the other three for removal. Forward assertion prop then -// drops them as redundant. +// to 3; forward assertion prop then drops the other three as redundant. // // Safety: // * Strengthening is sound: if the new (stronger) check passes, all the @@ -24,18 +21,20 @@ // the original checks would have failed too -- both throw the same // IndexOutOfRangeException. // * We only coalesce bounds checks that are not separated by side effects -// (calls, indirect/heap stores, atomic ops, memory barriers, or stores -// to locals that are live in/out of an exception handler in a containing -// try region). Other bounds checks between members of the group are not -// barriers (they only throw IOOB, which is the same exception type our -// strengthened check throws). +// that could change observable exception ordering: calls, any other +// potentially-throwing node (div/mod, checked arithmetic, faulting +// indirections / null checks, etc.), `GTF_ORDER_SIDEEFF` (e.g. volatile +// loads), heap-visible stores, and stores to locals that are live across +// an exception handler reachable from this block. Other bounds checks +// between members of the group are not barriers (they only throw IOOB, +// the same exception type our strengthened check throws). // * We require all candidates in the group to have the same length VN // and constant non-negative indices. The first BC's index must itself // be a constant so it can be mutated in place. // // This phase runs before PHASE_ASSERTION_PROP_MAIN so that the existing // forward direction of assertion prop sees the strengthened first BC and -// removes the marked-redundant later BCs. +// drops the redundant followers. // #include "jitpch.h" @@ -70,21 +69,28 @@ struct BoundsCheckCandidate // Returns true if a node may have a side effect that should prevent us from // reordering an earlier bounds-check failure across it. // -// Stores to tracked locals that are not live in/out of any exception handler -// are not barriers: they cannot be observed if a bounds-check failure is -// reordered to before them. +// Bounds checks themselves are not barriers: their only exception is IOOB, +// the same exception type our strengthened check throws. +// +// Stores to tracked locals that are not live across any exception handler +// reachable from this block are not barriers: they cannot be observed if a +// bounds-check failure is reordered to before them. // -bool IsSideEffectBarrier(Compiler* comp, GenTree* node, bool blockIsInsideTry) +bool IsSideEffectBarrier(Compiler* comp, GenTree* node, bool blockHasEHSuccs) { if (node->IsCall()) { return true; } - if (node->OperIs(GT_MEMORYBARRIER)) + if (node->OperIs(GT_BOUNDS_CHECK)) + { + return false; + } + if (node->OperMayThrow(comp)) { return true; } - if (node->OperIsAtomicOp()) + if ((node->gtFlags & GTF_ORDER_SIDEEFF) != 0) { return true; } @@ -94,7 +100,7 @@ bool IsSideEffectBarrier(Compiler* comp, GenTree* node, bool blockIsInsideTry) { return true; } - if (!blockIsInsideTry) + if (!blockHasEHSuccs) { return false; } @@ -129,7 +135,7 @@ PhaseStatus Compiler::optBoundsCheckCoalesce() // Per-block scratch state, reused across blocks. The candidates stack // holds the "head" (first) candidate in each (barrierCount, lenVN) group; - // followers are tagged GTF_CHK_INDEX_INBND immediately and not retained. + // followers only update the head's running max offset and are not retained. // groupMap maps a packed (barrierCount, lenVN) key to the candidate index // of the group head. typedef JitHashTable, int> GroupMap; @@ -144,14 +150,14 @@ PhaseStatus Compiler::optBoundsCheckCoalesce() { candidates.Reset(); groupMap.RemoveAll(); - int barrierCount = 0; - bool const blockIsInsideTry = block->hasTryIndex(); + int barrierCount = 0; + bool const blockHasEHSuccs = block->HasPotentialEHSuccs(this); for (Statement* const stmt : block->Statements()) { for (GenTree* const node : stmt->TreeList()) { - if (IsSideEffectBarrier(this, node, blockIsInsideTry)) + if (IsSideEffectBarrier(this, node, blockHasEHSuccs)) { barrierCount++; continue; @@ -197,12 +203,11 @@ PhaseStatus Compiler::optBoundsCheckCoalesce() continue; } - // Follower: tag for forward assertion prop to splice out, and - // bump the head's running max offset. + // Follower: bump the head's running max offset. Once we + // strengthen the head, forward assertion prop will drop us. BoundsCheckCandidate& head = candidates.BottomRef(headIndex); - JITDUMP("BC coalesce in " FMT_BB ": marking [%06u] (offset %d) as redundant of [%06u]\n", block->bbNum, + JITDUMP("BC coalesce in " FMT_BB ": [%06u] (offset %d) is redundant given [%06u]\n", block->bbNum, dspTreeID(bc), offset, dspTreeID(head.m_bc)); - bc->gtFlags |= GTF_CHK_INDEX_INBND; if (offset > head.m_offset) { head.m_offset = offset; diff --git a/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs b/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs index d6196e0a1922a9..7d01a8fbfab518 100644 --- a/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs +++ b/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs @@ -95,6 +95,74 @@ static bool TryStripFirstChar(ref ReadOnlySpan span, char value) return false; } + [MethodImpl(MethodImplOptions.NoInlining)] + static int Sum4Increasing(int[] a) => a[0] + a[1] + a[2] + a[3]; + + [MethodImpl(MethodImplOptions.NoInlining)] + static int Sum4Span(ReadOnlySpan s) => s[0] + s[1] + s[2] + s[3]; + + [MethodImpl(MethodImplOptions.NoInlining)] + static int Sum4MixedOrder(int[] a) => a[2] + a[3] + a[0] + a[1]; + + [MethodImpl(MethodImplOptions.NoInlining)] + static int DivBetweenBCs(int[] a, int divisor) + { + // The divide must not be reordered with a[5]: when divisor == 0 we + // must observe DivideByZeroException, not IndexOutOfRangeException. + int x = a[3]; + int y = 100 / divisor; + return x + y + a[5]; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static int NreBetweenBCs(int[] a, int[] b) + { + // First touch of b may throw NRE; that must not be reordered with + // a[5]: when b == null we must observe NullReferenceException. + int x = a[3]; + int y = b.Length; + return x + y + a[5]; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static int LocalLiveInCatch(int[] a) + { + // The store `x = 99` is between two BCs in a try block whose local is + // live in the catch. It must act as a barrier: if a[3]'s BC were + // strengthened to length 6, the IOOB would fire before x=99 and the + // catch would observe x == -1 instead of 99. + int x = -1; + try + { + int t = a[3]; + x = 99; + return t + a[5]; + } + catch (IndexOutOfRangeException) + { + return x; + } + } + + static int s_finallyObserved; + + [MethodImpl(MethodImplOptions.NoInlining)] + static int LocalLiveInFinally(int[] a) + { + // Same idea, but the local is live into a finally rather than a catch. + int x = -1; + try + { + int t = a[3]; + x = 99; + return t + a[5]; + } + finally + { + s_finallyObserved = x; + } + } + [Fact] public static int TestEntryPoint() { @@ -139,6 +207,46 @@ public static int TestEntryPoint() if (TryStripFirstChar(ref chars, 'h') != false) return 0; + // Bounds-check coalescing: 4 constant indices, same length VN. + int[] arr4 = new int[] { 10, 20, 30, 40 }; + if (Sum4Increasing(arr4) != 100) + return 0; + if (Sum4Span(arr4) != 100) + return 0; + if (Sum4MixedOrder(arr4) != 100) + return 0; + + // Short array: must throw IndexOutOfRangeException. + Assert.Throws(() => Sum4Increasing(new int[3])); + Assert.Throws(() => Sum4MixedOrder(new int[3])); + + // Exception ordering must be preserved across non-IOOB throwers. + int[] arr6 = new int[] { 1, 2, 3, 4, 5, 6 }; + if (DivBetweenBCs(arr6, 5) != (arr6[3] + 100 / 5 + arr6[5])) + return 0; + + // divisor == 0 with a too short for a[5]: must be DivideByZero, not IOOB. + Assert.Throws(() => DivBetweenBCs(new int[4], 0)); + + // b == null with a too short for a[5]: must be NRE, not IOOB. + Assert.Throws(() => NreBetweenBCs(new int[4], null)); + + // Local live in catch handler: a[3]'s BC must not be strengthened to + // a[5] across the `x = 99` store, otherwise the catch would see -1. + if (LocalLiveInCatch(new int[4]) != 99) + return 0; + + // Local live in finally: same constraint, observed via static field. + s_finallyObserved = 0; + try + { + LocalLiveInFinally(new int[4]); + return 0; + } + catch (IndexOutOfRangeException) { } + if (s_finallyObserved != 99) + return 0; + return 100; } } From f631140fabee70ad3707d881b4147579ef51cc27 Mon Sep 17 00:00:00 2001 From: Andy Ayers Date: Sat, 2 May 2026 08:40:44 -0700 Subject: [PATCH 03/11] Address PR feedback: use OperEffects for barrier; expand tests Replace the hand-rolled IsSideEffectBarrier operator enumeration with a check derived from GenTree::OperEffects (per-node, non-summary effect flags + precise exception set). This addresses jakobbotsch's correctness concern: GTF_CALL/GTF_ORDER_SIDEEFF block coalescing; nodes with GTF_EXCEPT block coalescing unless their only exception is IndexOutOfRange (so sibling GT_BOUNDS_CHECK still falls through); GTF_ASG blocks coalescing unless it's a local store whose destination is not live across an EH-reachable handler. Add three regression cases to ElidedBoundsChecks.cs covering: (1) heap-visible store between BCs preserves observable side-effects, (2) non-throwing call between BCs preserves observable side-effects, (3) bounds checks against arrays with distinct length VNs are not merged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/coreclr/jit/boundscheckcoalesce.cpp | 54 +++++++------ .../JIT/opt/RangeChecks/ElidedBoundsChecks.cs | 76 +++++++++++++++++++ 2 files changed, 105 insertions(+), 25 deletions(-) diff --git a/src/coreclr/jit/boundscheckcoalesce.cpp b/src/coreclr/jit/boundscheckcoalesce.cpp index eae938c632dd4c..aa7b68c4bf603a 100644 --- a/src/coreclr/jit/boundscheckcoalesce.cpp +++ b/src/coreclr/jit/boundscheckcoalesce.cpp @@ -21,13 +21,13 @@ // the original checks would have failed too -- both throw the same // IndexOutOfRangeException. // * We only coalesce bounds checks that are not separated by side effects -// that could change observable exception ordering: calls, any other -// potentially-throwing node (div/mod, checked arithmetic, faulting -// indirections / null checks, etc.), `GTF_ORDER_SIDEEFF` (e.g. volatile -// loads), heap-visible stores, and stores to locals that are live across -// an exception handler reachable from this block. Other bounds checks -// between members of the group are not barriers (they only throw IOOB, -// the same exception type our strengthened check throws). +// that could change observable exception ordering. We use per-node +// effect flags from GenTree::OperEffects: calls and ordering-side-effect +// nodes (e.g. volatile loads) are barriers; nodes that may throw are +// barriers unless their only exception is IndexOutOfRange (so other +// bounds checks fall through naturally); heap-visible stores are +// barriers, as are local stores whose destination is live across an +// exception handler reachable from this block. // * We require all candidates in the group to have the same length VN // and constant non-negative indices. The first BC's index must itself // be a constant so it can be mutated in place. @@ -67,34 +67,37 @@ struct BoundsCheckCandidate // IsSideEffectBarrier: check if a node blocks bounds check coalescing // // Returns true if a node may have a side effect that should prevent us from -// reordering an earlier bounds-check failure across it. +// reordering an earlier bounds-check failure across it. Uses the per-node +// (non-summary) effect flags from GenTree::OperEffects. // -// Bounds checks themselves are not barriers: their only exception is IOOB, -// the same exception type our strengthened check throws. +// Calls and ordering-side-effect nodes (e.g. volatile loads) are barriers. // -// Stores to tracked locals that are not live across any exception handler -// reachable from this block are not barriers: they cannot be observed if a -// bounds-check failure is reordered to before them. +// A node that may throw is a barrier unless its only possible exception is +// IndexOutOfRange (the same exception our strengthened check throws); this +// is what lets a sibling GT_BOUNDS_CHECK fall through as a non-barrier. +// +// A heap-visible store is a barrier; a store to a tracked local that is not +// live across any exception handler reachable from this block is not. // bool IsSideEffectBarrier(Compiler* comp, GenTree* node, bool blockHasEHSuccs) { - if (node->IsCall()) - { - return true; - } - if (node->OperIs(GT_BOUNDS_CHECK)) - { - return false; - } - if (node->OperMayThrow(comp)) + ExceptionSetFlags exSet; + GenTreeFlags const effects = node->OperEffects(comp, &exSet); + + if ((effects & (GTF_CALL | GTF_ORDER_SIDEEFF)) != 0) { return true; } - if ((node->gtFlags & GTF_ORDER_SIDEEFF) != 0) + + if ((effects & GTF_EXCEPT) != 0) { - return true; + if ((exSet & ~ExceptionSetFlags::IndexOutOfRangeException) != ExceptionSetFlags::None) + { + return true; + } } - if (node->OperIsStore()) + + if ((effects & GTF_ASG) != 0) { if (!node->OperIsLocalStore()) { @@ -107,6 +110,7 @@ bool IsSideEffectBarrier(Compiler* comp, GenTree* node, bool blockHasEHSuccs) LclVarDsc const* const dsc = comp->lvaGetDesc(node->AsLclVarCommon()); return !dsc->lvTracked || dsc->lvLiveInOutOfHndlr; } + return false; } } // namespace diff --git a/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs b/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs index 7d01a8fbfab518..565f311657b726 100644 --- a/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs +++ b/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs @@ -163,6 +163,47 @@ static int LocalLiveInFinally(int[] a) } } + static int s_storeObserved; + + [MethodImpl(MethodImplOptions.NoInlining)] + static int StoreBetweenBCs(int[] a) + { + // A heap-visible store between two BCs must act as a barrier: if a + // is too short for a[5], the store to s_storeObserved must still be + // observable (i.e., the strengthened check must not throw IOOB + // before the store). + int t = a[3]; + s_storeObserved = 99; + return t + a[5]; + } + + static int s_callObserved; + + [MethodImpl(MethodImplOptions.NoInlining)] + static void MarkCalled() + { + s_callObserved = 99; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static int CallBetweenBCs(int[] a) + { + // A call between two BCs must act as a barrier even if the callee + // doesn't throw: the call's side effects (here, writing s_callObserved) + // must remain observable when the second BC fails. + int t = a[3]; + MarkCalled(); + return t + a[5]; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static int TwoArrays(int[] a, int[] b) + { + // Two arrays with distinct length VNs interleaved: bounds checks + // for `a` and `b` must not coalesce into a single group. + return a[0] + b[0] + a[3] + b[1]; + } + [Fact] public static int TestEntryPoint() { @@ -247,6 +288,41 @@ public static int TestEntryPoint() if (s_finallyObserved != 99) return 0; + // Heap-visible store between BCs must remain observable when the + // second BC fails. If we (incorrectly) strengthened a[3] to a[5], + // the IOOB would fire before the store and s_storeObserved would + // stay 0. + s_storeObserved = 0; + if (StoreBetweenBCs(arr6) != (arr6[3] + arr6[5])) + return 0; + if (s_storeObserved != 99) + return 0; + s_storeObserved = 0; + Assert.Throws(() => StoreBetweenBCs(new int[4])); + if (s_storeObserved != 99) + return 0; + + // Non-throwing call between BCs must also act as a barrier. + s_callObserved = 0; + if (CallBetweenBCs(arr6) != (arr6[3] + arr6[5])) + return 0; + if (s_callObserved != 99) + return 0; + s_callObserved = 0; + Assert.Throws(() => CallBetweenBCs(new int[4])); + if (s_callObserved != 99) + return 0; + + // Distinct length VNs must not be merged into a single group. + int[] a4 = new int[] { 1, 2, 3, 4 }; + int[] b2 = new int[] { 10, 20 }; + if (TwoArrays(a4, b2) != (a4[0] + b2[0] + a4[3] + b2[1])) + return 0; + // a too short for a[3]: must throw IOOB. + Assert.Throws(() => TwoArrays(new int[3], b2)); + // b too short for b[1]: must throw IOOB. + Assert.Throws(() => TwoArrays(a4, new int[1])); + return 100; } } From 5e914c325b72520f1853b1fd074a907d2985bda0 Mon Sep 17 00:00:00 2001 From: Andy Ayers Date: Tue, 12 May 2026 12:32:35 -0700 Subject: [PATCH 04/11] cleanup and simplifyh --- src/coreclr/jit/boundscheckcoalesce.cpp | 212 ++++++++++++------------ 1 file changed, 110 insertions(+), 102 deletions(-) diff --git a/src/coreclr/jit/boundscheckcoalesce.cpp b/src/coreclr/jit/boundscheckcoalesce.cpp index aa7b68c4bf603a..3615a96bb8ee15 100644 --- a/src/coreclr/jit/boundscheckcoalesce.cpp +++ b/src/coreclr/jit/boundscheckcoalesce.cpp @@ -7,7 +7,7 @@ // Within a single block, when multiple GT_BOUNDS_CHECK nodes share the same // length VN and use constant indices, only the bounds check with the largest // constant index is actually needed. This pass finds such groups and -// strengthens the FIRST bounds check in the group by replacing its constant +// strengthens the first bounds check in the group by replacing its constant // index with the maximum constant index in the group. Forward assertion prop // then drops the now-redundant later bounds checks. // @@ -15,26 +15,11 @@ // indices 0, 1, 2, 3 and the same length. We rewrite the first BC's index // to 3; forward assertion prop then drops the other three as redundant. // -// Safety: -// * Strengthening is sound: if the new (stronger) check passes, all the -// original (weaker) checks would have passed too. If it fails, one of -// the original checks would have failed too -- both throw the same -// IndexOutOfRangeException. -// * We only coalesce bounds checks that are not separated by side effects -// that could change observable exception ordering. We use per-node -// effect flags from GenTree::OperEffects: calls and ordering-side-effect -// nodes (e.g. volatile loads) are barriers; nodes that may throw are -// barriers unless their only exception is IndexOutOfRange (so other -// bounds checks fall through naturally); heap-visible stores are -// barriers, as are local stores whose destination is live across an -// exception handler reachable from this block. -// * We require all candidates in the group to have the same length VN -// and constant non-negative indices. The first BC's index must itself -// be a constant so it can be mutated in place. +// We ensure no observable side effects (other than a bounds check exception +// can occur between the original check and the subsequent checks. // -// This phase runs before PHASE_ASSERTION_PROP_MAIN so that the existing -// forward direction of assertion prop sees the strengthened first BC and -// drops the redundant followers. +// This phase runs before assertion prop, which then optimizes away +// the trailing, now-redundant checks. // #include "jitpch.h" @@ -47,18 +32,19 @@ namespace { struct BoundsCheckCandidate { + // leading bounds check in a candidate set GenTreeBoundsChk* m_bc; - Statement* m_stmt; - ValueNum m_lenVN; - int m_offset; - int m_barrierCount; - BoundsCheckCandidate(GenTreeBoundsChk* bc, Statement* stmt, ValueNum lenVN, int offset, int barrierCount) + // array length being checked + ValueNum m_lenVN; + + // Max index being checked + int m_offset; + + BoundsCheckCandidate(GenTreeBoundsChk* bc, ValueNum lenVN, int offset) : m_bc(bc) - , m_stmt(stmt) , m_lenVN(lenVN) , m_offset(offset) - , m_barrierCount(barrierCount) { } }; @@ -66,25 +52,22 @@ struct BoundsCheckCandidate //------------------------------------------------------------------------ // IsSideEffectBarrier: check if a node blocks bounds check coalescing // -// Returns true if a node may have a side effect that should prevent us from -// reordering an earlier bounds-check failure across it. Uses the per-node -// (non-summary) effect flags from GenTree::OperEffects. -// -// Calls and ordering-side-effect nodes (e.g. volatile loads) are barriers. -// -// A node that may throw is a barrier unless its only possible exception is -// IndexOutOfRange (the same exception our strengthened check throws); this -// is what lets a sibling GT_BOUNDS_CHECK fall through as a non-barrier. +// Arguments: +// comp - the compiler instance +// node - the node to check +// blockHasEHSuccs - whether the block containing the node has reachable EH successors // -// A heap-visible store is a barrier; a store to a tracked local that is not -// live across any exception handler reachable from this block is not. +// Returns: +// true if a node may have a side effect that should prevent us from +// coalescing bounds checks across it. Uses the per-node +// (non-summary) effect flags from GenTree::OperEffects. // bool IsSideEffectBarrier(Compiler* comp, GenTree* node, bool blockHasEHSuccs) { ExceptionSetFlags exSet; GenTreeFlags const effects = node->OperEffects(comp, &exSet); - if ((effects & (GTF_CALL | GTF_ORDER_SIDEEFF)) != 0) + if ((effects & GTF_CALL) != 0) { return true; } @@ -134,21 +117,43 @@ PhaseStatus Compiler::optBoundsCheckCoalesce() return PhaseStatus::MODIFIED_NOTHING; } + // Track the current maximum offset seen for a given length VN + // optimization barrier count. + // + struct Key + { + int m_barrierCount; + ValueNum m_lengthVN; + + Key(int barrierCount, ValueNum lengthVN) + : m_barrierCount(barrierCount) + , m_lengthVN(lengthVN) + { + } + + bool operator==(const Key& other) const + { + return (m_barrierCount == other.m_barrierCount) && (m_lengthVN == other.m_lengthVN); + } + + static bool Equals(const Key& x, const Key& y) + { + return (x.m_barrierCount == y.m_barrierCount) && (x.m_lengthVN == y.m_lengthVN); + } + + static unsigned GetHashCode(const Key& x) + { + return (unsigned)x.m_lengthVN ^ (unsigned)x.m_barrierCount; + } + }; + + typedef JitHashTable GroupMap; + bool modified = false; CompAllocator alloc(getAllocator(CMK_AssertionProp)); - // Per-block scratch state, reused across blocks. The candidates stack - // holds the "head" (first) candidate in each (barrierCount, lenVN) group; - // followers only update the head's running max offset and are not retained. - // groupMap maps a packed (barrierCount, lenVN) key to the candidate index - // of the group head. - typedef JitHashTable, int> GroupMap; - ArrayStack candidates(alloc); - GroupMap groupMap(alloc); - - auto const makeKey = [](int barrierCount, ValueNum lenVN) -> UINT64 { - return (static_cast(static_cast(barrierCount)) << 32) | static_cast(lenVN); - }; + ArrayStack candidates(alloc); + GroupMap groupMap(alloc); for (BasicBlock* const block : Blocks()) { @@ -161,67 +166,70 @@ PhaseStatus Compiler::optBoundsCheckCoalesce() { for (GenTree* const node : stmt->TreeList()) { - if (IsSideEffectBarrier(this, node, blockHasEHSuccs)) + if (node->OperIs(GT_BOUNDS_CHECK)) { - barrierCount++; + GenTreeBoundsChk* const bc = node->AsBoundsChk(); + if (bc->gtThrowKind != SCK_RNGCHK_FAIL) + { + continue; + } + + GenTree* const idx = bc->GetIndex(); + if (!idx->IsIntCnsFitsInI32()) + { + continue; + } + + int const offset = static_cast(idx->AsIntCon()->IconValue()); + if (offset < 0) + { + continue; + } + + // Look through comma-wrapped length nodes. + // + GenTree* const lenNode = bc->GetArrayLength()->gtEffectiveVal(); + ValueNum const lenVN = vnStore->VNConservativeNormalValue(lenNode->gtVNPair); + if (lenVN == ValueNumStore::NoVN) + { + continue; + } + + Key key(barrierCount, lenVN); + int headIndex; + if (!groupMap.Lookup(key, &headIndex)) + { + // First constant-index bounds check with this length VN and this barrier count. + // Start a new group. + // + groupMap.Set(key, candidates.Height()); + candidates.Emplace(bc, lenVN, offset); + continue; + } + + // Following bounds check. See if this is a new max index. + // + BoundsCheckCandidate& head = candidates.BottomRef(headIndex); + JITDUMP("BC coalesce in " FMT_BB ": [%06u] (offset %d) is redundant given [%06u]\n", block->bbNum, + dspTreeID(bc), offset, dspTreeID(head.m_bc)); + if (offset > head.m_offset) + { + head.m_offset = offset; + } continue; } - if (!node->OperIs(GT_BOUNDS_CHECK)) - { - continue; - } - - GenTreeBoundsChk* const bc = node->AsBoundsChk(); - if (bc->gtThrowKind != SCK_RNGCHK_FAIL) - { - continue; - } - - GenTree* const idx = bc->GetIndex(); - if (!idx->IsIntCnsFitsInI32()) - { - continue; - } - - int const offset = static_cast(idx->AsIntCon()->IconValue()); - if (offset < 0) - { - continue; - } - - ValueNum const lenVN = vnStore->VNConservativeNormalValue(bc->GetArrayLength()->gtVNPair); - if (lenVN == ValueNumStore::NoVN) - { - continue; - } - - UINT64 const key = makeKey(barrierCount, lenVN); - int headIndex; - if (!groupMap.Lookup(key, &headIndex)) + if (IsSideEffectBarrier(this, node, blockHasEHSuccs)) { - // First member of this group: record it as the head and keep it - // in the candidates stack so we can strengthen it later. - groupMap.Set(key, candidates.Height()); - candidates.Emplace(bc, stmt, lenVN, offset, barrierCount); + barrierCount++; continue; } - - // Follower: bump the head's running max offset. Once we - // strengthen the head, forward assertion prop will drop us. - BoundsCheckCandidate& head = candidates.BottomRef(headIndex); - JITDUMP("BC coalesce in " FMT_BB ": [%06u] (offset %d) is redundant given [%06u]\n", block->bbNum, - dspTreeID(bc), offset, dspTreeID(head.m_bc)); - if (offset > head.m_offset) - { - head.m_offset = offset; - } } } - // Strengthen each group head whose recorded max exceeds its original - // index. Heads with no stronger follower are left alone -- existing - // forward assertion prop already handles equal-or-weaker followers. + // Revise the check made by the first entry in each group, if we + // found a subsequent check at a higher constant index. + // for (int i = 0; i < candidates.Height(); i++) { BoundsCheckCandidate& head = candidates.BottomRef(i); From 27a87a8d60e77528e44462399e2972dde57c117e Mon Sep 17 00:00:00 2001 From: Andy Ayers Date: Thu, 14 May 2026 11:29:16 -0700 Subject: [PATCH 05/11] add test case for spans showing why we can't optimize them the same way --- .../ReturnBlockRangeCheckCloning.cs | 36 +++++++++++++++++++ .../ReturnBlockRangeCheckCloning.csproj | 7 ++++ 2 files changed, 43 insertions(+) diff --git a/src/tests/JIT/opt/RangeChecks/ReturnBlockRangeCheckCloning.cs b/src/tests/JIT/opt/RangeChecks/ReturnBlockRangeCheckCloning.cs index cc149ec70ffdef..fbc94ed7a04657 100644 --- a/src/tests/JIT/opt/RangeChecks/ReturnBlockRangeCheckCloning.cs +++ b/src/tests/JIT/opt/RangeChecks/ReturnBlockRangeCheckCloning.cs @@ -3,6 +3,7 @@ using System; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using Xunit; // Tests that consecutive range checks in return blocks are combined @@ -28,6 +29,20 @@ static int SpanAccessReturn(ReadOnlySpan span) return span[0] + span[1] + span[2] + span[3]; } + // The element loads dereference span._reference, which can throw NRE. + // Coalescing the four bounds checks by strengthening the first one to + // index 3 would change the observable exception from NRE (thrown by the + // first element load when the bounds check at index 0 passes) into IOOB + // (thrown by the strengthened check at index 3 when _length is smaller). + // This method exists so the test below can verify the precedence is + // preserved: a stack-resident Span with null _reference and _length = 2 + // must throw NullReferenceException, not IndexOutOfRangeException. + [MethodImpl(MethodImplOptions.NoInlining)] + static int NullRefSpanAccessReturn(ReadOnlySpan span) + { + return span[0] + span[1] + span[2] + span[3]; + } + [Fact] public static int TestEntryPoint() { @@ -70,6 +85,27 @@ public static int TestEntryPoint() if (!threwOffset) return 0; + // A ReadOnlySpan with null _reference and _length = 2. + // Accessing span[0] passes the bounds check, then the element load + // dereferences null and must throw NullReferenceException. The bounds + // check for span[2] would throw IndexOutOfRangeException, but we must + // never see that here -- the NRE on span[0]'s element load comes first. + // If a future change to bounds-check coalescing unsoundly strengthens + // the first check to index 3 and removes the followers, IOOB would + // surface in place of NRE and this test would fail. + ReadOnlySpan nullRefSpan = MemoryMarshal.CreateReadOnlySpan(ref Unsafe.NullRef(), 2); + bool threwNullRef = false; + try + { + NullRefSpanAccessReturn(nullRefSpan); + } + catch (NullReferenceException) + { + threwNullRef = true; + } + if (!threwNullRef) + return 0; + return 100; } } diff --git a/src/tests/JIT/opt/RangeChecks/ReturnBlockRangeCheckCloning.csproj b/src/tests/JIT/opt/RangeChecks/ReturnBlockRangeCheckCloning.csproj index de6d5e08882e86..b87c12509ab4f3 100644 --- a/src/tests/JIT/opt/RangeChecks/ReturnBlockRangeCheckCloning.csproj +++ b/src/tests/JIT/opt/RangeChecks/ReturnBlockRangeCheckCloning.csproj @@ -1,8 +1,15 @@ + + + true + True + + From 327148ba3402ae85d48a3652e851285fa104eca9 Mon Sep 17 00:00:00 2001 From: Andy Ayers Date: Sat, 30 May 2026 07:46:03 -0700 Subject: [PATCH 06/11] Fix build: use IsLiveInOutOfHandler() accessor after main rename The lvLiveInOutOfHndlr field was renamed to lvLiveInOutOfHandler in main; use the IsLiveInOutOfHandler() accessor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/coreclr/jit/boundscheckcoalesce.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/coreclr/jit/boundscheckcoalesce.cpp b/src/coreclr/jit/boundscheckcoalesce.cpp index 3615a96bb8ee15..e0977301d3e1cf 100644 --- a/src/coreclr/jit/boundscheckcoalesce.cpp +++ b/src/coreclr/jit/boundscheckcoalesce.cpp @@ -91,7 +91,7 @@ bool IsSideEffectBarrier(Compiler* comp, GenTree* node, bool blockHasEHSuccs) return false; } LclVarDsc const* const dsc = comp->lvaGetDesc(node->AsLclVarCommon()); - return !dsc->lvTracked || dsc->lvLiveInOutOfHndlr; + return !dsc->lvTracked || dsc->IsLiveInOutOfHandler(); } return false; From 34b8bf108cd0b14250a680705eb59bc7733ac67d Mon Sep 17 00:00:00 2001 From: Andy Ayers Date: Mon, 1 Jun 2026 19:39:12 -0700 Subject: [PATCH 07/11] Fix bounds-check coalesce barriers for helper-call and non-RNGCHK nodes optBoundsCheckCoalesce could move a strengthened range check across nodes it should treat as barriers: - Nodes that lower to a helper call (e.g. variable-distance long shifts on 32-bit targets) require the call flag but are not GT_CALL. Querying OperEffects/OperRequiresGlobRefFlag for them tripped an assert during 'Coalesce bounds checks' on 32-bit targets (linux-arm/armel crossgen2). Treat any OperRequiresCallFlag node as a barrier up front. - Honor GTF_ORDER_SIDEEFF in addition to GTF_CALL when deciding barriers. - A GT_BOUNDS_CHECK whose throw kind is not SCK_RNGCHK_FAIL throws a different exception and must act as a barrier (barrierCount++) rather than being skipped, so the observed exception is not changed. Adds regression test SimdLoadBetweenBCs covering the non-RNGCHK barrier case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/coreclr/jit/boundscheckcoalesce.cpp | 22 +++++++++++++++- .../JIT/opt/RangeChecks/ElidedBoundsChecks.cs | 26 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/coreclr/jit/boundscheckcoalesce.cpp b/src/coreclr/jit/boundscheckcoalesce.cpp index e0977301d3e1cf..95a49da09248ec 100644 --- a/src/coreclr/jit/boundscheckcoalesce.cpp +++ b/src/coreclr/jit/boundscheckcoalesce.cpp @@ -64,10 +64,24 @@ struct BoundsCheckCandidate // bool IsSideEffectBarrier(Compiler* comp, GenTree* node, bool blockHasEHSuccs) { + // A node that lowers to a helper call requires the call flag but is not a + // GT_CALL (for example, a variable-distance long shift on 32-bit targets). + // Treat such nodes as barriers up front: they are effectively calls, and + // OperEffects/OperRequiresGlobRefFlag do not expect to be queried for them. + // + if (node->OperRequiresCallFlag(comp)) + { + return true; + } + ExceptionSetFlags exSet; GenTreeFlags const effects = node->OperEffects(comp, &exSet); - if ((effects & GTF_CALL) != 0) + // Calls are barriers, as are nodes whose evaluation order is explicitly + // constrained (GTF_ORDER_SIDEEFF): coalescing must not move a strengthened + // check across an operation the IR says cannot be reordered. + // + if ((effects & (GTF_CALL | GTF_ORDER_SIDEEFF)) != 0) { return true; } @@ -171,6 +185,12 @@ PhaseStatus Compiler::optBoundsCheckCoalesce() GenTreeBoundsChk* const bc = node->AsBoundsChk(); if (bc->gtThrowKind != SCK_RNGCHK_FAIL) { + // A bounds check with a different throw kind throws a + // different exception. Treat it as a barrier so we do not + // reorder a strengthened range check across it and change + // which exception is observed. + // + barrierCount++; continue; } diff --git a/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs b/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs index 9b2c6861257179..b6b8aefeef8d42 100644 --- a/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs +++ b/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs @@ -3,6 +3,7 @@ using System; using System.Runtime.CompilerServices; +using System.Runtime.Intrinsics; using System.Runtime.Intrinsics.Arm; using System.Runtime.Intrinsics.X86; using Xunit; @@ -204,6 +205,20 @@ static int TwoArrays(int[] a, int[] b) return a[0] + b[0] + a[3] + b[1]; } + [MethodImpl(MethodImplOptions.NoInlining)] + static float SimdLoadBetweenBCs(float[] a) + { + // The contiguous Vector128.Create from a[1..4] is lowered to a single + // SIMD load whose bounds check throws ArgumentOutOfRangeException, not + // IndexOutOfRangeException. It must act as a barrier: if a[0]'s check + // were strengthened to a[7] across it, a too-short array would observe + // IndexOutOfRangeException instead of the ArgumentOutOfRangeException + // the SIMD load is required to throw first. + float x = a[0]; + Vector128 v = Vector128.Create(a[1], a[2], a[3], a[4]); + return x + v.ToScalar() + a[7]; + } + [MethodImpl(MethodImplOptions.NoInlining)] static int UnsignedShiftBySignBit(int i) { @@ -335,6 +350,17 @@ public static int TestEntryPoint() if (UnsignedShiftBySignBit(-1) != 1 || UnsignedShiftBySignBit(0) != 0) return 0; + // A SIMD load with a non-IOOB bounds check (ArgumentOutOfRangeException) + // between two array checks must act as a barrier: a[0]'s check must not + // be strengthened across it, otherwise a short array would observe + // IndexOutOfRangeException instead of ArgumentOutOfRangeException. + if (Vector128.IsHardwareAccelerated) + { + if (SimdLoadBetweenBCs(new float[8]) != 0f) + return 0; + Assert.Throws(() => SimdLoadBetweenBCs(new float[1])); + } + return 100; } } From d7ac16254e28ed62f3ab9b9b002bc819ff957469 Mon Sep 17 00:00:00 2001 From: Andy Ayers Date: Tue, 2 Jun 2026 18:20:00 -0700 Subject: [PATCH 08/11] Gate RyuJIT-specific SIMD bounds-check assertion to CoreCLR The SimdLoadBetweenBCs assertion relies on RyuJIT recognizing a contiguous Vector128.Create as a single SIMD load that throws ArgumentOutOfRangeException. Mono has no such lowering, so the element access throws IndexOutOfRangeException and the exact-type assertion fails. Skip that assertion on Mono while keeping the strict check on CoreCLR where the optBoundsCheckCoalesce pass runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs b/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs index b6b8aefeef8d42..f32c45ccdd1bc7 100644 --- a/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs +++ b/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs @@ -10,6 +10,8 @@ public class ElidedBoundsChecks { + private static readonly bool IsMonoRuntime = Type.GetType("Mono.Runtime") is not null; + [MethodImpl(MethodImplOptions.NoInlining)] static int ComplexBinaryOperators(byte inData) { @@ -354,7 +356,10 @@ public static int TestEntryPoint() // between two array checks must act as a barrier: a[0]'s check must not // be strengthened across it, otherwise a short array would observe // IndexOutOfRangeException instead of ArgumentOutOfRangeException. - if (Vector128.IsHardwareAccelerated) + // The contiguous Vector128.Create -> single SIMD load recognition and the + // bounds-check coalescing being validated here are RyuJIT-specific, so + // only assert the exact exception type on CoreCLR. + if (!IsMonoRuntime && Vector128.IsHardwareAccelerated) { if (SimdLoadBetweenBCs(new float[8]) != 0f) return 0; From d9c8f76e37ee55d604a3dbb42389e4bbded45fa7 Mon Sep 17 00:00:00 2001 From: Andy Ayers Date: Wed, 3 Jun 2026 08:31:07 -0700 Subject: [PATCH 09/11] Use correct Mono detection probe in ElidedBoundsChecks The previous gate used Type.GetType("Mono.Runtime"), which is not present in the Mono runtime-test configurations, so IsMonoRuntime evaluated to false and the RyuJIT-specific SIMD assertion still ran on Mono. Switch to the canonical Type.GetType("Mono.RuntimeStructs") probe used by the test suite's TestLibrary.Utilities.IsMonoRuntime so the assertion is correctly skipped on Mono. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs b/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs index f32c45ccdd1bc7..938b6f8ad588f6 100644 --- a/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs +++ b/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs @@ -10,7 +10,7 @@ public class ElidedBoundsChecks { - private static readonly bool IsMonoRuntime = Type.GetType("Mono.Runtime") is not null; + private static readonly bool IsMonoRuntime = Type.GetType("Mono.RuntimeStructs") is not null; [MethodImpl(MethodImplOptions.NoInlining)] static int ComplexBinaryOperators(byte inData) From bcaac840fe9fbfc2a24720ae1675d72b371c958c Mon Sep 17 00:00:00 2001 From: Andy Ayers Date: Wed, 3 Jun 2026 08:35:56 -0700 Subject: [PATCH 10/11] Fix unmatched parenthesis in boundscheckcoalesce header comment Add the missing closing parenthesis after "bounds check exception" in the file-header description, per review feedback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/coreclr/jit/boundscheckcoalesce.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/coreclr/jit/boundscheckcoalesce.cpp b/src/coreclr/jit/boundscheckcoalesce.cpp index 95a49da09248ec..4cd605671b4fd6 100644 --- a/src/coreclr/jit/boundscheckcoalesce.cpp +++ b/src/coreclr/jit/boundscheckcoalesce.cpp @@ -15,7 +15,7 @@ // indices 0, 1, 2, 3 and the same length. We rewrite the first BC's index // to 3; forward assertion prop then drops the other three as redundant. // -// We ensure no observable side effects (other than a bounds check exception +// We ensure no observable side effects (other than a bounds check exception) // can occur between the original check and the subsequent checks. // // This phase runs before assertion prop, which then optimizes away From bab6fd48ea21b2221cf9d886442f9947a2b31afc Mon Sep 17 00:00:00 2001 From: Andy Ayers Date: Wed, 3 Jun 2026 15:22:41 -0700 Subject: [PATCH 11/11] Use TestLibrary.Utilities.IsMonoRuntime in ElidedBoundsChecks Per review feedback, replace the local Mono detection field with the shared TestLibrary.Utilities.IsMonoRuntime helper and add the CoreCLRTestLibrary project reference. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs | 4 +--- src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.csproj | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs b/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs index 938b6f8ad588f6..23d32a2f7304cb 100644 --- a/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs +++ b/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.cs @@ -10,8 +10,6 @@ public class ElidedBoundsChecks { - private static readonly bool IsMonoRuntime = Type.GetType("Mono.RuntimeStructs") is not null; - [MethodImpl(MethodImplOptions.NoInlining)] static int ComplexBinaryOperators(byte inData) { @@ -359,7 +357,7 @@ public static int TestEntryPoint() // The contiguous Vector128.Create -> single SIMD load recognition and the // bounds-check coalescing being validated here are RyuJIT-specific, so // only assert the exact exception type on CoreCLR. - if (!IsMonoRuntime && Vector128.IsHardwareAccelerated) + if (!TestLibrary.Utilities.IsMonoRuntime && Vector128.IsHardwareAccelerated) { if (SimdLoadBetweenBCs(new float[8]) != 0f) return 0; diff --git a/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.csproj b/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.csproj index dbc3ab7f2f9596..95a6680e89ffed 100644 --- a/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.csproj +++ b/src/tests/JIT/opt/RangeChecks/ElidedBoundsChecks.csproj @@ -12,6 +12,8 @@ true + +