Skip to content

Split hoisting can select unrelated loops / has bad edge cases #273

Open

Description

@wraitii

When splitting lifetimes for register allocations, 'split hoisting' starts with a split at a register conflict. If that point is deeper than the bundle’s earliest range, it scans forward from the bundle start and selects the first block deeper than that start. It checks neither loop membership nor dominance, and ignores intervening uses.

There are a few cases:

Bad: conditional call that rejoins - identical with trimming only

As pseudocode:

acc = 0
for k in 0..n:
    if k == 17:
        helper()
    acc += 1
return acc

Split hoisting decides to put the split before the loop, leading to a store/load around acc += 1. Trimming does the same thing independently.
In both cases this is arguably bad - only the if-branch should spill.

Bad: a later loop can spill in an earlier loop - trimming also unless #272

Slightly contrived example, but it's not implausible either:

if fast_path:
    acc = 0
    while n-- != 0:
        acc += 1
else:
    acc = 1
    while n-- != 0:
        helper() // opaque, clobbers acc
        acc += 1
return acc

here the join at the end puts both loop acc in the same bundle, and hoisting ends up putting the split before the fast-path loop. This leads to spills in both loops, even though only the slow path would need them.

Neutral: loop with one use or a later use - trimming is identical

acc = n * n;
for a in 0..3:
    opaque_call()
return acc;

Here both hoisting and trimming end up with the same logic: store once before, reload once after (of if used after call(), reload in the loop).

Good case: use before a call in a loop - beats trimming (depending)

x = argument
for n 0..times:
    observe(x)
    helper()
return x

Here hoisting leads to a store before the loop and a reload before the observe call. Trimming instead stores before helper and reloads after helper. Note that this leads to hoisting always having at least one store even if times == 0, in my contrived example.


There might be a few more situations, but that's what I found so far. See attachment for CLIF: split-hoisting-repro.md

Hoisting predates trimming in this codebase, so the partial redundancy probably comes from there.

There are a couple possible fixes:

  • Remove hoisting. It's slightly faster to run and occasionally a little worse than just trimming, but maybe not enough to really matter?
  • Remove hoisting and separately try to improve stores - this would make trimming equivalent to hoisting. Trimming separately probably should be smarter around control-flow, see Avoid trimming spill ranges into deeper loops #272 .
  • Restrict hoisting candidates to the loop & its parents. This solves case 2 but not case 1.
  • Fancier fix that tries to account for conditionals.
  • Maybe other ideas I haven't had.

I ran into both of the early cases while working on a JIT, hence the 'fallback with similar structure' pattern, but none seem that outlandish that common code wouldn't also trigger them to me.

Activity

  1. cfallin commented on Nov 9, 2026

    @cfallin
    Member

    Thanks for this writeup!

    I'm open to PRs if benchmarks show wins. I'll note that a lot of the heuristic-tuning work in RA2 is balanced on a knife edge (as I suspect it would be in most production register allocators): a solution that seems obvious for one case may regress another case in a way that is hard to address without a second fix that causes a third regression, etc etc. It's easy to come up with cases where any given heuristic doesn't do optimally well -- that is fundamentally true for any approximation-solution to an NP-complete optimization problem. At least at the time that I wrote the allocator (circa 2021), split hoisting did improve performance; but if there are other heuristics that work better on our benchmarks in geomean (and don't cause any regressions), I'm happy to see them!

    One other thing I try to hold to is to have a sound "theory of operation" for any given change: a plausible reason why it should work well. Hoisting in general makes sense in theory because it tries to put a split (which usually implies a move/load/store instruction) further up a loop nest, reducing dynamic instruction count. It'd be good to have such a justification for any further change we make as well.

  2. wraitii commented on Nov 9, 2026

    @wraitii
    Author

    Yeah, I suspect as much, and I'm not really confident that any of the fix ideas I offer earlier are good enough to take the risk, so I mostly wrote it up to keep some record of the issue. I have a hunch solidly better solutions here might be a fair bit more expensive in theirs of compilation time, which might not be acceptable for this project.

    I have found some workarounds for specific cases by wrapping values via opaque identity calls or forcing stack spills in CLIF.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions