Issue 204098
Summary [AVR][AggressiveInstCombine] TruncInstCombine bails on K&R uint8_t loop with outside-graph icmp user — emits 16-bit code where 8-bit is achievable
Labels new issue
Assignees
Reporter ravn
    I have been doing some code density work on z80 backend optimizations in an out-of-tree fork with the help of Claude Code comparing llvm-z80 with z88dk sdcc and in the process found a pattern that also applies to the in-tree AVR backend.  

This is only my second bug report to this project (the first is #202112, a related missed-opt in the same pass), so I would appreciate gentle help if it for any reason could be improved.   

Background:  The z80 is challenging because it has very few registers and limited 16-bit operations, so it is very important to get `i8` codegen for `uint8_t` patterns.   The AVR is not as constrained, but the pattern also applies to it. 

The root cause appears to be that `TruncInstCombine` bails on the whole rewrite when it sees an outside-graph user that isn't a `ZExt`/`SExt`, even if that user is an `icmp` whose operands are provably narrow under `KnownBits`.

I had Claude create a proof-of-concept fix, which gave a concrete 27.8% cycle improvement and ~31 % smaller code on the test case listed below for the AVR platform.  As I understand Claude may fix the symptom but not the underlying cause I have not included its fix here to avoid distraction.  If you agree on the cause I would be happy to share the work.






---

Note:  The rest of the text and the test code is generated by Claude.   

---

## Summary

On AVR (`atmega328p`, `clang --target=avr -Os`), `TruncInstCombine` (in `AggressiveInstCombine`) bails on the whole _expression_-graph rewrite when an in-graph value has an outside-graph user that isn't a `ZExt`/`SExt` — even when the outside user is an `icmp` whose operands are both provably narrow under `KnownBits`.  The K&R-style `uint8_t` arithmetic pattern that's idiomatic in 1980s embedded-C cryptographic and protocol-parser code hits this path: a `do { ... if (atb == x) break; ... } while (++i)` loop ends up carrying its `atb` value as `i16` through the loop, with 16-bit AVR `cp + cpc` compares and `movw` register-pair shuffles in the hot path.

A reduction extracted verbatim from a public-domain AES-256 helper (Ilya O. Levin's `aes256.c`, 2007-2009) shows the function compiles to **70 bytes** on stock LLVM where **48 bytes** is achievable, and executes **~27.8 % more cycles** on a 256-input sweep (measured on simavr with Timer1 at clk/1).

The bail is at `getBestTruncatedType` in `llvm/lib/Transforms/AggressiveInstCombine/TruncInstCombine.cpp`:

```cpp
for (auto *U : I->users())
  if (auto *UI = dyn_cast<Instruction>(U))
    if (UI != CurrentTruncInst && !InstInfoMap.count(UI)) {
      if (!IsExtInst)
        return nullptr;    // <-- bails here, forfeits the whole rewrite
      ...
```

## Reduction (verbatim)

```c
// avr-gflog-missed-opt.c — public-domain provenance below.
#include <stdint.h>

uint8_t gf_log(x)
uint8_t x;
{
    uint8_t atb = 1, i = 0, z;
    do {
        if (atb == x) break;
        z = atb; atb <<= 1; if (z & 0x80) atb ^= 0x1b; atb ^= z;
    } while (++i > 0);
    return i;
}
```

Build:

```sh
clang --target=avr -mmcu=atmega328p -Os -std=c89 \
      -Wno-deprecated-non-prototype \
      -c avr-gflog-missed-opt.c -o gflog.o
avr-size --format=berkeley gflog.o
avr-objdump -d gflog.o
```

Provenance: extracted verbatim from `gf_log` in Ilya O. Levin's public-domain AES-256 implementation (`literatecode.com`, 2007-2009).  The K&R declaration `uint8_t gf_log(x) uint8_t x; { ... }` is preserved from the upstream source.

## Observed

```
text    data     bss     dec     hex     filename
  70       0       0      70      46     gflog.o
```

Hot loop (selected, annotated):

```
movw r22, r30          ; 16-bit register-pair shuffle
andi r23, 0x00
cp   r22, r24          ; ← 16-bit compare (cp + cpc)
cpc  r23, r25          ;     against K&R-promoted i16 arg
breq +42
...
andi r30, 0x80
andi r31, 0x00         ; ← redundant high-byte mask
cp   r30, r1
cpc  r31, r1
...
movw r30, r22          ; ← another 16-bit shuffle
```

## Expected

```
text    data     bss     dec     hex     filename
  48       0       0      48      30     gflog.o
```

```
cp   r18, r24          ; 8-bit compare; both operands i8
breq +32
...
add  r22, r22          ; 8-bit shift
and  r23, r23          ; sign-bit test
brpl +2
eor  r22, r20          ; xor 0x1B
eor  r18, r22
```

## IR diff (`clang -emit-llvm -S -Os`)

Observed (i16 phi, outside-graph icmp blocks the narrowing):

```llvm
define i8 @gf_log(i16 noundef %0) {
  %2 = and i16 %0, 255             ; the K&R promotion's mask survives
  br label %3
3:
  %4 = phi i8 [ 0, %1 ], [ %17, %8 ]
  %5 = phi i16 [ 1, %1 ], [ %16, %8 ]    ; ← i16 phi
  %6 = and i16 %5, 255
  %7 = icmp eq i16 %6, %2          ; ← outside-graph icmp user
  br i1 %7, label %19, label %8
8:
  %9 = trunc i16 %5 to i8
  %10 = shl i8 %9, 1
  %11 = and i16 %5, 128
  %12 = icmp eq i16 %11, 0         ; ← another outside-graph icmp user
  ...
```

Both `%7` and `%12` are width-compatible: `%6` and `%2` are `and i16 ..., 255` (KnownBits-narrow); `%11` is `and i16 %5, 128` against constant `0` (provably narrow on both sides).  Yet the bail at `getBestTruncatedType` forfeits the whole narrowing.

Expected after narrowing:

```llvm
define i8 @gf_log(i16 noundef %0) {
  %2 = trunc i16 %0 to i8          ; narrow at entry
  br label %3
3:
  %5 = phi i8 [ 1, %1 ], [ %12, %7 ]    ; i8 phi
  %6 = icmp eq i8 %5, %2           ; i8 compare
  ...
```

## Runtime cycle witness (optional, but concrete)

A self-contained AVR cycle harness using Timer1 at clk/1 (1 tick = 1 cycle) with overflow-counting brackets a 256-input sweep of `gf_log`:

| | Observed (stock LLVM) | Expected (if narrowed) |
|---|---|---|
| Cycles, 256-call sweep | **825,294** | **595,773** |
| Cycles per call (avg) | **3,224** | **2,327** |

Delta: **−229,521 cycles (−27.8 %)**.

(Source code for the harness and reproducer wiring available on request; uses simavr's `.mmcu` console hook with `AVR_MCU_SIMAVR_CONSOLE(0x3E)`.)

## Cause

`TruncInstCombine` walks back from a `trunc` to build an _expression_ graph of narrowable operations and rebuilds them at the narrow type.  The outside-user check requires every in-graph value's users to be either (a) in the graph, or (b) a `ZExt`/`SExt` that the rewrite can collapse.  The bail at `getBestTruncatedType` returns `nullptr` for *any* other outside user.

This is conservative-correct, but on 8-bit-native backends — where the source-level pattern is `uint8_t` arithmetic in a loop, int-promoted to `i16` through C's integer-promotion rules — the typical loop-exit comparison `icmp <pred> %wide, %other` blocks the narrowing for the entire graph despite both operands being trivially narrow.

## Source-level workarounds attempted

We tested whether straightforward source rewrites unblock the
narrowing on stock LLVM (no compiler patches).  Six variants of
the gf_log body, same loop, different surface forms:

| Variant | Change vs original | Size on stock LLVM |
|---|---|---|
| v0 | original K&R declaration | **70 B** (the bug) |
| v1 | ANSI prototype only (`uint8_t gf_log(uint8_t x)`) | 62 B (partial) |
| v2 | K&R + cast in icmp (`if (atb == (uint8_t)x)`) | 70 B (no change) |
| v3 | K&R + local `uint8_t` copy (`uint8_t xb = x; if (atb == xb)`) | 70 B (no change) |
| v4 | ANSI + sign-bit form (`if ((int8_t)z < 0)` instead of `if (z & 0x80)`) | **48 B (fully unblocks)** |
| v5 | ANSI + branchless materialize of the bit-test mask | 66 B (partial) |
| v6 | prototype declaration + K&R definition body | 62 B (same as v1) |
| v7 | prototype + K&R body + sign-bit form | **48 B (same as v4)** |

Findings:

- **The two "obvious" workarounds — casting `(uint8_t)x` in the icmp
  and copying `x` to a local — achieve nothing.**  InstCombine doesn't
  fold the cast or local before TruncInstCombine sees the IR, so the
  i16 phi reaches the pass byte-identically to v0.
- **ANSI prototype alone (v1) is partial.**  It unblocks the loop-exit
  icmp side (the `atb == x` comparison narrows because `%0` arrives
  as i8 in the IR), but the `z & 0x80` bit-test still produces an
  outside-graph `and i16 %z, 128` shape that blocks the narrowing of
  the phi-rooted graph.
- **The K&R-vs-ANSI distinction is at the *prototype*, not the
  definition body.**  A separate prototype declaration in scope
  before a K&R-style definition (v6) gives byte-identical IR to a
  full ANSI conversion (v1).  Programmers who want to preserve the
  K&R definition syntax can add the prototype without rewriting the
  function body.
- **Two source-level changes together unblock the narrowing.**
  ANSI prototype (or prototype-before-K&R-body) + rewriting the
  bit-test as `(int8_t)z < 0` (which lowers to `icmp slt i8 %z, 0` —
  no `and` operation in the IR) produces the optimal 48-byte codegen
  on stock LLVM (variants v4 and v7).

In other words, the issue is concrete enough that "just rephrase the
C" is not a 30-second answer.  A C programmer would have to anticipate
two specific IR shapes — the outside-graph icmp from int-promotion AND
the outside-graph and-mask from the bit-test — and rewrite both.  The
two unobvious source changes that work are independent of each other;
one is a function signature change and the other is a bit-test idiom
change, and most embedded-C programmers would reach for the cast or
local copy first.

(Variant source available alongside the reducer if useful for review.)

## Direction (not a patch)

Admit the outside-graph user when **both** of the following hold:

1. It is an `ICmpInst` whose predicate is value-preserving at the narrow width.  Equality/inequality (eq/ne) and unsigned predicates are always safe.  Signed predicates require the narrow-width sign bit to stay clear (i.e. operands fit in `NarrowBits − 1`).
2. Both operands are provably narrow under `KnownBits`:
   - The *in-graph* operand (the GraphValue) must have `computeKnownBits(GraphValue).getMaxValue().getActiveBits() <= NarrowBits` (the *fit-bits* threshold above for signed predicates).  **This gate is load-bearing.**
   - The *other* operand fits if it's a `ConstantInt` whose `getActiveBits() <= NarrowBits`, or a single-use variable whose `KnownBits` proves the same.

Rewrite the admitted icmp as `icmp <pred> (trunc lhs to NarrowTy), (trunc rhs to NarrowTy)` before the phi-erase loop in `ReduceExpressionGraph` (so still-wide icmp operands don't get RAUW'd to poison).

**Soundness boundary.**  The in-graph KnownBits gate is the critical step.  Without it, a graph value that fits in the narrow type for the in-graph users (the trunc) may have high bits set at the outside-graph icmp, and narrowing the icmp would change its result.  Concrete witness: a related extension we carried on an out-of-tree fork for ~6 weeks omitted this gate and produced wrong results on the shape `%add = add i32 %x, 1; icmp ult i32 %add, (and y, 255)` when `%x = 65636` (low-16-bits of `%add` = 100 < 200 but full `%add` = 65637 > 200).  The sound version with the gate passes our lit suite (304 lines, available on request).

A parallel admission for outside-graph `(and X, Const)` users where `Const fits the narrow type` is sound regardless of in-graph KnownBits because the mask discards high bits unconditionally.  Same out-of-tree extension covers it with its own lit test.

## Notes for reviewers

- This is filed against AVR because the AVR reduction is self-contained and the size + cycle deltas are concrete.  The optimization is target-independent middle-end and should also benefit MSP430, the in-tree experimental 6502/65816 ports, and any backend whose narrow ops are materially cheaper than its wide ones.
- We carry sound versions of both extensions as out-of-tree commits on an experimental Z80 backend; happy to share the lit witnesses, the runtime soundness probes, and the diffs on request if any of this is useful for upstream review.
- Filed standalone rather than as an RFC because the AVR reproducer and the cycle witness are concrete enough.  Happy to mirror to a Discourse RFC thread if maintainers prefer the broader discussion venue.

## Reproducer files (available on request)

- `avr-gflog-missed-opt.c` — single-file C reduction (above)
- `avr-gflog-runtime.c` — Timer1 cycle harness for the runtime witness
- The two together build into an `.elf` that runs cleanly under simavr master (apt's pre-1.7 simavr lacks the `.mmcu` section parser)

_______________________________________________
llvm-bugs mailing list
[email protected]
https://lists.llvm.org/cgi-bin/mailman/listinfo/llvm-bugs

Reply via email to