Repository navigation
Description
Disclosure: this issue was drafted with the help of an AI tool (Claude Code), per the LLVM AI Tool Use Policy. The compiler outputs and the STM32F411 measurements were collected in that tool-assisted session, on my board. I have reviewed all of it and can answer questions about it.
Summary
On the CPUs that use CortexM4Model (cortex-m3, cortex-m4, cortex-m33, cortex-m35p), the pre-RA machine scheduler turns a fully unrolled sum of 64-bit products into code that spills heavily. With -mcpu=generic, or with -mcpu=cortex-m4 -mllvm -enable-misched=false, the same function needs no stack on clang trunk; with cortex-m4 it makes 47 stack accesses in a 40-byte frame (llc 18 on the IR below: 88 against 7). The IR is identical in every case; only the CPU differs.
The scheduler appears to start most of a row's smulls before it narrows any of them. Each 64-bit product holds a register pair until it is narrowed to (lo >> 16) | (hi << 16), so with several products live at once they no longer fit in the core registers. In the clang trunk output most spills are the high halves (4-byte spills); llc 18 on the IR below spills whole products with strd (22 8-byte spills and 11 4-byte spills, reloaded by 55 4-byte loads, against 3 spills and 4 reloads for generic).
Scheduling top-down avoids it: with -mllvm -misched-prera-direction=topdown, clang trunk needs no stack for cortex-m4 either. Bottom-up scheduling spills as much as the default.
With clang, -march=armv7e-m alone already selects -target-cpu cortex-m4 (checked with clang 18), so C code for any Cortex-M4 gets this by default.
Reproducer
#include <stdint.h>
/* Q16.16 multiply: 64-bit product, arithmetic shift, truncate to 32 bits. */
static inline int32_t qmul(int32_t a, int32_t b) {
return (int32_t)(((int64_t)a * b) >> 16);
}
/* A small fully connected layer: 8 inputs, 4 outputs. */
void dense(const int32_t w[4][8], const int32_t bias[4],
const int32_t x[8], int32_t y[4]) {
#pragma clang loop unroll(full)
for (int n = 0; n < 4; n++) {
int32_t s = 0;
for (int i = 0; i < 8; i++)
s += qmul(w[n][i], x[i]);
y[n] = bias[n] + s;
}
}clang --target=arm-none-eabi -mthumb -mfloat-abi=soft -O2 -S repro.c -mcpu=cortex-m4
clang --target=arm-none-eabi -mthumb -mfloat-abi=soft -O2 -S repro.c -march=armv7e-m -mcpu=generic
clang --target=arm-none-eabi -mthumb -mfloat-abi=soft -O2 -S repro.c -mcpu=cortex-m4 -mllvm -enable-misched=false
clang --target=arm-none-eabi -mthumb -mfloat-abi=soft -O2 -S repro.c -mcpu=cortex-m4 -mllvm -misched-prera-direction=topdown
Clang trunk (Compiler Explorer, "armv7-a clang (trunk)", 2026-11-11 and 2026-11-12):
dense |
stack loads + stores | stack frame |
|---|---|---|
-mcpu=generic |
0 | none |
-mcpu=cortex-m4 |
47 | 40 bytes |
-mcpu=cortex-m4 -mllvm -enable-misched=false |
0 | none |
-mcpu=cortex-m4 -mllvm -misched-prera-direction=topdown |
0 | none |
All four have the same 32 smulls. Only the scheduling differs. "Stack frame" here and below is the spill area (sub sp, #N); every build also pushes the callee-saved registers. clang 18.1.3 gives the same picture with a larger cortex-m4 frame: 60 stack accesses in 52 bytes, and 0 with -mllvm -enable-misched=false or -mllvm -misched-topdown (the option's name before -misched-prera-direction).
The repro.ll at the end of this issue is the same function as rustc emits it (rustc 1.99.1, LLVM 23.1.1). It contains no CPU attribute, so llc -mcpu selects the CPU:
llc -O3 -mtriple=thumbv7em-none-eabi -mcpu=generic repro.ll
llc -O3 -mtriple=thumbv7em-none-eabi -mcpu=cortex-m4 repro.ll
llc 18.1.3: generic 186 instructions, 7 stack accesses, 12-byte frame; cortex-m4 273 instructions, 88 stack accesses, 220-byte frame. rustc's LLVM 23.1.1 gives the same split. With -C target-cpu=cortex-m4 the IR rustc produces is byte-identical except for the "target-cpu" attribute, so this happens in the backend.
Which CPUs
llc 18.1.3 on repro.ll:
-mtriple / -mcpu |
scheduling model | stack accesses | frame |
|---|---|---|---|
| thumbv7m, thumbv7em, thumbv8m.main, thumbv8.1m.main / generic | CortexA8Model, no use-misched |
7 | 12 |
| thumbv7m / cortex-m3 | CortexM4Model | 88 | 220 |
| thumbv7em / cortex-m4 | CortexM4Model | 88 | 220 |
| thumbv7em / cortex-m7 | CortexM7Model | 15 | 20 |
| thumbv8m.main / cortex-m33 | CortexM4Model | 88 | 220 |
| thumbv8m.main / cortex-m35p | CortexM4Model | 88 | 220 |
| thumbv8.1m.main / cortex-m55 | CortexM55Model | 12 | 16 |
| thumbv8.1m.main / cortex-m85 | CortexM85Model | 19 | 28 |
thumbv7em / cortex-m4, -enable-misched=false |
CortexM4Model | 7 | 12 |
thumbv7em / cortex-m4, -misched-topdown |
CortexM4Model | 9 | 8 |
thumbv7em / cortex-m4, -misched-bottomup |
CortexM4Model | 88 | 220 |
So it follows CortexM4Model (in ARMProcessors.td it covers cortex-m3, m4, m33 and m35p, all with FeatureUseMISched; generic uses CortexA8Model without it, so the pre-RA machine scheduler does not run there). -enable-misched=false removes the spills; -enable-post-misched=false makes no difference. Forcing top-down removes nearly all of them, while bottom-up matches the default bidirectional result exactly, so the bottom-up side seems to be where the products get hoisted. rustc's LLVM 23.1.1 agrees: 92 stack accesses with cortex-m4 by default, 15 with -misched-prera-direction=topdown, 9 with -enable-misched=false, 11 for generic.
Effect on hardware
I found this by measuring first: the slowdown showed up in cycle counts, and reading the assembly then traced it to the spills. On an STM32F411 (Cortex-M4F, 100 MHz), a training step of a small 8-4-8 Q16.16 autoencoder takes:
| build (rustc, opt-level 3) | cycles per training step | cycles per inference (forward pass and error) |
|---|---|---|
| generic CPU | 6,437 | 1,332 |
-C target-cpu=cortex-m4 |
8,811 (+37%) | 1,611 (+21%) |
-C target-cpu=cortex-m4 -C llvm-args=-enable-misched=false |
6,415 | 1,350 |
The numbers come from tinymind-rs, a no_std Rust library for neural networks on microcontrollers; the on-board benchmark is in its bench/ directory (anomaly workload).
Turning the scheduler off is not a general workaround. In the same project it makes int8 code slower: an int8 MLP goes from 20,437 to 22,428 cycles (+9.7%) and an int8 CNN from 189,097 to 196,624 (+4.0%). The scheduler does help in general; its register pressure handling just does not seem to cope with this pattern under CortexM4Model.
Expected
-mcpu=cortex-m4 should not spill where the generic schedule needs no stack at all. The pattern is common in fixed-point DSP and ML code on Cortex-M: wide products narrowed and accumulated, small loops that fully unroll.
Versions
- clang trunk (Compiler Explorer, 2026-11-11 and 2026-11-12)
- clang / llc 18.1.3 (Ubuntu)
- rustc 1.99.1 with LLVM 23.1.1
repro.ll
; dense(): 8x4 Q16.16 dense layer, fully unrolled (from rustc 1.99.1, LLVM 23.1.1, opt-level 3)
target datalayout = "e-m:e-p:32:32-Fi8-i64:64-v128:64:128-a:0:32-n32-S64"
target triple = "thumbv7em-unknown-none-eabihf"
; Function Attrs: mustprogress nofree norecurse nosync nounwind willreturn memory(argmem: readwrite)
define dso_local void @dense(ptr noalias nofree noundef readonly align 4 dereferenceable(128) %w, ptr noalias nofree noundef readonly align 4 dereferenceable(16) %bias, ptr noalias nofree noundef readonly align 4 dereferenceable(32) %x, ptr noalias nofree noundef writeonly align 4 dereferenceable(16) %y) unnamed_addr #0 {
start:
%_12 = load i32, ptr %w, align 4
%_15 = load i32, ptr %x, align 4
%_29 = sext i32 %_12 to i64
%_30 = sext i32 %_15 to i64
%_28 = mul nsw i64 %_30, %_29
%_27 = lshr i64 %_28, 16
%_11 = trunc i64 %_27 to i32
%0 = getelementptr inbounds i8, ptr %w, i32 4
%_12.1 = load i32, ptr %0, align 4
%1 = getelementptr inbounds i8, ptr %x, i32 4
%_15.1 = load i32, ptr %1, align 4
%_29.1 = sext i32 %_12.1 to i64
%_30.1 = sext i32 %_15.1 to i64
%_28.1 = mul nsw i64 %_30.1, %_29.1
%_27.1 = lshr i64 %_28.1, 16
%_11.1 = trunc i64 %_27.1 to i32
%2 = add i32 %_11, %_11.1
%3 = getelementptr inbounds i8, ptr %w, i32 8
%_12.2 = load i32, ptr %3, align 4
%4 = getelementptr inbounds i8, ptr %x, i32 8
%_15.2 = load i32, ptr %4, align 4
%_29.2 = sext i32 %_12.2 to i64
%_30.2 = sext i32 %_15.2 to i64
%_28.2 = mul nsw i64 %_30.2, %_29.2
%_27.2 = lshr i64 %_28.2, 16
%_11.2 = trunc i64 %_27.2 to i32
%5 = add i32 %2, %_11.2
%6 = getelementptr inbounds i8, ptr %w, i32 12
%_12.3 = load i32, ptr %6, align 4
%7 = getelementptr inbounds i8, ptr %x, i32 12
%_15.3 = load i32, ptr %7, align 4
%_29.3 = sext i32 %_12.3 to i64
%_30.3 = sext i32 %_15.3 to i64
%_28.3 = mul nsw i64 %_30.3, %_29.3
%_27.3 = lshr i64 %_28.3, 16
%_11.3 = trunc i64 %_27.3 to i32
%8 = add i32 %5, %_11.3
%9 = getelementptr inbounds i8, ptr %w, i32 16
%_12.4 = load i32, ptr %9, align 4
%10 = getelementptr inbounds i8, ptr %x, i32 16
%_15.4 = load i32, ptr %10, align 4
%_29.4 = sext i32 %_12.4 to i64
%_30.4 = sext i32 %_15.4 to i64
%_28.4 = mul nsw i64 %_30.4, %_29.4
%_27.4 = lshr i64 %_28.4, 16
%_11.4 = trunc i64 %_27.4 to i32
%11 = add i32 %8, %_11.4
%12 = getelementptr inbounds i8, ptr %w, i32 20
%_12.5 = load i32, ptr %12, align 4
%13 = getelementptr inbounds i8, ptr %x, i32 20
%_15.5 = load i32, ptr %13, align 4
%_29.5 = sext i32 %_12.5 to i64
%_30.5 = sext i32 %_15.5 to i64
%_28.5 = mul nsw i64 %_30.5, %_29.5
%_27.5 = lshr i64 %_28.5, 16
%_11.5 = trunc i64 %_27.5 to i32
%14 = add i32 %11, %_11.5
%15 = getelementptr inbounds i8, ptr %w, i32 24
%_12.6 = load i32, ptr %15, align 4
%16 = getelementptr inbounds i8, ptr %x, i32 24
%_15.6 = load i32, ptr %16, align 4
%_29.6 = sext i32 %_12.6 to i64
%_30.6 = sext i32 %_15.6 to i64
%_28.6 = mul nsw i64 %_30.6, %_29.6
%_27.6 = lshr i64 %_28.6, 16
%_11.6 = trunc i64 %_27.6 to i32
%17 = add i32 %14, %_11.6
%18 = getelementptr inbounds i8, ptr %w, i32 28
%_12.7 = load i32, ptr %18, align 4
%19 = getelementptr inbounds i8, ptr %x, i32 28
%_15.7 = load i32, ptr %19, align 4
%_29.7 = sext i32 %_12.7 to i64
%_30.7 = sext i32 %_15.7 to i64
%_28.7 = mul nsw i64 %_30.7, %_29.7
%_27.7 = lshr i64 %_28.7, 16
%_11.7 = trunc i64 %_27.7 to i32
%20 = add i32 %17, %_11.7
%_17 = load i32, ptr %bias, align 4
%_16 = add i32 %_17, %20
store i32 %_16, ptr %y, align 4
%21 = getelementptr inbounds i8, ptr %w, i32 32
%_12.112 = load i32, ptr %21, align 4
%_29.114 = sext i32 %_12.112 to i64
%_28.116 = mul nsw i64 %_30, %_29.114
%_27.117 = lshr i64 %_28.116, 16
%_11.118 = trunc i64 %_27.117 to i32
%22 = getelementptr inbounds i8, ptr %w, i32 36
%_12.1.1 = load i32, ptr %22, align 4
%_29.1.1 = sext i32 %_12.1.1 to i64
%_28.1.1 = mul nsw i64 %_30.1, %_29.1.1
%_27.1.1 = lshr i64 %_28.1.1, 16
%_11.1.1 = trunc i64 %_27.1.1 to i32
%23 = add i32 %_11.118, %_11.1.1
%24 = getelementptr inbounds i8, ptr %w, i32 40
%_12.2.1 = load i32, ptr %24, align 4
%_29.2.1 = sext i32 %_12.2.1 to i64
%_28.2.1 = mul nsw i64 %_30.2, %_29.2.1
%_27.2.1 = lshr i64 %_28.2.1, 16
%_11.2.1 = trunc i64 %_27.2.1 to i32
%25 = add i32 %23, %_11.2.1
%26 = getelementptr inbounds i8, ptr %w, i32 44
%_12.3.1 = load i32, ptr %26, align 4
%_29.3.1 = sext i32 %_12.3.1 to i64
%_28.3.1 = mul nsw i64 %_30.3, %_29.3.1
%_27.3.1 = lshr i64 %_28.3.1, 16
%_11.3.1 = trunc i64 %_27.3.1 to i32
%27 = add i32 %25, %_11.3.1
%28 = getelementptr inbounds i8, ptr %w, i32 48
%_12.4.1 = load i32, ptr %28, align 4
%_29.4.1 = sext i32 %_12.4.1 to i64
%_28.4.1 = mul nsw i64 %_30.4, %_29.4.1
%_27.4.1 = lshr i64 %_28.4.1, 16
%_11.4.1 = trunc i64 %_27.4.1 to i32
%29 = add i32 %27, %_11.4.1
%30 = getelementptr inbounds i8, ptr %w, i32 52
%_12.5.1 = load i32, ptr %30, align 4
%_29.5.1 = sext i32 %_12.5.1 to i64
%_28.5.1 = mul nsw i64 %_30.5, %_29.5.1
%_27.5.1 = lshr i64 %_28.5.1, 16
%_11.5.1 = trunc i64 %_27.5.1 to i32
%31 = add i32 %29, %_11.5.1
%32 = getelementptr inbounds i8, ptr %w, i32 56
%_12.6.1 = load i32, ptr %32, align 4
%_29.6.1 = sext i32 %_12.6.1 to i64
%_28.6.1 = mul nsw i64 %_30.6, %_29.6.1
%_27.6.1 = lshr i64 %_28.6.1, 16
%_11.6.1 = trunc i64 %_27.6.1 to i32
%33 = add i32 %31, %_11.6.1
%34 = getelementptr inbounds i8, ptr %w, i32 60
%_12.7.1 = load i32, ptr %34, align 4
%_29.7.1 = sext i32 %_12.7.1 to i64
%_28.7.1 = mul nsw i64 %_30.7, %_29.7.1
%_27.7.1 = lshr i64 %_28.7.1, 16
%_11.7.1 = trunc i64 %_27.7.1 to i32
%35 = add i32 %33, %_11.7.1
%36 = getelementptr inbounds i8, ptr %bias, i32 4
%_17.1 = load i32, ptr %36, align 4
%_16.1 = add i32 %_17.1, %35
%37 = getelementptr inbounds i8, ptr %y, i32 4
store i32 %_16.1, ptr %37, align 4
%38 = getelementptr inbounds i8, ptr %w, i32 64
%_12.219 = load i32, ptr %38, align 4
%_15.220 = load i32, ptr %x, align 4
%_29.221 = sext i32 %_12.219 to i64
%_30.222 = sext i32 %_15.220 to i64
%_28.223 = mul nsw i64 %_30.222, %_29.221
%_27.224 = lshr i64 %_28.223, 16
%_11.225 = trunc i64 %_27.224 to i32
%39 = getelementptr inbounds i8, ptr %w, i32 68
%_12.1.2 = load i32, ptr %39, align 4
%_15.1.2 = load i32, ptr %1, align 4
%_29.1.2 = sext i32 %_12.1.2 to i64
%_30.1.2 = sext i32 %_15.1.2 to i64
%_28.1.2 = mul nsw i64 %_30.1.2, %_29.1.2
%_27.1.2 = lshr i64 %_28.1.2, 16
%_11.1.2 = trunc i64 %_27.1.2 to i32
%40 = add i32 %_11.225, %_11.1.2
%41 = getelementptr inbounds i8, ptr %w, i32 72
%_12.2.2 = load i32, ptr %41, align 4
%_15.2.2 = load i32, ptr %4, align 4
%_29.2.2 = sext i32 %_12.2.2 to i64
%_30.2.2 = sext i32 %_15.2.2 to i64
%_28.2.2 = mul nsw i64 %_30.2.2, %_29.2.2
%_27.2.2 = lshr i64 %_28.2.2, 16
%_11.2.2 = trunc i64 %_27.2.2 to i32
%42 = add i32 %40, %_11.2.2
%43 = getelementptr inbounds i8, ptr %w, i32 76
%_12.3.2 = load i32, ptr %43, align 4
%_15.3.2 = load i32, ptr %7, align 4
%_29.3.2 = sext i32 %_12.3.2 to i64
%_30.3.2 = sext i32 %_15.3.2 to i64
%_28.3.2 = mul nsw i64 %_30.3.2, %_29.3.2
%_27.3.2 = lshr i64 %_28.3.2, 16
%_11.3.2 = trunc i64 %_27.3.2 to i32
%44 = add i32 %42, %_11.3.2
%45 = getelementptr inbounds i8, ptr %w, i32 80
%_12.4.2 = load i32, ptr %45, align 4
%_15.4.2 = load i32, ptr %10, align 4
%_29.4.2 = sext i32 %_12.4.2 to i64
%_30.4.2 = sext i32 %_15.4.2 to i64
%_28.4.2 = mul nsw i64 %_30.4.2, %_29.4.2
%_27.4.2 = lshr i64 %_28.4.2, 16
%_11.4.2 = trunc i64 %_27.4.2 to i32
%46 = add i32 %44, %_11.4.2
%47 = getelementptr inbounds i8, ptr %w, i32 84
%_12.5.2 = load i32, ptr %47, align 4
%_15.5.2 = load i32, ptr %13, align 4
%_29.5.2 = sext i32 %_12.5.2 to i64
%_30.5.2 = sext i32 %_15.5.2 to i64
%_28.5.2 = mul nsw i64 %_30.5.2, %_29.5.2
%_27.5.2 = lshr i64 %_28.5.2, 16
%_11.5.2 = trunc i64 %_27.5.2 to i32
%48 = add i32 %46, %_11.5.2
%49 = getelementptr inbounds i8, ptr %w, i32 88
%_12.6.2 = load i32, ptr %49, align 4
%_15.6.2 = load i32, ptr %16, align 4
%_29.6.2 = sext i32 %_12.6.2 to i64
%_30.6.2 = sext i32 %_15.6.2 to i64
%_28.6.2 = mul nsw i64 %_30.6.2, %_29.6.2
%_27.6.2 = lshr i64 %_28.6.2, 16
%_11.6.2 = trunc i64 %_27.6.2 to i32
%50 = add i32 %48, %_11.6.2
%51 = getelementptr inbounds i8, ptr %w, i32 92
%_12.7.2 = load i32, ptr %51, align 4
%_15.7.2 = load i32, ptr %19, align 4
%_29.7.2 = sext i32 %_12.7.2 to i64
%_30.7.2 = sext i32 %_15.7.2 to i64
%_28.7.2 = mul nsw i64 %_30.7.2, %_29.7.2
%_27.7.2 = lshr i64 %_28.7.2, 16
%_11.7.2 = trunc i64 %_27.7.2 to i32
%52 = add i32 %50, %_11.7.2
%53 = getelementptr inbounds i8, ptr %bias, i32 8
%_17.2 = load i32, ptr %53, align 4
%_16.2 = add i32 %_17.2, %52
%54 = getelementptr inbounds i8, ptr %y, i32 8
store i32 %_16.2, ptr %54, align 4
%55 = getelementptr inbounds i8, ptr %w, i32 96
%_12.326 = load i32, ptr %55, align 4
%_29.328 = sext i32 %_12.326 to i64
%_28.330 = mul nsw i64 %_30.222, %_29.328
%_27.331 = lshr i64 %_28.330, 16
%_11.332 = trunc i64 %_27.331 to i32
%56 = getelementptr inbounds i8, ptr %w, i32 100
%_12.1.3 = load i32, ptr %56, align 4
%_29.1.3 = sext i32 %_12.1.3 to i64
%_28.1.3 = mul nsw i64 %_30.1.2, %_29.1.3
%_27.1.3 = lshr i64 %_28.1.3, 16
%_11.1.3 = trunc i64 %_27.1.3 to i32
%57 = add i32 %_11.332, %_11.1.3
%58 = getelementptr inbounds i8, ptr %w, i32 104
%_12.2.3 = load i32, ptr %58, align 4
%_29.2.3 = sext i32 %_12.2.3 to i64
%_28.2.3 = mul nsw i64 %_30.2.2, %_29.2.3
%_27.2.3 = lshr i64 %_28.2.3, 16
%_11.2.3 = trunc i64 %_27.2.3 to i32
%59 = add i32 %57, %_11.2.3
%60 = getelementptr inbounds i8, ptr %w, i32 108
%_12.3.3 = load i32, ptr %60, align 4
%_29.3.3 = sext i32 %_12.3.3 to i64
%_28.3.3 = mul nsw i64 %_30.3.2, %_29.3.3
%_27.3.3 = lshr i64 %_28.3.3, 16
%_11.3.3 = trunc i64 %_27.3.3 to i32
%61 = add i32 %59, %_11.3.3
%62 = getelementptr inbounds i8, ptr %w, i32 112
%_12.4.3 = load i32, ptr %62, align 4
%_29.4.3 = sext i32 %_12.4.3 to i64
%_28.4.3 = mul nsw i64 %_30.4.2, %_29.4.3
%_27.4.3 = lshr i64 %_28.4.3, 16
%_11.4.3 = trunc i64 %_27.4.3 to i32
%63 = add i32 %61, %_11.4.3
%64 = getelementptr inbounds i8, ptr %w, i32 116
%_12.5.3 = load i32, ptr %64, align 4
%_29.5.3 = sext i32 %_12.5.3 to i64
%_28.5.3 = mul nsw i64 %_30.5.2, %_29.5.3
%_27.5.3 = lshr i64 %_28.5.3, 16
%_11.5.3 = trunc i64 %_27.5.3 to i32
%65 = add i32 %63, %_11.5.3
%66 = getelementptr inbounds i8, ptr %w, i32 120
%_12.6.3 = load i32, ptr %66, align 4
%_29.6.3 = sext i32 %_12.6.3 to i64
%_28.6.3 = mul nsw i64 %_30.6.2, %_29.6.3
%_27.6.3 = lshr i64 %_28.6.3, 16
%_11.6.3 = trunc i64 %_27.6.3 to i32
%67 = add i32 %65, %_11.6.3
%68 = getelementptr inbounds i8, ptr %w, i32 124
%_12.7.3 = load i32, ptr %68, align 4
%_29.7.3 = sext i32 %_12.7.3 to i64
%_28.7.3 = mul nsw i64 %_30.7.2, %_29.7.3
%_27.7.3 = lshr i64 %_28.7.3, 16
%_11.7.3 = trunc i64 %_27.7.3 to i32
%69 = add i32 %67, %_11.7.3
%70 = getelementptr inbounds i8, ptr %bias, i32 12
%_17.3 = load i32, ptr %70, align 4
%_16.3 = add i32 %_17.3, %69
%71 = getelementptr inbounds i8, ptr %y, i32 12
store i32 %_16.3, ptr %71, align 4
ret void
}
attributes #0 = { mustprogress nofree norecurse nosync nounwind willreturn memory(argmem: readwrite) "target-features"="+vfp4d16sp" }
Activity
llvmorg-github-actions commentedon Nov 12, 2026
@llvm/issue-subscribers-backend-arm
Author: Dan McLeran (danmcleran)
Details
*Disclosure: this issue was drafted with the help of an AI tool (Claude Code), per the [LLVM AI Tool Use Policy](https://llvm.org/docs/AIToolPolicy.html). The compiler outputs and the STM32F411 measurements were collected in that tool-assisted session, on my board. I have reviewed all of it and can answer questions about it.*Summary
On the CPUs that use
CortexM4Model(cortex-m3, cortex-m4, cortex-m33, cortex-m35p), the pre-RA machine scheduler turns a fully unrolled sum of 64-bit products into code that spills heavily. With-mcpu=generic, or with-mcpu=cortex-m4 -mllvm -enable-misched=false, the same function needs no stack on clang trunk; with cortex-m4 it makes 47 stack accesses in a 40-byte frame (llc 18 on the IR below: 88 against 7). The IR is identical in every case; only the CPU differs.The scheduler appears to start most of a row's
smulls before it narrows any of them. Each 64-bit product holds a register pair until it is narrowed to(lo >> 16) | (hi << 16), so with several products live at once they no longer fit in the core registers. In the clang trunk output most spills are the high halves (4-byte spills); llc 18 on the IR below spills whole products withstrd(22 8-byte spills and 11 4-byte spills, reloaded by 55 4-byte loads, against 3 spills and 4 reloads for generic).Scheduling top-down avoids it: with
-mllvm -misched-prera-direction=topdown, clang trunk needs no stack for cortex-m4 either. Bottom-up scheduling spills as much as the default.With clang,
-march=armv7e-malone already selects-target-cpu cortex-m4(checked with clang 18), so C code for any Cortex-M4 gets this by default.Reproducer
Clang trunk (Compiler Explorer, "armv7-a clang (trunk)", 2026-11-11 and 2026-11-12):
dense-mcpu=generic-mcpu=cortex-m4-mcpu=cortex-m4 -mllvm -enable-misched=false-mcpu=cortex-m4 -mllvm -misched-prera-direction=topdownAll four have the same 32
smulls. Only the scheduling differs. "Stack frame" here and below is the spill area (sub sp, #N); every build also pushes the callee-saved registers. clang 18.1.3 gives the same picture with a larger cortex-m4 frame: 60 stack accesses in 52 bytes, and 0 with-mllvm -enable-misched=falseor-mllvm -misched-topdown(the option's name before-misched-prera-direction).The
repro.llat the end of this issue is the same function as rustc emits it (rustc 1.99.1, LLVM 23.1.1). It contains no CPU attribute, sollc -mcpuselects the CPU:llc 18.1.3: generic 186 instructions, 7 stack accesses, 12-byte frame; cortex-m4 273 instructions, 88 stack accesses, 220-byte frame. rustc's LLVM 23.1.1 gives the same split. With
-C target-cpu=cortex-m4the IR rustc produces is byte-identical except for the"target-cpu"attribute, so this happens in the backend.Which CPUs
llc 18.1.3 on
repro.ll:-mtriple/-mcpuuse-misched-enable-misched=false-misched-topdown-misched-bottomupSo it follows
CortexM4Model(inARMProcessors.tdit covers cortex-m3, m4, m33 and m35p, all withFeatureUseMISched;genericuses CortexA8Model without it, so the pre-RA machine scheduler does not run there).-enable-misched=falseremoves the spills;-enable-post-misched=falsemakes no difference. Forcing top-down removes nearly all of them, while bottom-up matches the default bidirectional result exactly, so the bottom-up side seems to be where the products get hoisted. rustc's LLVM 23.1.1 agrees: 92 stack accesses with cortex-m4 by default, 15 with-misched-prera-direction=topdown, 9 with-enable-misched=false, 11 for generic.Effect on hardware
I found this by measuring first: the slowdown showed up in cycle counts, and reading the assembly then traced it to the spills. On an STM32F411 (Cortex-M4F, 100 MHz), a training step of a small 8-4-8 Q16.16 autoencoder takes:
-C target-cpu=cortex-m4-C target-cpu=cortex-m4 -C llvm-args=-enable-misched=falseThe numbers come from tinymind-rs, a
no_stdRust library for neural networks on microcontrollers; the on-board benchmark is in itsbench/directory (anomalyworkload).Turning the scheduler off is not a general workaround. In the same project it makes int8 code slower: an int8 MLP goes from 20,437 to 22,428 cycles (+9.7%) and an int8 CNN from 189,097 to 196,624 (+4.0%). The scheduler does help in general; its register pressure handling just does not seem to cope with this pattern under
CortexM4Model.Expected
-mcpu=cortex-m4should not spill where the generic schedule needs no stack at all. The pattern is common in fixed-point DSP and ML code on Cortex-M: wide products narrowed and accumulated, small loops that fully unroll.Versions
<details>
<summary><code>repro.ll</code></summary>
</details>
Notes: link the LLVM scheduler issue; top-down scheduling A/B on both…
Docs: settings for your own build (target-cpu, LLVM's scheduler) (#97)