rust-lang / rust-lang/rust

Miscompilation at opt-level >= 2 on aarch64: loop vectorizer gives a struct field the value of a sibling field

Open
#160,646 3 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

needs-triage
Dominant language
Rust
Stars
119k
Forks
16.1k
PR merge metrics
PR metrics pending

Description

Code

Self-contained, no dependencies, no unsafe:

use std::ops::{Add, Index};

#[derive(Clone, Copy, Debug, PartialEq)]
struct Tensor1<const DIM: usize> { entries: [f64; DIM] }

impl<const DIM: usize> Index<usize> for Tensor1<DIM> {
    type Output = f64;
    fn index(&self, i: usize) -> &f64 { &self.entries[i] }
}

impl<const DIM: usize> Add for Tensor1<DIM> {
    type Output = Self;
    fn add(mut self, other: Self) -> Self {
        for d in 0..DIM { self.entries[d] += other.entries[d]; }
        self
    }
}

#[derive(Clone, Copy, Debug, PartialEq)]
struct Point<const DIM: usize> { coords: Tensor1<DIM> }

impl Point<2> {
    fn new(x: f64, y: f64) -> Self { Self { coords: Tensor1 { entries: [x, y] } } }
}

impl<const DIM: usize> Index<usize> for Point<DIM> {
    type Output = f64;
    fn index(&self, i: usize) -> &f64 { &self.coords[i] }
}

impl<const DIM: usize> Add<Tensor1<DIM>> for Point<DIM> {
    type Output = Point<DIM>;
    fn add(self, offset: Tensor1<DIM>) -> Point<DIM> {
        Point { coords: self.coords + offset }
    }
}

fn transform<F>(cells: &mut [Vec<Point<2>>], mut f: F)
where F: FnMut(usize, &Point<2>) -> Point<2> {
    for (cell, points) in cells.iter_mut().enumerate() {
        for point in points.iter_mut() {
            let original = *point;
            let moved = f(cell, &original);
            *point = original + moved.coords;
        }
    }
}

fn main() {
    // Four cells of nine points; four or more points per cell is the threshold.
    let mut cells: Vec<Vec<Point<2>>> = (0..4)
        .map(|_| (0..9).map(|k| Point::new(0.25 * k as f64, 0.125 * k as f64)).collect())
        .collect();
    let before = cells.clone();

    transform(&mut cells, |_, p| Point::new(3.0, p[1]));

    // Adding (3, y) to (x, y) gives (x + 3, 2y).
    for (c, points) in cells.iter().enumerate() {
        for (i, a) in points.iter().enumerate() {
            let b = before[c][i];
            assert_eq!(*a, Point::new(b[0] + 3.0, 2.0 * b[1]),
                "cell {c} point {i}: from ({}, {})", b[0], b[1]);
        }
    }
    println!("ok");
}
$ rustc -C opt-level=0 repro.rs && ./repro
ok
$ rustc -C opt-level=1 repro.rs && ./repro
ok
$ rustc -C opt-level=2 repro.rs && ./repro
thread 'main' panicked at repro.rs:62:13:
assertion `left == right` failed: cell 0 point 0: from (0, 0)
  left: Point { coords: Tensor1 { entries: [3.0, 3.0] } }
 right: Point { coords: Tensor1 { entries: [3.0, 0.0] } }
$ rustc -C opt-level=3 repro.rs && ./repro
(same panic)
Expected

ok at every optimization level. The closure returns Point::new(3.0, p[1]), so the
second component of the returned point is a copy of the second component of the input.

Actual

Correct at -C opt-level=0 and -C opt-level=1. At -C opt-level=2 and 3 the second
component of the returned point holds 3.0 — the constant that was written into the
first component — instead of the coordinate that was read. The difference is not a
rounding difference; the value is wrong by the whole constant.

Narrowing

Each of these was tested by changing one thing at a time on the reproduction above.

  • It is the loop vectorizer. -C opt-level=2 -C no-vectorize-loops prints ok.
    -C opt-level=2 -C no-vectorize-slp still panics.
  • A nested loop is needed. Flattening the points into a single &mut [Point<2>]
    and using one loop does not reproduce.
  • The inner loop needs at least four iterations. Two or three points per cell do
    not reproduce; four or more do. A NEON vector holds two f64.
  • Any arithmetic on the copied component hides it. Point::new(3.0, 2.0 * p[1])
    and Point::new(3.0, p[1] + 1.0) are correct; only the verbatim copy p[1] is wrong.
  • Only that component order is wrong. Point::new(p[1], 3.0) and
    Point::new(p[0], p[1]) are correct, and so is Point::new(3.0, p[0]).
  • Capturing makes no difference. The same failure occurs with
    move |_, p| Point::new(three, p[1]) capturing a local, and with a value that is only
    known at run time (taken from the process argument count, so it cannot be
    constant-folded). So this is not constant propagation.
  • Not tied to the constructor or to the Index impl. Point::from_array([3.0, p[1]])
    and reading p.coords[1] directly fail the same way.
Where it was found

In a finite element library, in a routine that displaces the cached support points of a
mesh by a per-point offset. The wrong output was a mesh point at (3, 3) where (3, 0)
was meant. It is invisible in a debug build: the regression test written for it passes at
-C opt-level=0 even without the workaround. The workaround in that code is an
#[inline(never)] bridge, which works only because an uninlinable call in the loop body
stops the loop from being vectorized.

Meta
$ rustc --version --verbose
rustc 1.93.1 (01f6ddf75 2026-02-11)
binary: rustc
commit-hash: 01f6ddf7588f42ae2d7eb0a2f21d44e8e96674cf
commit-date: 2026-02-11
host: aarch64-apple-darwin
release: 1.93.1
LLVM version: 21.1.8

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by compiling repro.rs on aarch64 at opt-level 2 and 3, then compare it with no-vectorize-loops to confirm the loop-vectorizer boundary. Trace the compiler or LLVM loop-vectorization path involved in the nested loop and copied component. Done means the reproduction prints ok at every optimization level and a regression test covers the case.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
compilers
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.