InstCombine code sinking creates pathological register pressure in some unrolled loops
- Dominant language
- LLVM
- Stars
- 40.5k
- Forks
- 18.7k
- PR merge metrics
- PR metrics pending
Description
LLM disclosure: I used an LLM to help reduce my original bad code to a minimal reproducer. The final reproducer was hand-written, and all analysis and words are my own.
First reported to Rust in https://github.com/rust-lang/rust/issues/160957. I'm reproducing some of the rust aspects here to motivate the issue, but after the intro the focus is on LLVM IR transforms.
## Rust stuff
This small function, compiled for x86_64, produces asm that spills 640 bytes to stack while attempting to sum 64 u64s:
```rust
#[inline(never)]
pub fn all_the_spills(input: &[u64; 1024], out: &mut u64, other: &mut u64, flag: bool) {
let mut o = 0u64;
for a in 0..64 {
// The `* 16` is just to defeat the autovectorizer and make the problem more obvious in the
// asm output. Still happens with `input[a]`, but less obvious because SSE2 reduces the
// number of loads required.
o += input[a * 16];
}
// Removing just this store produces good asm.
*other = 1;
// Removing just this branch (i.e. make `*out = o` unconditional) produces good asm.
if flag {
*out = o;
}
}
```
Generated asm produced by `cargo rustc --release -- --emit asm`
```asm
.att_syntax
.file "repro.8e6011e3694ac10c-cgu.0"
.section .text._RNvCscdRkKAp4ykW_5repro14all_the_spills,"ax",@progbits
.globl _RNvCscdRkKAp4ykW_5repro14all_the_spills
.prefalign 4, .Lfunc_end0, nop
.type _RNvCscdRkKAp4ykW_5repro14all_the_spills,@function
_RNvCscdRkKAp4ykW_5repro14all_the_spills:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
pushq %r15
.cfi_def_cfa_offset 24
pushq %r14
.cfi_def_cfa_offset 32
pushq %r13
.cfi_def_cfa_offset 40
pushq %r12
.cfi_def_cfa_offset 48
pushq %rbx
.cfi_def_cfa_offset 56
subq $280, %rsp
.cfi_def_cfa_offset 336
.cfi_offset %rbx, -56
.cfi_offset %r12, -48
.cfi_offset %r13, -40
.cfi_offset %r14, -32
.cfi_offset %r15, -24
.cfi_offset %rbp, -16
movl %ecx, -120(%rsp)
movq %rsi, 272(%rsp)
movq (%rdi), %rax
movq %rax, 168(%rsp)
movq 128(%rdi), %rax
movq %rax, -128(%rsp)
movq 256(%rdi), %rax
movq %rax, 160(%rsp)
movq 384(%rdi), %r10
movq 512(%rdi), %rax
movq %rax, 192(%rsp)
movq 640(%rdi), %r9
movq 768(%rdi), %rax
movq %rax, 248(%rsp)
movq 896(%rdi), %rax
movq %rax, 232(%rsp)
movq 1024(%rdi), %rax
movq %rax, 208(%rsp)
movq 1152(%rdi), %rax
movq %rax, 240(%rsp)
movq 1280(%rdi), %rax
movq %rax, 264(%rsp)
movq $1, (%rdx)
movq 1408(%rdi), %rax
movq %rax, -112(%rsp)
movq 1536(%rdi), %rcx
movq 1664(%rdi), %r11
movq 1792(%rdi), %rdx
movq 1920(%rdi), %r13
movq 2048(%rdi), %rax
movq %rax, -104(%rsp)
movq 2176(%rdi), %rbp
movq 2304(%rdi), %r15
movq 2432(%rdi), %rax
movq 2560(%rdi), %rsi
movq %rsi, -96(%rsp)
movq 2688(%rdi), %r12
movq 2816(%rdi), %rsi
movq %rsi, -88(%rsp)
movq 2944(%rdi), %rsi
movq %rsi, -80(%rsp)
movq 3072(%rdi), %rsi
movq %rsi, -72(%rsp)
movq 3200(%rdi), %rsi
movq %rsi, -64(%rsp)
movq 3328(%rdi), %rsi
movq %rsi, -56(%rsp)
movq 3456(%rdi), %rsi
movq %rsi, -48(%rsp)
movq 3584(%rdi), %r14
movq 3712(%rdi), %rsi
movq %rsi, -40(%rsp)
movq 3840(%rdi), %rsi
movq %rsi, -32(%rsp)
movq 3968(%rdi), %rsi
movq %rsi, -24(%rsp)
movq 4096(%rdi), %rsi
movq %rsi, -16(%rsp)
movq 4224(%rdi), %rsi
movq %rsi, -8(%rsp)
movq 4352(%rdi), %rsi
movq %rsi, (%rsp)
movq 4480(%rdi), %rsi
movq %rsi, 8(%rsp)
movq 4608(%rdi), %rbx
movq 4736(%rdi), %rsi
movq %rsi, 16(%rsp)
movq 4864(%rdi), %rsi
movq %rsi, 24(%rsp)
movq 4992(%rdi), %rsi
movq %rsi, 32(%rsp)
movq 5120(%rdi), %rsi
movq %rsi, 40(%rsp)
movq 5248(%rdi), %rsi
movq %rsi, 48(%rsp)
movq 5376(%rdi), %rsi
movq %rsi, 56(%rsp)
movq 5504(%rdi), %rsi
movq %rsi, 64(%rsp)
movq 5632(%rdi), %rsi
movq %rsi, 72(%rsp)
movq 5760(%rdi), %rsi
movq 5888(%rdi), %r8
movq %r8, 80(%rsp)
movq 6016(%rdi), %r8
movq %r8, 88(%rsp)
movq 6144(%rdi), %r8
movq %r8, 96(%rsp)
movq 6272(%rdi), %r8
movq %r8, 104(%rsp)
movq 6400(%rdi), %r8
movq %r8, 112(%rsp)
movq 6528(%rdi), %r8
movq %r8, 120(%rsp)
movq 6656(%rdi), %r8
movq %r8, 128(%rsp)
movq 6784(%rdi), %r8
movq %r8, 136(%rsp)
movq 6912(%rdi), %r8
movq %r8, 144(%rsp)
movq 7040(%rdi), %r8
movq %r8, 256(%rsp)
movq 7168(%rdi), %r8
movq %r8, 152(%rsp)
movq 7296(%rdi), %r8
movq %r8, 176(%rsp)
movq 7424(%rdi), %r8
movq %r8, 184(%rsp)
movq 7552(%rdi), %r8
movq %r8, 200(%rsp)
movq 7680(%rdi), %r8
movq %r8, 216(%rsp)
movq 7808(%rdi), %r8
movq %r8, 224(%rsp)
movq 7936(%rdi), %r8
movq 8064(%rdi), %rdi
cmpl $0, -120(%rsp)
je .LBB0_2
movq %rdi, -120(%rsp)
movq %r9, %rdi
movq -128(%rsp), %r9
addq 168(%rsp), %r9
addq 160(%rsp), %r10
addq %r9, %r10
addq 192(%rsp), %rdi
movq %r8, -128(%rsp)
movq 248(%rsp), %r8
addq %rdi, %r8
addq %r10, %r8
movq 208(%rsp), %r10
addq 232(%rsp), %r10
movq 240(%rsp), %r9
addq %r10, %r9
movq 264(%rsp), %r10
addq %r9, %r10
addq %r8, %r10
addq -112(%rsp), %rcx
addq %rcx, %r11
addq %r11, %rdx
addq %rdx, %r13
addq %r10, %r13
addq -104(%rsp), %rbp
addq %rbp, %r15
addq %r15, %rax
movq -96(%rsp), %rcx
addq %rax, %rcx
addq %rcx, %r12
addq %r13, %r12
movq -80(%rsp), %rax
addq -88(%rsp), %rax
movq -72(%rsp), %rcx
addq %rax, %rcx
movq -64(%rsp), %rax
addq %rcx, %rax
movq -56(%rsp), %rcx
addq %rax, %rcx
movq -48(%rsp), %rax
addq %rcx, %rax
addq %rax, %r14
addq %r12, %r14
movq -32(%rsp), %rcx
addq -40(%rsp), %rcx
movq -24(%rsp), %rax
addq %rcx, %rax
movq -16(%rsp), %rcx
addq %rax, %rcx
movq -8(%rsp), %rax
addq %rcx, %rax
movq (%rsp), %rcx
addq %rax, %rcx
movq 8(%rsp), %rax
addq %rcx, %rax
addq %rax, %rbx
addq %r14, %rbx
movq 24(%rsp), %rax
addq 16(%rsp), %rax
movq 32(%rsp), %rcx
addq %rax, %rcx
movq 40(%rsp), %rax
addq %rcx, %rax
movq 48(%rsp), %rcx
addq %rax, %rcx
movq 56(%rsp), %rax
addq %rcx, %rax
movq 64(%rsp), %rcx
addq %rax, %rcx
movq 72(%rsp), %rax
addq %rcx, %rax
addq %rax, %rsi
addq %rbx, %rsi
movq 88(%rsp), %rax
addq 80(%rsp), %rax
movq 96(%rsp), %rcx
addq %rax, %rcx
movq 104(%rsp), %rax
addq %rcx, %rax
movq 112(%rsp), %rcx
addq %rax, %rcx
movq 120(%rsp), %rax
addq %rcx, %rax
movq 128(%rsp), %rcx
addq %rax, %rcx
movq 136(%rsp), %rax
addq %rcx, %rax
movq 144(%rsp), %rcx
addq %rax, %rcx
movq 256(%rsp), %rax
addq %rcx, %rax
addq %rsi, %rax
movq 176(%rsp), %rcx
addq 152(%rsp), %rcx
movq 184(%rsp), %rdx
addq %rcx, %rdx
movq 200(%rsp), %rcx
addq %rdx, %rcx
movq 216(%rsp), %rdx
addq %rcx, %rdx
movq 224(%rsp), %rcx
addq %rdx, %rcx
movq -128(%rsp), %rdx
addq %rcx, %rdx
movq -120(%rsp), %rcx
addq %rdx, %rcx
addq %rax, %rcx
movq 272(%rsp), %rax
movq %rcx, (%rax)
.LBB0_2:
addq $280, %rsp
.cfi_def_cfa_offset 56
popq %rbx
.cfi_def_cfa_offset 48
popq %r12
.cfi_def_cfa_offset 40
popq %r13
.cfi_def_cfa_offset 32
popq %r14
.cfi_def_cfa_offset 24
popq %r15
.cfi_def_cfa_offset 16
popq %rbp
.cfi_def_cfa_offset 8
retq
.Lfunc_end0:
.size _RNvCscdRkKAp4ykW_5repro14all_the_spills, .Lfunc_end0-_RNvCscdRkKAp4ykW_5repro14all_the_spills
.cfi_endproc
.ident "rustc version 1.99.0-nightly (771916f90 2026-08-08)"
.section ".note.GNU-stack","",@progbits
```
As the source code says, removing either the store outside the branch or the branch produces the expected unrolled load+adds:
Generated asm when `*other = 1;` is removed
```asm
.att_syntax
.file "repro.8e6011e3694ac10c-cgu.0"
.section .text._RNvCscdRkKAp4ykW_5repro14all_the_spills,"ax",@progbits
.globl _RNvCscdRkKAp4ykW_5repro14all_the_spills
.prefalign 4, .Lfunc_end0, nop
.type _RNvCscdRkKAp4ykW_5repro14all_the_spills,@function
_RNvCscdRkKAp4ykW_5repro14all_the_spills:
.cfi_startproc
testl %ecx, %ecx
je .LBB0_2
movq 128(%rdi), %rax
addq (%rdi), %rax
addq 256(%rdi), %rax
addq 384(%rdi), %rax
addq 512(%rdi), %rax
addq 640(%rdi), %rax
addq 768(%rdi), %rax
addq 896(%rdi), %rax
addq 1024(%rdi), %rax
addq 1152(%rdi), %rax
addq 1280(%rdi), %rax
addq 1408(%rdi), %rax
addq 1536(%rdi), %rax
addq 1664(%rdi), %rax
addq 1792(%rdi), %rax
addq 1920(%rdi), %rax
addq 2048(%rdi), %rax
addq 2176(%rdi), %rax
addq 2304(%rdi), %rax
addq 2432(%rdi), %rax
addq 2560(%rdi), %rax
addq 2688(%rdi), %rax
addq 2816(%rdi), %rax
addq 2944(%rdi), %rax
addq 3072(%rdi), %rax
addq 3200(%rdi), %rax
addq 3328(%rdi), %rax
addq 3456(%rdi), %rax
addq 3584(%rdi), %rax
addq 3712(%rdi), %rax
addq 3840(%rdi), %rax
addq 3968(%rdi), %rax
addq 4096(%rdi), %rax
addq 4224(%rdi), %rax
addq 4352(%rdi), %rax
addq 4480(%rdi), %rax
addq 4608(%rdi), %rax
addq 4736(%rdi), %rax
addq 4864(%rdi), %rax
addq 4992(%rdi), %rax
addq 5120(%rdi), %rax
addq 5248(%rdi), %rax
addq 5376(%rdi), %rax
addq 5504(%rdi), %rax
addq 5632(%rdi), %rax
addq 5760(%rdi), %rax
addq 5888(%rdi), %rax
addq 6016(%rdi), %rax
addq 6144(%rdi), %rax
addq 6272(%rdi), %rax
addq 6400(%rdi), %rax
addq 6528(%rdi), %rax
addq 6656(%rdi), %rax
addq 6784(%rdi), %rax
addq 6912(%rdi), %rax
addq 7040(%rdi), %rax
addq 7168(%rdi), %rax
addq 7296(%rdi), %rax
addq 7424(%rdi), %rax
addq 7552(%rdi), %rax
addq 7680(%rdi), %rax
addq 7808(%rdi), %rax
addq 7936(%rdi), %rax
addq 8064(%rdi), %rax
movq %rax, (%rsi)
.LBB0_2:
retq
.Lfunc_end0:
.size _RNvCscdRkKAp4ykW_5repro14all_the_spills, .Lfunc_end0-_RNvCscdRkKAp4ykW_5repro14all_the_spills
.cfi_endproc
.ident "rustc version 1.99.0-nightly (771916f90 2026-08-08)"
.section ".note.GNU-stack","",@progbits
```
The example is a minimized example of where I first encountered this problem, https://codeberg.org/danderson/columnstore/src/commit/5504d40bde732fb48b38bf8e34d7b6efb5ee1766/columnstore/benches/utl.rs#L8 . That loop does two running SIMD sums in one loop, and ends by storing both results to memory. In the minimum example, `*other = 1` is a stand-in for the first of those two stores, and the `if flag` branch is a stand-in for the slice bounds check that happens before storing the second result.
The spilling behavior is the same as the minimal example, just worse due to the size of values: that function ends up spilling ~2KiB to stack to sum over a 8KiB array, and performs more than 2x worse than the "correct" implementation (a stream of `vpaddq`s into a pair of zmm registers)
## LLVM stuff
After dumping IR in the good and bad cases and diffing, the problems start in an InstCombinePass, specifically the code-sinking logic. The full IR dumps of all LLVM passes is here:
[bad.ir.txt](https://github.com/user-attachments/files/31078528/bad.ir.txt)
[good.ir.txt](https://github.com/user-attachments/files/31078531/good.ir.txt)
[diff.ir.txt](https://github.com/user-attachments/files/31078533/diff.ir.txt)
The badness starts at the InstCombinePass at L3169 in bad.ir.txt and L3119 in good.ir.txt. At this point the loop has already been fully unrolled, so the IR is a bit unwieldy. I'll trim out the repetition, see the files above for the full IR. The input to the InstCombinePass is:
```llvm
define void @_RNvCs9tXDu8JEGG5_5repro14all_the_spills(ptr noalias nofree noundef readonly align 8 captures(none) dereferenceable(8192) %0, ptr noalias nofree noundef writeonly align 8 captures(none) dereferenceable(8) %1, ptr noalias nofree noundef writeonly align 8 captures(none) dereferenceable(8) %2, i1 noundef zeroext %3) unnamed_addr #0 {
%5 = load i64, ptr %0, align 8, !noundef !4
%6 = getelementptr inbounds nuw i8, ptr %0, i64 128
%7 = load i64, ptr %6, align 8, !noundef !4
%8 = add i64 %7, %5
<63 more GEP+load+add triples, final value in %194>
store i64 1, ptr %2, align 8
br i1 %3, label %196, label %195
195: ; preds = %196, %4
ret void
196: ; preds = %4
store i64 %194, ptr %1, align 8
br label %195
}
```
After the InstCombinePass:
```llvm
define void @_RNvCs9tXDu8JEGG5_5repro14all_the_spills(ptr noalias nofree noundef readonly align 8 captures(none) dereferenceable(8192) %0, ptr noalias nofree noundef writeonly align 8 captures(none) dereferenceable(8) %1, ptr noalias nofree noundef writeonly align 8 captures(none) dereferenceable(8) %2, i1 noundef zeroext %3) unnamed_addr #0 {
%5 = load i64, ptr %0, align 8, !noundef !4
%6 = getelementptr inbounds nuw i8, ptr %0, i64 128
%7 = load i64, ptr %6, align 8, !noundef !4
<63 more GEP+load pairs, loads going into %9, %11, ..., %131>
store i64 1, ptr %2, align 8
br i1 %3, label %133, label %132
132: ; preds = %133, %4
ret void
133: ; preds = %4
%134 = add i64 %7, %5
<63 more adds, consuming %9, %11, ... %131>
store i64 %196, ptr %1, align 8
br label %132
}
```
IOW, all the adds have moved into the branch BB, but the loads remained in the entry block. This is pretty obviously code-sinking at work, and indeed `--instcombine-code-sinking=false` stops this code motion and results in excellent optimized IR once again. In "good" IR cases, where the unrelated store is removed from the IR, code-sinking moves the entire GEP+load+add triple into the branch BB.
Digging into the source, I believe https://github.com/rust-lang/llvm-project/blob/rustc/23.1-2026-07-22/llvm/lib/Transforms/InstCombine/InstructionCombining.cpp#L5622 is the reason: IIUC, InstCombine deliberately avoids doing alias analysis, so load sinking uses the very conservative heuristic that _any_ memory store between the candidate load and the target BB blocks sinking.
In this case, I believe this partial sinking ends up being quite harmful: it prevents later machine-specific passes from fusing the load+add pair into an `addq` with memory operand, and it forces all of the loop's loads to remain live simultaneously which later causes mass register spilling. Contrast with the happy case, where a single value remains live between the BBs.
I suspect in many cases it's not a huge deal if one or two loads get separated from their uses, but combined with loop unrolling and autovectorization the effect can get pretty dramatic, for source code that's doing something fairly reasonable. The only implicit bounds check in the rust source that triggers this code sinking happens after the hot loop, so naively it doesn't feel like an optimization hazard.
## Related bugs
I did a quick search through the bug tracker, and found two issues that are similar, but not quite the same:
- https://github.com/llvm/llvm-project/issues/200785 describes a similar problem with code sinking in other passes, specifically that sinking an instruction which reduces the number of live values away from its operands increases register pressure.
- https://github.com/llvm/llvm-project/issues/43130 describes exactly this loop unroll + partial sinking problem, but in the narrower scope of GPU code. It seems to me this is actually a problem across all uses of LLVM that combine loop unrolling with code sinking.
## What to do?
I'd like to try and fix this, but I'm not a compiler person so I could use guidance on how to proceed, or at least suggestions for what to go learn about next. At a high level, the desired outcome in this case is obvious: either sink both the adds and their loads, or sink neither. What I'm unsure about is how to fit that into the performance constraints of InstCombine. The thoughts I have so far (which may all be terrible, sorry in advance):
- Find some heuristic that's less conservative than "any store blocks load sinking", but cheaper than full alias analysis. Especially in the case of Rust code, it's fairly common that all function args are `noalias`, which naively feels like it should help.
- Relax the ban on alias analysis in InstCombine, and find a heuristic to keep cost under control, e.g. only perform alias analysis if there's a single blocking store, and revert to the conservative behavior if there are more.
- Find some heuristic to prevent the adds from sinking. Again naively, something like: don't sink an instruction if it's the only consumer of its inputs and some of those inputs are unsinkable, because doing so takes a set of short-lived values, and stretches them out over several BBs. My fear is that may just translate to "never sink anything" :/
- Adjust some other pass before/after this InstCombinePass, to avoid the input IR pattern in more cases, or detect the pathological register pressure and un-sink to fix it. I have no idea what pass that would be unfortunately.
Contributor guide
Research direction
Start with bad.ir.txt and good.ir.txt at the InstCombinePass locations around L3169 and L3119, then compare diff.ir.txt with the provided minimal LLVM IR. Trace the code-sinking transformation after full loop unrolling and verify behavior against the Rust reproducer's generated assembly. Done means avoiding the pathological register pressure while retaining the expected unrolled load-and-add code.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- compilers
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100