
Intro
C++ is a great language for low-level optimization and is getting better!
Is it perfect? Constraining the compiler can backfire.
Thanks to the committee for the tools that make this possible, and to the compiler developers, whom the code in this talk probably undermines.

02 / 44
Timeline
How this talk happened

03 / 44
Notice
Not a product placement. But…

diamondinoia.com/cppcon26
- poetcompile-time control and runtime dispatch, C++17
- xsimdportable SIMD batches
- FINUFFTwhere this started, issue #459
- these slidesthe benchmark code

04 / 44
Scope
This talk is not a template packs tutorial.
For template packs see Andrei Alexandrescu, CppCon 2025, How to Tame Packs, std::tuple, and the Wily std::integer_sequence youtu.be/X_w_pcPs2Fk.
Here: the gist, how to use them for low level optimization.

05 / 44
Agenda
The journey
integral_constant, integer_sequence, if constexprconstevalstd::simd
06 / 44
Part one
The six monsters
Six patterns that buy performance and charge for it every day after.

07 / 44
The six monsters
What lives in real codebases
Hand unrolling
Macro magic
Pragmas
Kernel dispatch
Inline asm
Generated code

08 / 44
Baseline
One accumulator, one serial chain
or
- Each add waits for the previous one. The loop is one dependency chain.
- The vector units are idle. Nothing here can issue in parallel.
- This is the whole baseline. Every ratio in this talk is against it.

09 / 44
Monster 01 · hand unrolled
Four accumulators, four chains
- Four copies of one line. The body changes in four places or not at all.
- The unroll factor is baked into the source. Retuning means retyping.
- A wrong index in one copy still compiles. And still returns a number.

10 / 44
Monster 02 · macro magic
Unrolling through the preprocessor
- The body is written once and the unroll factor is a macro parameter.
- Text substitution. No types, no scope:
BODY(i++)compiles, and is wrong. - The debugger lands on the invocation line. All four bodies, one line.
- It is unreadable.

11 / 44
Monster 03 · pragmas
Ask the compiler nicely
The loop is unrolled. The reduction is not.
- One accumulator, one dependency chain. Unrolling the body does not break it.
- Floating-point addition is not associative, so the compiler may not reassociate.
- Nothing warns. The pragma was honoured. The code is still 1.0x.

12 / 44
Monster 03 · pragmas
Every compiler wants its own spelling
| pre-2014 | #pragma unroll exists in ICC, IBM XL and CUDA. Not in GCC, not in Clang. |
| 2014-09 · Clang 3.5 | ships #pragma unroll and #pragma nounroll, plus #pragma clang loop unroll[_count]. |
| 2018-05 · GCC 8 | adds #pragma GCC unroll n for C, C++, Fortran and Ada. |
| 2021-10 · Clang 13 | accepts #pragma GCC unroll and nounroll. Also gains #pragma omp unroll. |
| 2025 · GCC 15 | implements #pragma omp unroll. |
- Some forms are hints, not contracts.
- The failure mode is silent: slower code, no diagnostic.
- An unrecognized pragma is a warning, maybe the performance report mentions unrolling/vectorization

13 / 44
Monster 03 · pragmas
The pragma that says what we mean
reduction(+:s) is permission, not a hint.
- GCC splits the reduction across SIMD lanes and reduces once at the end.
- Needs
-fopenmp-simd. Forget the flag and the pragma is ignored. - Still a pragma: outside the language, spelled differently per vendor.

14 / 44
Monster 04 · dispatch
The C++ solution.
C++17, and the body is written once.
UNROLLis a template parameter, so the inner loop (hopefully) is fully unrolled.- The accumulators are an array, not four named variables.
- One question left: who picks
UNROLL?

15 / 44
Monster 04 · dispatch
… and the ladder is typed by hand
- One branch per supported factor, written and audited by hand.
- Two parameters and the ladder is O(N×M).
- Miss a case and it throws at run time, or silently hits the wrong kernel.
- The ladder drifts from the kernels it dispatches to.

16 / 44
Monster 05 · inline asm
Sixteen chains, zero abstraction
- Optimal the day it is written. Compilers improve; this does not.
- One ISA. AVX-512, NEON, SVE: start again.
- The register allocator is now a human, and the human is now on call.
- Debuggers and sanitizers see an opaque block.

17 / 44
Monster 06 · code generation
Write a program that writes the kernels
OCaml, or any other language, emits the C++ that the compiler then sees.
- Two languages, two toolchains, one more build step to maintain.
- The generated output gets no compile-time validation from the generator. The generator's type system does not reach the emitted code.
- Compile times explode.
- It does not generalize. One more kernel shape and the world is regenerated.

18 / 44
Honourable (?) mention
Compiler built-in vectors
Same 16 accumulators. The compiler allocates the registers.
- No intrinsic, no ISA name anywhere in the source.
- 16 accumulators fit the register file.
- Still a vendor extension, not the standard.

19 / 44
Scoreboard
What the monsters bought
| implementation | GB/s | vs plain | |
|---|---|---|---|
| plain loop | 9.6 | 1.0x | |
| std::reduce | 33.8 | 3.5x | |
| 01 hand unrolled ×4 | 38.0 | 4.0x | |
| 02 macro unrolled ×4 | 38.0 | 4.0x | |
| 03a #pragma GCC unroll 4 | 9.6 | 1.0x | |
| 03b #pragma omp simd reduction | 74.8 | 7.8x | |
| 04 dispatch<16> | 138.8 | 14.5x | |
| 05 inline asm, 16 ymm | 248.2 | 25.9x | |
| + built-in vectors | 240.9 | 25.1x |
Core Ultra 7 155H (Meteor Lake), AVX2 · gcc 17.0.0 20260914 · -O3 -march=native -fopenmp-simd · n = 8192 floats, L1-resident, 64B-aligned · min of 20000 × best of 3, pinned to one core · code/code.cpp

20 / 44
The bill
26x faster. At what cost?
Maintainability
The unroll factor lives in four places at once
Portability
One ISA, one compiler, one spelling of the pragma
Debuggability
The debugger stops at the macro invocation, or at an opaque asm block
Readability
The intent is buried under the mechanism
Drift
The ladder and the kernels stop agreeing, silently

21 / 44
Part two
C++ already has the tools.
if constexpr, fold expressions, template packs and concepts express the intent in code the compiler can still optimize.
Since C++17 I reach for the monsters less and less. As compilers improve, the performance is free. New architectures are free. C++26 reflection makes it shorter still.

22 / 44
C++17
The helper writes the body
Monster 01, with UNROLL accumulators instead of four.
static_forpasses the index as anintegral_constant, sos[k]is a compile-time index.- The body is typed once and the compiler writes the copies.
- A wrong index is now a compile error, not a silent wrong answer.
- Four lines of library, and it ships today on the compiler you already have.
- This function is the whole idea behind
poet::static_for.

23 / 44
C++20
The pack writes the body
The same kernel, with the helper deleted.
- The templated lambda takes the pack, so
static_foris no longer needed. - The fold expression emits one
sums[K] += a[i + K]per index. - One body still, and one compile error still for a wrong index.
- UNROLL = 8 here. The dispatch slide lets the machine pick 16.

24 / 44
C++26
The pack becomes a statement
template for repeats the body once per element.
- Inside each copy
kis a constant expression. - No lambda, no
integer_sequence, no fold. s[k] += a[i + k]

25 / 44
Dispatch
The factor arrives at run time
Assume choose_unroll() selects an unroll factor for the machine.
if constexprneeds a constant. This is not one.unrollis anint; the kernels are templates.- Every monster so far solved this with a ladder.

26 / 44
C++17 · dispatch
One fold, no ladder
unroll is known at run time only.
if constexprcannot branch on a runtime value. The fold can.- The list of supported factors is the code. It cannot drift.
- Short-circuit
||stops at the first match. - No match returns
false, so the fallback is explicit, not a throw.

27 / 44
C++26
The ladder is the list
Assume choose_unroll() picks a factor for the machine.
- The braced list is the set of kernels that exist. Add 32 and the dispatch grows with it.
- Every branch is generated from the same source line.
- The fallback is one call, not a throw.

28 / 44
C++26 · std::simd
Vectors in the language
Five standard pieces, no intrinsics.
std::simd::vec<float>picks the native vector width.choose_unroll()picks the number of independent accumulators.template forwrites the load list.std::reducecombines the vectors, then the scalar tail, sonneeds no precondition.std::simd::reduceturns the last vector into one float.

29 / 44
C++26 · std::simd
Same dispatch, SIMD kernels
This is the whole selection logic.
- The kernel is generic in
UNROLL. The dispatch is generic in the list. - Nothing here names an ISA, so a wider machine needs no new code. The hand-written asm would have to be rewritten for it.

30 / 44
Scoreboard
Standard C++, no monsters
| implementation | GB/s | vs plain | |
|---|---|---|---|
| plain loop | 9.6 | 1.0x | |
| 05 inline asm, 16 ymm | 248.2 | 25.9x | |
| C++17 static_for, UNROLL 8 | 75.2 | 7.8x | |
| C++20 pack, UNROLL 8 | 75.2 | 7.8x | |
| C++26 template for, UNROLL 8 | 75.2 | 7.8x | |
| fold dispatch | 135.4 | 14.1x | |
| template for dispatch | 130.0 | 13.6x | |
| std::simd + dispatch | 235.7 | 24.6x |
The platform probe gives a register budget. The dispatch keeps the machine policy out of the kernel body. 135.4 GB/s at the selected UNROLL = 16.

31 / 44
poet · C++17
The manual ladder becomes one call
Three functions, five forms. That is the whole API.
static_for<Begin, End, Step>passes a compile-time index.dynamic_fortakes the same range at run time and unrolls it byUnroll.Stepfolds the stride when it is a template parameter, and stays runtime as an argument.- The lane form selects an independent sum.
dispatchselects a template argument at run time.

32 / 44
poet · C++17
Kernel and dispatch, no ladder
The remainder loop is inside dynamic_for.
unrollsis the list of kernels that exist. It appears once.poet::throw_on_no_matchis an object, not a type: no braces.- Nothing here is C++20.

33 / 44
poet + xsimd · C++17
std::simd, nine years early
poet supplies the control flow, xsimd the vector type.
xsimd::best_archresolves the widest ISA the build targets.poet::dynamic_forsteps byUNROLL × widthelements.- Nothing in the kernel needs a standard past C++17.

34 / 44
Machine policy
How many accumulators fit?
poet::vector_register_count() reads -march at compile time.
constevalin C++20,constexprin C++17: the same call compiles in both.- The compiler folds
choose_unroll(), and the dispatch with it. - This is the only place the machine is mentioned.

35 / 44
poet + xsimd · C++17
Select the kernel, keep the standard
One call selects the unroll factor.
poet::dispatch_param<unrolls>lists the values the ladder covers.choose_unroll()is a run-time value.dispatchmatches it against that list.- The kernel underneath never changes.

36 / 44
Scoreboard
Two standards apart, same numbers
| implementation | GB/s | vs plain | |
|---|---|---|---|
| plain loop | 9.6 | 1.0x | |
| 05 inline asm, 16 ymm | 248.2 | 25.9x | |
| C++17 poet + xsimd | 230.8 | 24.1x | |
| C++26 std::simd + template for | 235.7 | 24.6x |

37 / 44
Results
Every implementation, different machines
| implementation | Core Ultra 7 155H | Xeon w5-3435X | ||||
|---|---|---|---|---|---|---|
| plain loop | 9.6 | 1.0x | 9.0 | 1.0x | ||
| std::reduce | 33.8 | 3.5x | 31.6 | 3.5x | ||
| 01 hand unrolled ×4 | 38.0 | 4.0x | 35.7 | 4.0x | ||
| 02 macro unrolled ×4 | 38.0 | 4.0x | 35.7 | 4.0x | ||
| 03a #pragma GCC unroll 4 | 9.6 | 1.0x | 9.0 | 1.0x | ||
| 03b #pragma omp simd reduction | 74.8 | 7.8x | 70.0 | 7.8x | ||
| 04 dispatch<16> | 138.8 | 14.5x | 133.7 | 14.9x | ||
| 05 inline asm, 16 ymm | 248.2 | 25.9x | 252.1 | 28.1x | ||
| + built-in vectors | 240.9 | 25.1x | 246.4 | 27.4x | ||
| C++17 static_for, auto unroll | 135.4 | 14.1x | 210.1 | 23.4x | ||
| C++17 poet + xsimd | 230.8 | 24.1x | 372.4 | 41.4x | ||
| C++26 std::simd + dispatch | 235.7 | 24.6x | 352.3 | 39.2x | ||

38 / 44
Conclusion
Where the journey ends
The monsters are replaceable
Macros, pragmas and inline asm all have a standard C++ answer
No need to wait for C++26
With abstraction, C++17 reaches the same throughput
Performance without the bill
One kernel body, one list of factors, no ISA in the source
The compilers keep improving
New architectures come for free. The asm block does not

39 / 44
Backup · how far we can take it
admiral: my FFT library
- It was not supposed to be a library. It was supposed to be a playground for testing optimizations.
- Why FFT? Because there are fast implementations.
- It started as a collection of kernels (asm, C, C++) that I used to learn optimizations and HPC over the last 10+ years.

40 / 44
Backup · AI
I asked an LLM for exactly this

41 / 44
Backup · result
One kernel, every radix

42 / 44
Backup · performance
Multi-threaded, against the field









scroll sideways · 1D, 2D, 3D × three machinesMKL 2026.0.0 · FFTW3 (MEASURE) 3.3.11 · DUCC 0.41.1-72-g9919ab6 · Sleef 3.9.0-41-g7623d6c · admiral 683a697gcc 14.3.0, -O3 -march=icelake-server|znver2|znver4 · whole node, both sockets, SMT off · fft_bench c9ae666, 2026-09-14

43 / 44
Thank you
Questions?

diamondinoia.com/cppcon26
- poetcompile-time control, runtime dispatch, C++17
- xsimdportable SIMD batches
- FINUFFTissue #459, where this started
- admiralthe FFT library of the backup slides
- simdrefsearchable SIMD intrinsic reference
- slidesMarkdown source and
code/code.cpp

44 / 44