Repository navigation
Description
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
cfallin commentedon Nov 9, 2026
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.
wraitii commentedon Nov 9, 2026
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.