llvm / llvm/llvm-project

Missed optimization: bit-scan loops are not folded to x & (x-1) and the three neighbouring identities

Open
#212,908 3 comments 0 reactions 0 assignees View on GitHub
loopoptim missed-optimization
Dominant language
LLVM
Stars
40.5k
Forks
18.7k
PR merge metrics
PR metrics pending

Description

I've been benchmarking naive C code versus the shortest provable equivalent instruction sequence, and all four bad cases on clang are identical. They're all of the form "loop through bit positions to find lowest set/clear bit, return something based on it," which can all be reduced to a two- or three-operation expression with no loop. Clang does great with those expressions, but fails to reduce to it from the loop, and fully unrolls it into a 98-instruction, ~32-branch sequence instead.

The clearest case:

```c
#include

uint32_t clear_lowest_bit(uint32_t x) {
for (int i = 0; i < 32; i++) {
if (x & (1u << i)) {
return x ^ (1u << i);
}
}
return 0;
}
```

This is `x & (x - 1)`, including at `x == 0`, where the loop falls through and returns 0 and the expression gives `0 & 0xFFFFFFFF == 0`. No undefined behaviour: `i` never reaches 32 inside the shift.

### How to reproduce

Compiler Explorer, `x86-64 clang (trunk)` (API id `cclang_trunk`), flags `-O3 -march=x86-64`. The build I saw was:

```
clang version 24.0.0git (https://github.com/llvm/llvm-project.git 5e91f5d57a19752fe245ab64c1265e26c44d0d76)
```

From the shell:

```
curl -s -H 'Content-Type: application/json' -H 'Accept: text/plain' \
-d '{"source":"","options":{"userArguments":"-O3 -march=x86-64"}}' \
https://godbolt.org/api/compiler/cclang_trunk/compile
```

### Actual

98 instructions (counting the function body, excluding `ret`). The pattern is one `mov`/`test`/`jne` triple per bit position, 32 times, all branching to the same exit block. First lines, verbatim:

```asm
clear_lowest_bit:
mov eax, 1
test dil, 1
jne .LBB0_33
mov eax, 2
test dil, 2
jne .LBB0_33
mov eax, 4
test dil, 4
jne .LBB0_33
mov eax, 8
test dil, 8
```

...84 more lines of the same triple, then:

```asm
je .LBB0_32
.LBB0_33:
xor eax, edi
ret
.LBB0_32:
xor eax, eax
ret
```

It's sensible to unroll a loop if the trip count is known, and hence the unroller shouldn't be the bug. The important thing is that no folding takes place after unrolling the chain, and hence the cost of the miss is 98 instead of 16, which is what GCC trunk gives for the same source, where the loop is kept.

### Expected

`return x & (x - 1);`, which the same clang trunk build at the same flags turns into:

```asm
clear_lowest_bit:
lea eax, [rdi - 1]
and eax, edi
ret
```

Two instructions.

### The other three

Same missing transformation, three adjacent identities, all measured on the same trunk build and flags:

| naive source | identity | clang trunk, loop form | clang trunk, identity written directly |
|---|---|---|---|
| `clear_lowest_bit` | `x & (x - 1)` | 98 | 2 |
| `smear_lowest_bit` | `x \| (x - 1)` | 97 | 2 |
| `turn_off_trailing_ones` | `x & (x + 1)` | 96 | 2 |
| `isolate_lowest_zero` | `~x & (x + 1)` | 96 | 4 |

The three loop bodies I haven't shown:

```c
uint32_t smear_lowest_bit(uint32_t x) {
if (x == 0) {
return 0xFFFFFFFFu;
}
for (int i = 0; i < 32; i++) {
if (x & (1u << i)) {
return x | ((1u << i) - 1);
}
}
return 0xFFFFFFFFu;
}

uint32_t turn_off_trailing_ones(uint32_t x) {
int i = 0;
while (i < 32 && (x & (1u << i))) {
x ^= 1u << i;
i++;
}
return x;
}

uint32_t isolate_lowest_zero(uint32_t x) {
for (int i = 0; i < 32; i++) {
if (!(x & (1u << i))) {
return 1u << i;
}
}
return 0;
}
```

The check `x == 0` in `smear_lowest_bit` causes the empty-scan result to become all ones, that is, the equivalent of `x | (x - 1)`. Thus, the loop and the identity agree on every input.

Clang unrolls `turn_off_trailing_ones` into a slightly different shape — an `and`/`cmov` chain instead of the `mov`/`test`/`jne` triples — but the count is similar and the missing fold is the same.

`isolate_lowest_zero` is the most inefficient among the four. Its identity involves three stages, and on the base ISA it requires four instructions because of the absence of three-operand ANDN. Nevertheless, the overall instruction count becomes considerably lower, going down from 96 to 4.

Improving `clear_lowest_bit` in isolation would be helpful. The other three issues are reported as well because they are probably related to the same pattern; reporting four similar issues because of one reason would be inappropriate.

### Issues I've looked at

None of these is a duplicate, although they are sufficiently similar to be related:

- #1860 – the `v &= v-1` popcount loop. Same trick, but as the inner step of a counting loop rather than the whole function.
- #35140, #41463 – BLSR selection. Both use the already-reduced expression.
- #60801 – folding `X ? X & ~(1U << countr_zero(X)) : 0` to `X & (X-1)`. Closest in spirit, but its input is already an intrinsic plus a select, not a scan loop.
- #74976, #82487 – count-trailing-ones. They belong to the same family, but count bits instead of masking them as `turn_off_trailing_ones` does.

Nobody who knows the trick writes the loop. But plenty of code isn't written by someone who knows the trick, student code, code translated out of another language, code emitted by a generator or transpiler with no bit-twiddling peephole of its own. That's who this helps, and it's the same argument behind the loop idioms LLVM already recognises: `LoopIdiomRecognize` covers popcount and count-leading/trailing-zeros loops, and `x &= x - 1` is the body of the popcount loop it already matches. These four identities sit right next to patterns that are already in scope.

Contributor guide

Open the contributing guide

Research direction

Reproduce the four cases with the Compiler Explorer command and inspect LoopIdiomRecognize, which the issue identifies as the area covering related loop idioms. Compare the generated assembly for the loop and direct-identity forms, then verify that all four functions produce substantially shorter code without changing results for edge inputs.

Written by the indexing model from the issue text.

Assessment

Tech stack
c
Domain
compilers
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.