JIT: gate inversion of bottom-tested no-IV loops on a hoisting benefit#130368
JIT: gate inversion of bottom-tested no-IV loops on a hoisting benefit#130368AndyAyersMS wants to merge 2 commits into
Conversation
Under JitLoopInversionRequireBenefitForBottomTested (off by default), skip inverting an already bottom-tested loop with no recognized IV unless the duplicated condition holds a call or a loop-invariant, hoistable load. Targets the arm64 regressions in dotnet#130045 while keeping the Dictionary Enumerator.MoveNext inversion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch |
|
@EgorBot -arm64 --envvars DOTNET_JitLoopInversionRequireBenefitForBottomTested:1 using System;
using System.Numerics;
using BenchmarkDotNet.Attributes;
public class Bench
{
private readonly int[] _a = new int[512];
private readonly int[] _b = new int[512];
private BigInteger _x, _y;
[GlobalSetup]
public void Setup()
{
for (int i = 0; i < _a.Length; i++) { _a[i] = i; _b[i] = i; }
byte[] bytes = new byte[259];
new Random(42).NextBytes(bytes);
bytes[^1] &= 0x7f;
_x = new BigInteger(bytes);
_y = new BigInteger(bytes);
}
[Benchmark]
public bool Span_SequenceEqual() => _a.AsSpan().SequenceEqual(_b);
[Benchmark]
public int Span_IndexOf_NotFound() => _a.AsSpan().IndexOf(-1);
[Benchmark]
public int BigInteger_CompareTo() => _x.CompareTo(_y);
[Benchmark]
public bool BigInteger_Equals() => _x.Equals(_y);
}Base is Note Comment generated with GitHub Copilot CLI. |
There was a problem hiding this comment.
Pull request overview
This PR adds an (opt-in) heuristic gate to optTryInvertWhileLoop so that when a loop is already bottom-tested and AnalyzeIteration recognizes no IV, loop inversion is only performed if duplicating the condition block appears to have a “benefit” (call or potentially hoistable memory load). It also introduces a new JIT config knob to enable this gate.
Changes:
- Track “exiting cond latch seen” separately from “IV-test latch” and only apply the new gate in the bottom-tested + no-IV case.
- Classify
condBlockfor calls/indirections and use the existing loop size walk to conservatively detect stores to locals used in indirection address expressions. - Add
JitLoopInversionRequireBenefitForBottomTestedconfig (default off).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/coreclr/jit/optimizer.cpp |
Adds the benefit-based gate and condition classification for bottom-tested no-IV loops. |
src/coreclr/jit/jitconfigvalues.h |
Introduces the new release config switch controlling the gate. |
| bool condHasCall = false; | ||
| bool condHasIndir = false; | ||
| bool condBaseStored = false; | ||
| BitVecTraits condTraits(lvaCount, this); | ||
| BitVec condIndirBaseLocals(BitVecOps::MakeEmpty(&condTraits)); | ||
| if (checkBenefit) | ||
| { | ||
| assert(analyzedIteration); | ||
|
|
There was a problem hiding this comment.
Good catch — deferred the allocation: condIndirAddrLocals is now initialized to BitVecOps::UninitVal() and only MakeEmpty-d inside if (checkBenefit), so nothing is allocated when the config is off. (reply via Copilot CLI)
| // Classify condBlock here and record the locals used as bases of its indirections; the size-check | ||
| // walk below marks whether any such base is assigned in the loop (making the load loop-variant). |
There was a problem hiding this comment.
Fixed — reworded to "locals appearing in its indirection address expressions", and renamed condIndirBaseLocals→condIndirAddrLocals / m_baseLocals→m_addrLocals to match what CollectLocals actually does. (reply via Copilot CLI)
| // When set, skip inverting a loop that is already bottom-tested (has an exiting BBJ_COND latch) and | ||
| // has no recognized induction variable, unless the loop-continuation test that would be duplicated | ||
| // contains a call or a loop-invariant (hence hoistable) memory load. |
There was a problem hiding this comment.
Softened — the comment now says "a memory load whose address has no loop-varying local" and drops the loop-invariant/hoistable claim. (reply via Copilot CLI)
|
Re-running on server arm64 (Cobalt 100) — the earlier @EgorBot -linux_arm64 --envvars DOTNET_JitLoopInversionRequireBenefitForBottomTested:1 using System;
using System.Numerics;
using BenchmarkDotNet.Attributes;
public class Bench
{
private readonly int[] _a = new int[512];
private readonly int[] _b = new int[512];
private BigInteger _x, _y;
[GlobalSetup]
public void Setup()
{
for (int i = 0; i < _a.Length; i++) { _a[i] = i; _b[i] = i; }
byte[] bytes = new byte[259];
new Random(42).NextBytes(bytes);
bytes[^1] &= 0x7f;
_x = new BigInteger(bytes);
_y = new BigInteger(bytes);
}
[Benchmark]
public bool Span_SequenceEqual() => _a.AsSpan().SequenceEqual(_b);
[Benchmark]
public int Span_IndexOf_NotFound() => _a.AsSpan().IndexOf(-1);
[Benchmark]
public int BigInteger_CompareTo() => _x.CompareTo(_y);
[Benchmark]
public bool BigInteger_Equals() => _x.Equals(_y);
}Base is Note Comment generated with GitHub Copilot CLI. |
|
Adding the two biggest #130045 regressions ( @EgorBot -linux_arm64 -ubuntu24_azure_genoa -windows_x64 --envvars DOTNET_JitLoopInversionRequireBenefitForBottomTested:1 using System;
using System.Buffers;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using System.Runtime.CompilerServices;
using BenchmarkDotNet.Attributes;
public class Bench
{
private SearchValues<char> _searchValues;
private char[] _textExcept;
private int[] _found;
private Dictionary<int, int> _dictionary;
private readonly int[] _a = new int[512];
private readonly int[] _b = new int[512];
private BigInteger _x, _y;
[GlobalSetup]
public void Setup()
{
_searchValues = SearchValues.Create("ßäöüÄÖÜ");
_textExcept = new string('ß', 256).ToCharArray();
_textExcept[128] = '\n';
_found = Enumerable.Range(0, 512).ToArray();
_dictionary = _found.ToDictionary(k => k, k => k);
for (int i = 0; i < _a.Length; i++) { _a[i] = i; _b[i] = i; }
byte[] bytes = new byte[259];
new Random(42).NextBytes(bytes);
bytes[^1] &= 0x7f;
_x = new BigInteger(bytes);
_y = new BigInteger(bytes);
}
[Benchmark]
public int LastIndexOfAnyExcept() => _textExcept.AsSpan().LastIndexOfAnyExcept(_searchValues);
[Benchmark]
public bool ContainsKeyTrue_IDictionary() => ContainsKey(_dictionary);
[MethodImpl(MethodImplOptions.NoInlining)]
private bool ContainsKey(IDictionary<int, int> collection)
{
bool result = false;
var found = _found;
for (int i = 0; i < found.Length; i++)
result ^= collection.ContainsKey(found[i]);
return result;
}
[Benchmark]
public bool Span_SequenceEqual() => _a.AsSpan().SequenceEqual(_b);
[Benchmark]
public int BigInteger_CompareTo() => _x.CompareTo(_y);
}Base is Note Comment generated with GitHub Copilot CLI. |
|
Also on Ampere (Neoverse-N1) — the core class behind the @EgorBot -ubuntu24_azure_ampere --envvars DOTNET_JitLoopInversionRequireBenefitForBottomTested:1 using System;
using System.Buffers;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using System.Runtime.CompilerServices;
using BenchmarkDotNet.Attributes;
public class Bench
{
private SearchValues<char> _searchValues;
private char[] _textExcept;
private int[] _found;
private Dictionary<int, int> _dictionary;
private readonly int[] _a = new int[512];
private readonly int[] _b = new int[512];
private BigInteger _x, _y;
[GlobalSetup]
public void Setup()
{
_searchValues = SearchValues.Create("ßäöüÄÖÜ");
_textExcept = new string('ß', 256).ToCharArray();
_textExcept[128] = '\n';
_found = Enumerable.Range(0, 512).ToArray();
_dictionary = _found.ToDictionary(k => k, k => k);
for (int i = 0; i < _a.Length; i++) { _a[i] = i; _b[i] = i; }
byte[] bytes = new byte[259];
new Random(42).NextBytes(bytes);
bytes[^1] &= 0x7f;
_x = new BigInteger(bytes);
_y = new BigInteger(bytes);
}
[Benchmark]
public int LastIndexOfAnyExcept() => _textExcept.AsSpan().LastIndexOfAnyExcept(_searchValues);
[Benchmark]
public bool ContainsKeyTrue_IDictionary() => ContainsKey(_dictionary);
[MethodImpl(MethodImplOptions.NoInlining)]
private bool ContainsKey(IDictionary<int, int> collection)
{
bool result = false;
var found = _found;
for (int i = 0; i < found.Length; i++)
result ^= collection.ContainsKey(found[i]);
return result;
}
[Benchmark]
public bool Span_SequenceEqual() => _a.AsSpan().SequenceEqual(_b);
[Benchmark]
public int BigInteger_CompareTo() => _x.CompareTo(_y);
}Base is Note Comment generated with GitHub Copilot CLI. |
|
@EgorBot -ubuntu24_azure_ampere --envvars DOTNET_JitLoopInversionRequireBenefitForBottomTested:1 --filter "ContainsKeyTrueInt32IDictionary" Real perf-repo benchmark this time (random Note Comment generated with GitHub Copilot CLI. |
|
Retry of the @EgorBot -ubuntu24_azure_ampere --envvars DOTNET_JitLoopInversionRequireBenefitForBottomTested:1 --filter "ContainsKeyTrueInt32IDictionary" Base is Note Comment generated with GitHub Copilot CLI. |
EgorBot validation summaryRan the #130045 cases across the hardware classes behind the perf lab, comparing Hardware → lab mapping: Ampere N1 = The two biggest regressions recover to their lab baselines
The PR numbers land right on the pre-#129868 lab baselines. Full matrix (fix effect on the benchmark's Mean; negative = faster)
Reading it
Notes: Note Comment generated with GitHub Copilot CLI. |
Defer the benefit-gate bitvec allocation to when the config is on, correct the "indirection address locals" wording, and soften the config comment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| void CollectLocals(GenTree* tree) | ||
| { | ||
| if (tree->OperIsLocal()) | ||
| { | ||
| BitVecOps::AddElemD(m_traits, *m_addrLocals, tree->AsLclVarCommon()->GetLclNum()); | ||
| } | ||
| tree->VisitOperands([&](GenTree* op) -> GenTree::VisitResult { | ||
| CollectLocals(op); | ||
| return GenTree::VisitResult::Continue; | ||
| }); | ||
| } |
| // When set, skip inverting a loop that is already bottom-tested (has an exiting BBJ_COND latch) and | ||
| // has no recognized induction variable, unless the loop-continuation test that would be duplicated | ||
| // contains a call or a memory load whose address has no loop-varying local. |
Behind
DOTNET_JitLoopInversionRequireBenefitForBottomTested(off by default) for now.When a loop is already bottom-tested (it has an exiting
BBJ_CONDlatch that is not the recognized IV test) andAnalyzeIterationfinds no IV,optTryInvertWhileLoopstill inverts today, adding a duplicated zero-trip test plus a peeled iteration. Loop cloning can't follow up (it also needs the IV), so on arm64 this is often pure overhead — see #130045, the collateral from #129868.This gates that case: only invert when the block we would duplicate (
condBlock) carries a benefit worth bottom-testing — a call, or an indirection whose base is not assigned in the loop (a loop-invariant, hoistable load). The base-assignment check piggybacks on the existing size-limit walk, so there's no extra loop traversal.The point of the gate is that the good and bad cases are otherwise structurally identical (multi-exit loop, exiting-cond latch, no recognized IV):
Dictionary.Enumerator.MoveNext— condition_index < _dictionary._count;_countis an invariant field load → kept.BigIntegerCalculator.Compare—--i >= 0, pure arithmetic → skipped.SpanHelpers.SequenceEqual/NonPackedContainsValueType—a[i], loop-variant → skipped.SPMI (benchmarks.run, checked)
With the config enabled:
Compare172→116,SequenceEqual320→292,NonPackedContainsValueType200→168 / 456→424 / 432→404); ~197 other inversions are preserved that a straight revert would lose.Dictionary.Enumerator.MoveNextstays inverted (146 bytes, unchanged), so the JIT: only skip loop inversion when the IV test is the bottom test #129868 win is kept.Local timing doesn't reproduce the regression on Apple Silicon, so posting EgorBot arm64 runs below for the key cases.
Note
This change and PR description were produced with GitHub Copilot CLI.