`MatchBranchSimplification` destroys power-of-two knowledge for `#[repr(u8)]` enums whose discriminants equal to match arms
@dianqk is already working on this.
Since Sep 9, 2026.
- Dominant language
- Rust
- Stars
- 119k
- Forks
- 16.1k
- PR merge metrics
- PR metrics pending
Description
Reproduction:
#![crate_type = "lib"]
#[derive(Clone, Copy)]
#[repr(u8)]
pub enum Vsew { E8 = 8, E16 = 16, E32 = 32, E64 = 64 }
impl Vsew {
#[inline(always)]
pub const fn bits_width(self) -> u8 {
match self {
Self::E8 => 8,
Self::E16 => 16,
Self::E32 => 32,
Self::E64 => 64,
}
}
}
#[unsafe(no_mangle)]
pub fn vlmax(sew: Vsew) -> u32 {
512 / u32::from(sew.bits_width())
}
https://rust.godbolt.org/z/f6Y4Yq4b6
In this example vlmax, unfortunately, retains faithful division operation (rustc 1.100.0-nightly (cea272fa3 2026-09-07)):
vlmax:
movzx ecx, dil
mov ax, 512
xor edx, edx
div cx
movzx eax, ax
ret
Compiling with -Zmir-enable-passes=-MatchBranchSimplification generates much better code here:
vlmax:
rep bsf ecx, edi
add cl, -3
mov eax, 64
shr eax, cl
ret
More closely related examples
#![crate_type = "lib"]
#[derive(Clone, Copy)]
#[repr(u8)]
pub enum Vsew {
E8 = 8,
E16 = 16,
E32 = 32,
E64 = 64,
}
impl Vsew {
#[inline(always)]
pub const fn bits_width(self) -> u8 {
match self {
Self::E8 => 8,
Self::E16 => 16,
Self::E32 => 32,
Self::E64 => 64,
}
}
}
#[repr(u8)]
pub enum Lmul {
M1 = 1,
M2 = 2,
M4 = 4,
M8 = 8,
}
impl Lmul {
#[inline(always)]
pub const fn num(self) -> u8 {
match self {
Self::M1 => 1,
Self::M2 => 2,
Self::M4 => 4,
Self::M8 => 8,
}
}
}
#[unsafe(no_mangle)]
pub fn aligned(x: u8, g: Lmul) -> bool {
// div
x.is_multiple_of(g.num())
}
#[unsafe(no_mangle)]
pub fn elem_div(i: u32, sew: Vsew) -> (u32, u32) {
// 2x div
let epr = 64 / u32::from(sew.bits_width() / 8);
(i / epr, i % epr)
}
// x / min(a, b) and x / (a * b) with all operands powers of two
#[unsafe(no_mangle)]
pub fn index_register_count(index: Vsew, sew: Vsew, ln: Lmul, ld: Lmul) -> (u16, u16) {
let num = u16::from(index.bits_width()) * u16::from(ln.num());
let den = u16::from(sew.bits_width()) * u16::from(ld.num());
let g = if num < den { num } else { den };
(num / g, den / g) // 2x div
}
The workaround is to write explicit assertions like this:
let w = sew.bits_width();
unsafe { core::hint::assert_unchecked(w.is_power_of_two()) };
512 / u32::from(w)
https://rust.godbolt.org/z/hx9dGMPfz
Which (another surprise) generates better code still (but only as long as MatchBranchSimplification is enabled, otherwise extra instruction appears):
vlmax:
rep bsf ecx, edi
mov eax, 512
shr eax, cl
ret
LLM-generated root cause analysis and potential solutions
Summary
For a #[repr(uN)] enum whose discriminants are exactly the values a match maps them to
(a very common "unit -> numeric width" pattern), the MatchBranchSimplification MIR pass replaces
the SwitchInt with a plain cast of the discriminant. The only fact that survives into LLVM IR is
the convex hull range(i8 8, 65), so LLVM can no longer prove the value is a power of two and
emits a hardware div/idiv.
Without the pass (-Zmir-opt-level=0 or -Zmir-enable-passes=-MatchBranchSimplification) LLVM sees
the switch, folds it into a shift map, and emits bsf+shr. So the MIR pass is a pessimisation
here: it removes information that LLVM was already able to exploit.
Environment
rustc 1.100.0-nightly (e7769602a 2026-08-24)
host: x86_64-unknown-linux-gnu
LLVM version: 23.1.0
Also reproduces on stable-shaped code; flags: rustc -O --crate-type lib.
Reproduction
#![crate_type = "lib"]
#[derive(Clone, Copy)]
#[repr(u8)]
pub enum Vsew { E8 = 8, E16 = 16, E32 = 32, E64 = 64 }
impl Vsew {
#[inline(always)]
pub const fn bits_width(self) -> u8 {
match self {
Self::E8 => 8,
Self::E16 => 16,
Self::E32 => 32,
Self::E64 => 64,
}
}
}
#[no_mangle]
pub fn vlmax(sew: Vsew) -> u32 {
512 / u32::from(sew.bits_width())
}
Actual (rustc -O)
vlmax:
movzbl %dil, %ecx
movw $512, %ax
xorl %edx, %edx
divw %cx ; <-- hardware division
movzwl %ax, %eax
retq
Expected (rustc -O -Zmir-opt-level=0, or -Zmir-enable-passes=-MatchBranchSimplification)
vlmax:
rep bsfl %edi, %ecx
addb $-3, %cl
movl $64, %eax
shrl %cl, %eax
retq
Both spellings produce the same MIR after the pass, so the same div results:
// identical output
pub fn e(s: Vsew) -> u32 { let w: u32 = match s { Vsew::E8 => 8, Vsew::E16 => 16,
Vsew::E32 => 32, Vsew::E64 => 64 }; 512 / w }
Where the information is lost
rustc -O -C no-prepopulate-passes --emit llvm-ir (default MIR opts) — the switch is already gone
before LLVM runs, and only a hull range remains:
define noundef i32 @vlmax(i8 noundef range(i8 8, 65) %sew) unnamed_addr {
start:
%_2 = zext i8 %sew to i32
%_4 = icmp eq i32 %_2, 0
br i1 %_4, label %panic, label %bb1
bb1:
%_0 = udiv i32 512, %_2
ret i32 %_0
...
rustc -O -Zmir-opt-level=0 -C no-prepopulate-passes --emit llvm-ir — the switch is intact, and
LLVM's SimplifyCFG/InstCombine turn it into a shift:
define noundef i32 @vlmax(i8 noundef range(i8 8, 65) %sew) unnamed_addr {
start:
switch i8 %sew, label %bb1 [
i8 8, label %bb5
i8 16, label %bb4
i8 32, label %bb3
i8 64, label %bb2
]
...
%_0 = udiv i32 512, %_4
Bisecting the MIR passes pins it exactly:
-Zmir-enable-passes=-MatchBranchSimplification -> optimized (bsf/shr)
-Zmir-enable-passes=-GVN -> still div
-Zmir-enable-passes=-JumpThreading -> still div
-Zmir-enable-passes=-EarlyOtherwiseBranch -> still div
-Zmir-enable-passes=-SingleUseConsts -> still div
-Zmir-enable-passes=-SimplifyComparisonIntegral -> still div
The problem is not specific to a match in the same function: the same happens when the enum is
loaded from memory, because bits_width() is inlined and simplified first, leaving
%_4 = load i8, ptr %s, align 1, !range !6, !noundef !7
...
!6 = !{i8 8, i8 65} ; hull, not {8,16,32,64}
Other affected shapes (same root cause)
All of these emit div/idiv today and all of them become shift/mask with
-Zmir-enable-passes=-MatchBranchSimplification:
#[repr(u8)] pub enum Lmul { M1 = 1, M2 = 2, M4 = 4, M8 = 8 }
impl Lmul { #[inline(always)] pub const fn num(self) -> u8 {
match self { Self::M1 => 1, Self::M2 => 2, Self::M4 => 4, Self::M8 => 8 } } }
#[no_mangle] pub fn aligned(x: u8, g: Lmul) -> bool { x.is_multiple_of(g.num()) } // divb
#[no_mangle] pub fn elem_div(i: u32, sew: Vsew) -> (u32, u32) { // divb + divl
let epr = 64 / u32::from(sew.bits_width() / 8);
(i / epr, i % epr)
}
// x / min(a, b) and x / (a * b) with all operands powers of two
#[no_mangle] pub fn index_register_count(index: Vsew, sew: Vsew, ln: Lmul, ld: Lmul) -> (u16, u16) {
let num = u16::from(index.bits_width()) * u16::from(ln.num());
let den = u16::from(sew.bits_width()) * u16::from(ld.num());
let g = if num < den { num } else { den };
(num / g, den / g) // 2x divw
}
LLVM already knows pow2 * pow2 and min(pow2, pow2) are powers of two, so once the fact reaches
it, the min/mul variants fold too — no extra LLVM support is needed for those.
Workaround
let w = sew.bits_width();
unsafe { core::hint::assert_unchecked(w.is_power_of_two()) };
512 / u32::from(w)
vlmax_workaround:
rep bsfl %edi, %ecx
movl $512, %eax
shrl %cl, %eax
retq
Rewriting bits_width as 8u8 << k with k in 0..=3, or using sequential discriminants
E8 = 0, E16 = 1, ..., also avoids the problem (in the latter case the pass produces an
exponential/linear form that LLVM can still reason about).
Possible fixes
- Have
MatchBranchSimplificationavoid replacing aSwitchIntwith a bare cast when the
discriminant set is non-contiguous and the result is not otherwise simplified — the switch is a
strictly more informative encoding, and LLVM handles it well. - Or preserve the fact: LLVM's
!rangemetadata supports multiple disjoint intervals, so an
enum load could carry!{i8 8, i8 9, i8 16, i8 17, i8 32, i8 33, i8 64, i8 65}instead of
!{i8 8, i8 65}. (Therange(..)parameter attribute only allows a single interval, so
arguments would needllvm.assumeor a different mechanism — and LLVM would also need to learn
to use such information; see the companion LLVM report.)
Related LLVM work: llvm/llvm-project#115767 (fixed) made LLVM strength-reduce udiv/urem once the
power-of-two property is known, which is why the assert_unchecked workaround works.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.