Compile-time loop unrolling for early-exit loops (break, continue, return). Unrolls without the per-iteration bounds checks #pragma unroll generates, so the CPU can actually exploit the instruction-level parallelism the unroll was supposed to buy.
What is ILP?
#include <ilp_for.hpp>Headline numbers (Apple M2, Clang 19, 10M elements, -O3 -march=native):
| Loop Type | Simple | Pragma | ILP | Speedup |
|---|---|---|---|---|
ILP_FOR with ILP_BREAK |
1.46ms | 1.46ms | 0.94ms | 1.56x |
ILP_FOR with ILP_RETURN |
1.68ms | 1.51ms | 0.94ms | 1.79x |
ILP_FOR_RANGE with ILP_BREAK |
2.21ms | - | 0.94ms | 2.35x |
x86 (AMD Ryzen AI 9 HX PRO 370, Zen 5, Clang 20, 10M elements, -O3 -march=native):
| Loop Type | Simple | Pragma | ILP | Speedup |
|---|---|---|---|---|
ILP_FOR with ILP_BREAK |
1.02ms | 0.73ms | 0.50ms | 2.04x |
ILP_FOR with ILP_RETURN |
1.02ms | 0.70ms | 0.49ms | 2.07x |
ILP_FOR_RANGE with ILP_BREAK |
1.18ms | - | 0.49ms | 2.40x |
GCC 15 on the same CPU is a different story: its early-break vectorizer auto-vectorizes the simple break/continue/range loops to ~0.3ms — faster than even Clang's ILP code (~0.5ms). SIMD beats scalar unrolling there, so on GCC 15 write the plain loop for those patterns; only ILP_RETURN (which GCC can't vectorize) keeps its ~2x win. Full per-compiler tables in docs/PERFORMANCE.md.
Start with a loop you'd write on any ordinary day:
int sum = 0;
for (size_t i = 0; i < n; ++i) {
if (data[i] < 0) break; // Early exit
if (data[i] == 0) continue; // Skip zeros
sum += data[i];
}The early exit rules out vectorization, but there's still instruction-level parallelism on the table: split the accumulator into four independent chains, ask the compiler to unroll, and four additions can be in flight at once...
constexpr size_t N = 4;
int sums[N] = {0};
#pragma unroll(4)
for (size_t i = 0; i < n; ++i) {
if (data[i] < 0) break; // Early exit
if (data[i] == 0) continue; // Skip zeros
sums[i & (N-1)] += data[i];
}
int sum = (sums[0] + sums[1]) + (sums[2] + sums[3]);...except the compiler doesn't hold up its end. SCEV cannot compute a trip count for a loop that might break, so the unroller plays it safe and re-checks the bounds after every element. What you get is:
loop:
if (i >= n) goto done; // bounds check
if (data[i] < 0) goto done;
if (data[i] != 0) sums[i & 3] += data[i];
i++;
if (i >= n) goto done; // bounds check (again!)
if (data[i] < 0) goto done;
if (data[i] != 0) sums[i & 3] += data[i];
i++;
if (i >= n) goto done; // bounds check (again!)
if (data[i] < 0) goto done;
if (data[i] != 0) sums[i & 3] += data[i];
i++;
if (i >= n) goto done; // bounds check (again!)
if (data[i] < 0) goto done;
if (data[i] != 0) sums[i & 3] += data[i];
i++;
goto loop;
done:
sum = (sums[0] + sums[1]) + (sums[2] + sums[3]);
The way out is the classic main-loop-plus-remainder pattern: check the bounds once per block of four, then mop up the stragglers. The compiler rewards you with clean machine code. Your code reviewers will be less impressed:
constexpr size_t N = 4;
int sums[N] = {0};
size_t i = 0;
for (; i + 4 <= n; i += 4) { // Main loop: bounds check once per 4 elements
if (data[i+0] < 0) break;
if (data[i+0] != 0) sums[0] += data[i+0];
if (data[i+1] < 0) break;
if (data[i+1] != 0) sums[1] += data[i+1];
if (data[i+2] < 0) break;
if (data[i+2] != 0) sums[2] += data[i+2];
if (data[i+3] < 0) break;
if (data[i+3] != 0) sums[3] += data[i+3];
}
for (; i < n; ++i) { // Remainder
if (data[i] < 0) break;
if (data[i] == 0) continue;
sums[i & (N-1)] += data[i];
}
int sum = (sums[0] + sums[1]) + (sums[2] + sums[3]);See why not pragma unroll? for the assembly evidence (~1.5x speedup).
ILP_FOR generates that same block-plus-remainder structure from something you can actually read. A bit of macro CAPITALISATION aside, it's the loop you started with:
constexpr size_t N = 4;
int sums[N] = {0};
ILP_FOR(auto i, size_t{0}, n, N) {
if (data[i] < 0) ILP_BREAK;
if (data[i] == 0) ILP_CONTINUE;
sums[i & (N-1)] += data[i];
} ILP_END;
int sum = (sums[0] + sums[1]) + (sums[2] + sums[3]);The ILP_FOR_AUTO variants go a step further and choose the unroll factor for you, from per-architecture instruction-timing tables — the same source stays properly tuned across targets instead of hard-coding one machine's sweet spot (details below).
Everything the macros do is sugar over a plain function API — if your codebase bans macros, use that directly (Macro-free API).
- Quick Start
- Large Return Types
- API Reference
- Macro-free API
- Important Notes
- When to Use ILP
- Advanced
- Test Coverage
- Requirements
View Assembly Examples - Compare ILP vs hand-rolled code on Compiler Explorer
ILP_FOR(auto i, 0, n, 4) {
if (data[i] < 0) ILP_BREAK;
if (data[i] == 0) ILP_CONTINUE;
process(data[i]);
} ILP_END;...or if you want something more portable, use ILP_FOR_AUTO with a LoopType:
ILP_FOR_AUTO(auto i, 0, n, Search, int) {
if (data[i] < 0) ILP_BREAK;
if (data[i] == 0) ILP_CONTINUE;
process(data[i]);
} ILP_END;Not sure which LoopType fits? The bundled clang-tidy check will suggest one — and flag the one you guessed wrong (see tools/clang-tidy/):
ILP_FOR_AUTO(auto i, 0, n, Add, int) { //incorrect LoopType
if (data[i] < 0) ILP_BREAK;
if (data[i] == 0) ILP_CONTINUE;
process(data[i]);
} ILP_END;ILP_FOR_AUTO(auto i, 0, n, Add, int) {
^~~~~~~~~~~~~~~~~~~~~
ILP_FOR_AUTO(auto i, 0, n, Search, int)
file.cpp:42:5: note: Portable fix: use ILP_FOR_AUTO with LoopType::Search
file.cpp:42:5: note: Architecture-specific fix for skylake: use ILP_FOR with N=4
// ILP_RETURN(x) returns from enclosing function
int find_index(const std::vector<int>& data, int target) {
ILP_FOR(auto i, 0, static_cast<int>(data.size()), 4) {
if (data[i] == target) ILP_RETURN(i); // Returns from find_index()
} ILP_END_RETURN; // Must use ILP_END_RETURN when ILP_RETURN is used
return -1; // Not found
}...or with auto-selected unroll factor:
int find_index(const std::vector<int>& data, int target) {
ILP_FOR_AUTO(auto i, 0, static_cast<int>(data.size()), Search, int) {
if (data[i] == target) ILP_RETURN(i);
} ILP_END_RETURN;
return -1;
}So you never have to spell the return type at the loop site, ILP_FOR and ILP_FOR_AUTO carry returned values in a small inline buffer (SBO), sized to sizeof(std::intmax_t) on your target — typically 8 bytes on 64-bit platforms. That works for any type that is:
- ≤ SBO size in size (typically 8 bytes)
- ≤ SBO size alignment
- Trivially copyable
This covers int, size_t, pointers, and trivially-copyable structs. Contract
violations are caught at compile time via static_assert. Transport uses std::memcpy
and recovery uses std::bit_cast, which are valid for this restricted set of types;
the result wrapper itself is move-only. Recovery also rejects references and types
larger than the SBO instead of permitting a dangling reference or out-of-bounds read.
Use the typed API for non-trivially-copyable move-only classes or any type with
custom copy/move/destruction.
One thing static_assert can't catch: the untyped path is type-erased, so if the
value you ILP_RETURN and the type you recover it as (usually the enclosing
function's declared return type) are two different types that both fit the SBO —
say, ILP_RETURN(some_int) inside a function that returns long — the bytes get
reinterpreted, not converted. That is a wrong-value contract violation in release
and can still be undefined for a target type that does not admit every bit pattern
(use particular care with pointers and enums). Debug builds
(any build without -DNDEBUG) catch this automatically: on a mismatch, the program
aborts with a message naming both types, rather than returning a wrong value. This
also covers mismatches across nested propagation, not just the
top-level case. Force it on in a release build with -DILP_DEBUG_TYPECHECK, or force
it off in a debug build with -DILP_NO_DEBUG_TYPECHECK — the latter is only useful if
you need debug-build binary layout to match release exactly, since the check adds one
pointer to the SBO when enabled; don't mix TUs built with and without it. When in
doubt, just make sure your ILP_RETURN argument's type matches what you're recovering
it as, or use ILP_FOR_T to make the type explicit and skip this whole class of bug.
Types that don't fit this contract take ILP_FOR_T, which names the return type explicitly. In practice most hot loops traffic in integers, indices, and pointers, so the SBO covers the common case and ILP_FOR_T is the exception.
To override the SBO size, define ILP_SBO_SIZE before including the header:
clang++ -std=c++20 -DILP_SBO_SIZE=16 mycode.cpp # 16-byte SBOstruct Result { int x, y, z; double value; }; // > 8 bytes
Result find_result(const std::vector<int>& data, int target) {
ILP_FOR_T(Result, int i, 0, static_cast<int>(data.size()), 4) {
if (data[i] == target) ILP_RETURN(Result{i, i*2, i*3, i*1.5});
} ILP_END_RETURN;
return Result{-1, 0, 0, 0.0};
}| Macro | Description |
|---|---|
ILP_FOR(var, start, end, N) |
Index loop with explicit N |
ILP_FOR_RANGE(var, range, N) |
Range-based loop with explicit N |
ILP_FOR_AUTO(var, start, end, LoopType, element_type) |
Index loop with auto-selected N |
ILP_FOR_RANGE_AUTO(var, range, LoopType, element_type) |
Range loop with auto-selected N |
ILP_FOR_T(type, var, start, end, N) |
Index loop for typed/non-trivial return values |
ILP_FOR_RANGE_T(type, var, range, N) |
Range loop for typed/non-trivial return values |
ILP_FOR_T_AUTO(type, var, start, end, LoopType, element_type) |
Index loop for typed/non-trivial values with auto-selected N |
ILP_FOR_RANGE_T_AUTO(type, var, range, LoopType, element_type) |
Range loop for typed/non-trivial values with auto-selected N |
See LoopType Reference for available types (Sum, Search, MinMax, etc.)
Always end with ILP_END. If using ILP_RETURN, use ILP_END_RETURN instead —
mixing them up is a compile-time error naming the fix, not a runtime bug: a body
that calls ILP_RETURN but is closed with plain ILP_END fails to build. The macro
layer is the same in both build modes, so this holds under ILP_MODE_SIMPLE too.
| Macro | Use In | Description |
|---|---|---|
ILP_CONTINUE |
Any loop | Skip to next iteration |
ILP_BREAK |
Loops | Exit loop |
ILP_RETURN(val) |
Loops with return type | Return val from enclosing function |
| Macro | Description |
|---|---|
ILP_FLATTEN |
Function annotation — force-inline the loop's call tree so GCC fuses independent body predicates |
ILP_FLATTEN prefixes a function (not a loop), and applies equally to the macro
and the function API — the annotation goes on the enclosing function in both cases:
// Macro API
ILP_FLATTEN size_t first_odd_over(const uint32_t* d, size_t n, uint32_t t) {
ILP_FOR(auto i, size_t{0}, n, 4) {
if (d[i] % 2 == 0) ILP_CONTINUE;
if (d[i] > t) ILP_RETURN(i);
} ILP_END_RETURN;
return n;
}// Function API - same annotation, same place
ILP_FLATTEN size_t first_odd_over(const uint32_t* d, size_t n, uint32_t t) {
auto r = ilp::for_loop<4>(size_t{0}, n, [&](auto i, auto& ctrl) {
if (d[i] % 2 == 0) return; // continue
if (d[i] > t) return ctrl.return_with(i);
});
if (r) return *std::move(r);
return n;
}When to use it: only on GCC, only when a loop body has two or more independent predicates and the least-predictable one is checked first (e.g. a 50/50 parity skip before a rarely-true threshold check), and only if you actually measure a branch-misprediction cliff (it appears once the data spills cache). It is not a general "make it faster" knob — on a well-predicted loop it does nothing useful.
Why it works: in practice GCC only fuses those predicates into one branch when the
loop's call tree is inlined by its early inliner (the exact blocker isn't pinned
down — see the caveat doc); through both APIs several layers inline later than that,
so the fusion is missed and the coin-flip check stays per-element. [[gnu::flatten]]
forces the whole tree to inline early, restoring the fusion (~10-16x on the affected
loops with -march=native — the fusion also unlocks auto-vectorization, so that figure
isn't from branch-fusion alone; verified 26% → 0% branch-misses, GCC 14/15). Yes,
auto-vectorization of an early-exit loop: GCC 14+'s early-break vectorizer can reach
this simple fused shape, an exception to the general rule that early exits block
vectorization — most loops ilp_for targets remain out of its reach.
Clang doesn't need the hint. Two limits: it only takes effect when the debug-mode type
check is disabled (NDEBUG without -DILP_DEBUG_TYPECHECK, or
-DILP_NO_DEBUG_TYPECHECK), and [[gnu::flatten]] force-inlines everything the
function calls, so keep the annotated function small. Reordering the predicates
(selective condition first) fixes the same cliff with no annotation. Full story: the
GCC predicate-order caveat in docs/PRAGMA_UNROLL.md.
The macros are sugar over a plain function API (ilp::for_loop and friends in
ilp_for/detail/loops_ilp.hpp). If your style guide bans macros, call it directly —
same unrolling, same semantics, no ILP_* tokens:
// Macro version
ILP_FOR(auto i, 0, n, 4) {
if (data[i] < 0) ILP_BREAK;
if (data[i] == 0) ILP_CONTINUE;
if (data[i] == target) ILP_RETURN(i);
sum += data[i];
} ILP_END_RETURN;
// Function API equivalent
auto r = ilp::for_loop<4>(0, n, [&](auto i, auto& ctrl) {
if (data[i] < 0) return ctrl.break_loop(); // ILP_BREAK
if (data[i] == 0) return; // ILP_CONTINUE
if (data[i] == target) return ctrl.return_with(i); // ILP_RETURN(i)
sum += data[i];
});
if (r) return *std::move(r); // ILP_END_RETURNA bare return; from the body lambda means continue — the natural, idiomatic
meaning for a lambda, and not a footgun the way it is inside the ILP_FOR macro
expansion (see DESIGN_NOTES.md).
for_loop/for_loop_range return a [[nodiscard]] ForResult, even for loops
that never call return_with — the equivalent of ILP_FOR ... ILP_END (no
ILP_RETURN). For that case, prefer ilp::for_each/ilp::for_each_range, which
return void:
// Macro version (no ILP_RETURN, so ILP_END)
ILP_FOR(auto i, 0, n, 4) {
if (data[i] < 0) ILP_BREAK;
sum += data[i];
} ILP_END;
// Function API equivalent - no [[maybe_unused]] auto r = ... needed
ilp::for_each<4>(0, n, [&](auto i, auto& ctrl) {
if (data[i] < 0) return ctrl.break_loop();
sum += data[i];
});Calling ctrl.return_with(x) inside a for_each body is a compile error pointing
you at ilp::for_loop instead — for_each genuinely cannot return a value out of
the enclosing function, so there is nothing to discard and nothing to nodiscard.
| Macro | Function API |
|---|---|
ILP_FOR(auto i, 0, n, 4) {...} ILP_END; (no ILP_RETURN) |
ilp::for_each<4>(0, n, [&](auto i, auto& ctrl){...}); |
ILP_FOR(auto i, 0, n, 4) {...} ILP_END_RETURN; |
ilp::for_loop<4>(0, n, [&](auto i, auto& ctrl){...}); |
ILP_FOR_AUTO(auto i, 0, n, Search, int) {...} ILP_END; |
ilp::for_each<ilp::optimal_N<ilp::LoopType::Search, int>>(0, n, [&](auto i, auto& ctrl){...}); |
ILP_FOR_RANGE(auto&& v, r, 4) {...} ILP_END; |
ilp::for_each_range<4>(r, [&](auto&& v, auto& ctrl){...}); |
ILP_FOR_T(Result, auto i, 0, n, 4) {...} ILP_END_RETURN; |
ilp::for_loop_typed<Result, 4>(0, n, [&](auto i, auto& ctrl){...}); |
ILP_BREAK |
return ctrl.break_loop(); |
ILP_CONTINUE |
return; |
ILP_RETURN(x) |
return ctrl.return_with(x); (only on for_loop/for_loop_typed ctrl - poisoned on for_each) |
ILP_END_RETURN |
if (r) return *std::move(r); |
ILP_MODE_SIMPLE is a translation-unit-wide define. The function API also accepts
an explicit ilp::Mode template argument, which overrides ilp::default_mode for
just that one loop — useful for stepping through a single hot loop without
de-ILPing the whole file:
// Whole file built normally, but de-ILP just this loop while debugging it:
auto r = ilp::for_loop<4, ilp::Mode::Simple>(0, n, [&](auto i, auto& ctrl) { ... });ilp::Mode::Simple runs only the tail/remainder loop — the same single
bounds-check-per-iteration code path ILP_MODE_SIMPLE produces for the macros
(both go through the same body-lambda mechanism, so there's no macro-vs-function-API
difference in debugger experience here).
ILP_FOR/ILP_BREAK (and ilp::for_loop) lower to a per-lane body call with
exit-state tracking, which cannot auto-vectorize (see
Where ilp_for loses below). ilp::find_if is a
separate, dedicated primitive for the specific case a break-style search
can't reach. Its default (N = 0) resolves to one of two shapes, chosen per
compiler and ISA — see Default block-size strategy
below: on Clang, a two-phase "blockcheck" shape (a branch-free "does this
block contain a match?" scan, then a scalar re-scan of only the hit block)
that scales its block size to the element type; on GCC 15+ with a suitable
ISA, the plain scalar loop, deferring to GCC's own early-break loop
vectorizer. On Clang, this is roughly 5x faster than a scalar loop at
4-byte elements and ~24x at 1-byte elements; on GCC 15+ with SSE4.1 or
better, the default wins or near-ties GCC's own fastest known code for this
pattern on native (AVX-512) hardware — but on mid-tier ISAs (SSE4.2/AVX2)
that's not universal: an explicit-N blockcheck still wins outright at some
element sizes there (see the v2/v3 breakdown in
docs/PERFORMANCE.md). The
blockcheck shape is the default for older GCC and narrower ISAs (no SSE4.1),
where it's still 3-5x over scalar. See docs/PERFORMANCE.md
for the full measured tables.
std::vector<int> data = {5, 3, 8, 42, 1, 9};
auto it = ilp::find_if(data, [](int v) { return v == 42; });
if (it != data.end())
std::cout << "found at " << (it - data.begin()) << "\n";Signature:
template<std::size_t N = 0, ilp::Mode M = ilp::default_mode,
std::ranges::random_access_range Range, typename Pred>
requires std::ranges::sized_range<Range>
&& std::indirect_unary_predicate<Pred, std::ranges::iterator_t<Range>>
std::ranges::borrowed_iterator_t<Range> ilp::find_if(Range&& range, Pred pred);Like std::ranges::find_if, the return type is
std::ranges::borrowed_iterator_t<Range> — passing an rvalue non-borrowed
range (e.g. a temporary std::vector) yields std::ranges::dangling instead
of a usable iterator, since it would point into a range that no longer exists
by the time you can use it.
Purity/over-invocation contract: pred must be pure. The blockcheck shape
invokes it on every element of a block before knowing whether that block
contains the match, so it may be called on elements past the first match, and
more than once per element — don't rely on side effects or a specific
invocation count. An exception thrown from pred propagates normally, but may
happen after later elements were already tested.
N = 0 (the default) resolves to a strategy keyed on compiler and ISA —
this tunes the auto-vectorizer/compiler, not the CPU microarchitecture, so
(unlike for_loop's optimal_N) there is no cpu::Profile knob for it:
| Compiler / ISA | Default | Notes |
|---|---|---|
| Clang (any) | Blockcheck, B = clamp(256 / sizeof(T), 32, 128) |
No measured cliff at any element size; block size scales with the element's byte width. |
| GCC 15+ and SSE4.1+ (x86) or AArch64 | Plain scalar loop, no block phase | Wins or near-ties on native (AVX-512) hardware; on mid-tier ISAs (SSE4.2/AVX2) it's a net win on balance, but blockcheck still wins outright at some element sizes there — an explicit N is the escape hatch (see PERFORMANCE.md's v2/v3 breakdown). 32-bit ARM/NEON is deliberately excluded — unverified, and the riskier guess. |
| GCC (otherwise), MSVC, unknown | Blockcheck, B = min(16, 64 / sizeof(T)) |
Conservative SLP-safe sizing — GCC only vectorizes blockcheck via SLP over one native vector group. |
An explicit N always forces the blockcheck shape on every compiler,
unaffected by this table (unchanged behavior). Passing an explicit N > 16 on
GCC still triggers a deprecation warning pointing at the measured SLP cliff
(Clang has no such cliff in the measured range, so the warning is GCC-only).
See docs/PERFORMANCE.md for the
full measured tables and the SLP-vs-loop-vectorizer mechanism behind this
split, and benchmarks/find_block_sweep.cpp to reproduce the numbers (or
re-tune) on new hardware.
Index-predicate searches: find_if takes an element predicate, not an
index predicate. For an index-based search, pass std::views::iota as the
range:
auto indices = std::views::iota(std::size_t{0}, data.size());
auto it = ilp::find_if(indices, [&](std::size_t i) { return data[i] == target; });Mode::Simple: as with the rest of the function API, an explicit
ilp::Mode::Simple (or ILP_MODE_SIMPLE, via ilp::default_mode) degrades
find_if to the scalar finish loop only — no block phase, one bounds check
per element.
With ILP_FOR_RANGE, declare the loop variable auto&& so elements bind in place instead of being copied (unless copying is the point):
// Good - uses forwarding reference (zero copies)
ILP_FOR_RANGE(auto&& val, strings, 4) {
process(val);
} ILP_END;
// Bad - copies each element into 'val' (slow for large types!)
ILP_FOR_RANGE(auto val, strings, 4) {
process(val);
} ILP_END;None of this is news if you've written a range-for before — but it bites harder in a loop you chose specifically for speed: over a std::vector<std::string>, auto copies every single string.
Range loops require a sized random-access range (std::vector, std::array, std::span, raw arrays...) — the unrolled blocks need indexed access and a block bound. A std::list, std::set, or non-random-access/unsized view won't compile; those containers can't use this lowering, so use an ordinary range-for there.
Index-based loops are immune — the loop variable is an integer, so plain auto is right:
ILP_FOR(auto i, 0, n, 4) {
process(data[i]);
} ILP_END;ILP_RETURN returns from the enclosing C++ function at any nesting depth, in both
build modes (the macro layer is unconditional — see Debugging):
int find_first_match(const std::vector<std::vector<int>>& rows, int target) {
ILP_FOR(auto r, std::size_t{0}, rows.size(), 2) {
ILP_FOR(auto c, std::size_t{0}, rows[r].size(), 4) {
if (rows[r][c] == target) ILP_RETURN(static_cast<int>(r * 100 + c));
} ILP_END_RETURN; // required: this loop carries the inner value outward
} ILP_END_RETURN;
return -1;
}Every enclosing loop on the path out must be closed with ILP_END_RETURN —
each one carries the value one level further. Closing an enclosing loop with plain
ILP_END instead is a compile-time error naming the fix, since a break-only
loop has nowhere to put the value.
Type caveat: the propagated value's type must be the same at every level it
passes through. An untyped ILP_FOR (no ILP_FOR_T) recovers a nested value via
the same type-erased SBO recovery used at the top level (see
Large Return Types and
DESIGN_NOTES.md item 3) — it reinterprets the stored bytes
as whatever type the next level outward expects, rather than converting. So
ILP_RETURN(some_int) propagating out through an untyped loop into an
int-returning function is fine; propagating that same int out through an
ILP_FOR_T(long, ...) outer loop is not — the bytes get reinterpreted as long.
Keep the propagated type consistent, or use ILP_FOR_T at every level that isn't
already returning the exact type you want. Debug builds catch this particular
mismatch automatically and abort naming both types — see the debug-mode type check
note in Large Return Types.
Two boundary cases to know:
- A loop macro nested inside a function-API
for_loop/for_eachbody is rejected at compile time. Supported loop callbacks returnvoid; the mixed expansion would make the callback return a result Proxy and historically could fall off the end as a non-voidlambda. The callback-return check now stops that UB with a diagnostic naming the fix. Use nestedfor_loopcalls and propagate explicitly (extract the inner result into a local, then callctrl.return_with(that_local)on the outer ctrl). - An intervening non-ILP callback (e.g. an
ILP_FORinside astd::for_eachlambda inside an outerILP_FOR). The value still propagates once the innerILP_FORcompletes, but the enclosing algorithm (std::for_each, etc.) finishes its own remaining iterations first — the return is deferred, not immediate.
Use ILP_FOR for loops with early exit (break, continue, return). #pragma unroll will unroll these, but the per-iteration bounds checks it inserts eat the benefit. ILP_FOR skips that overhead (~1.5x speedup).
GCC note: a loop body with two or more independent predicates (e.g. skip-if-even, then match-if-over-threshold) can hit a branch-misprediction cliff on GCC through the macro expansion. Order the most-selective condition first, or mark the enclosing function
ILP_FLATTEN. See the GCC predicate-order caveat in docs/PRAGMA_UNROLL.md.
Skip ILP for straight-line loops with no early exit. The auto-vectorizer handles those well on its own, and the simple, pragma, and ILP versions almost always compile to the same assembly. Using ILP_FOR there is harmless — in most of my tests the code was identical — just unnecessary.
// Use ILP_FOR - early exit benefits from fewer bounds checks
ILP_FOR_AUTO(auto i, 0, n, Search, int) {
if (data[i] == target) ILP_BREAK;
} ILP_END;
// Skip ILP - compiler auto-vectorizes loops without break
int sum = std::accumulate(data.begin(), data.end(), 0);ilp_for is not the right tool for a trivially-vectorizable search - a loop whose
exit condition is a simple comparison over contiguous data (std::find, memchr,
"first index where x == target"). Those are far better served by SIMD chunked
scanning: load a vector of elements, compare them all at once, and use a movemask
(or equivalent) to find the first hit, exactly as a tuned memchr/std::find
implementation does. That processes 16/32/64 elements per branch instead of unrolling
scalar comparisons — or use ilp::find_if,
which generates that chunked-scan shape for you. The win is compiler-and-ISA-dependent:
on Clang it's roughly 5x over a plain scalar loop at 4-byte elements and ~24x at
1-byte elements. On GCC 15+ with SSE4.1 or better, the default wins or near-ties
GCC's own already-vectorized plain loop on native (AVX-512) hardware — GCC's
early-break loop vectorizer is at least competitive with the blockcheck shape
there, so find_if mostly defers to it rather than competing with it. That's
not universal, though: on mid-tier ISAs (SSE4.2/AVX2) the vectorizer fires but
doesn't always win, and an explicit-N blockcheck still beats the default
outright at some element sizes (see PERFORMANCE.md's v2/v3 breakdown). The
blockcheck shape remains the default for older GCC and narrower ISAs (no
SSE4.1), where it's still 3-5x over scalar. See
docs/PERFORMANCE.md for the full
measured tables.
ilp_for targets the case the auto-vectorizer and movemask tricks can't reach:
early-exit loops whose bodies aren't vectorizable (branchy per-element work, function
calls, dependency chains, irregular control flow), where the win comes from breaking
dependency chains and removing per-iteration bounds checks rather than from packing
data into vector registers.
See docs/PERFORMANCE.md for benchmarks and docs/PRAGMA_UNROLL.md for why pragma doesn't help.
The underlying gap this library works around — SCEV falling back to a bounds check per element instead of per unrolled block for early-exit loops — is a missed optimization, not a fundamental limit of what compilers can do; see Could This Be Fixed Upstream? for why, and what it would mean for this library if a compiler ever closed it.
The _AUTO macros pick their unroll factors from a CPU profile. With no profile defined they fall back to conservative defaults — fine, but if you're chasing the last few percent, or building one codebase for several machines, tell them which silicon they're on:
clang++ -std=c++20 -DILP_CPU_SKYLAKE # Intel Skylake
clang++ -std=c++20 -DILP_CPU_ALDERLAKE # Intel Alder Lake
clang++ -std=c++20 -DILP_CPU_APPLE_M1 # Apple M1
clang++ -std=c++20 -DILP_CPU_ZEN5 # AMD Zen 4/5Each profile cites the sources its instruction-timing data came from, so the numbers are checkable rather than folklore. If you build a profile for a new architecture, send it my way and I'll get it added.
If you need to debug your loop logic, you can disable ILP entirely:
clang++ -std=c++20 -DILP_MODE_SIMPLE -O0 -g mycode.cppILP_MODE_SIMPLE does not change what the macros expand to — every ILP_FOR
block still lowers to the same body lambda taking a ctrl parameter, at every
nesting depth, exactly like the default build. What it changes is ilp::default_mode
(the runtime unrolling strategy the macros dispatch on): with it defined, every loop
runs the remainder-only path — one bounds check per iteration, no unrolling — the
simplest code path to single-step through.
| ILP Macro | Meaning (same in both modes) |
|---|---|
ILP_CONTINUE |
skip to the next iteration (return; from the body lambda) |
ILP_BREAK |
exit the loop |
ILP_RETURN(x) |
return x from the enclosing function, at any nesting depth |
ILP_END / ILP_END_RETURN |
close the loop (must match whether the body uses ILP_RETURN) |
Because the macro layer is unconditional, every compile-time/runtime guarantee
(END-enforcement, debug-mode type/consumption checks) holds identically under
ILP_MODE_SIMPLE — nothing here is default-build-only. A bare return; written
directly in a loop body (rather than via ILP_CONTINUE) also means continue in
both modes now, since it's returning from the same body lambda either way.
ILP_MODE_SIMPLE also switches the function API's default (via ilp::default_mode),
so ilp::for_loop(...) calls in the same translation unit degrade the same way.
For a per-loop alternative that doesn't require a global define — e.g. to de-ILP a
single loop while leaving the rest of the file unrolled — see
Per-loop debug mode in the
Macro-free API section.
When using _AUTO variants, you must specify a 'LoopType' to auto-select the optimal unroll factor:
| LoopType | Operation | Use Case |
|---|---|---|
Sum |
acc += val |
Summation, accumulation |
DotProduct |
acc += a * b |
Dot products, FMA |
Search |
Early exit | find, any_of, all_of |
Copy |
dst = src |
Memory copy |
Transform |
dst = f(src) |
Element-wise transforms |
Multiply |
acc *= val |
Product reduction |
Divide |
val / const |
Division |
Sqrt |
sqrt(val) |
Square root |
MinMax |
min/max(acc, val) |
Min/max reduction |
Bitwise |
&, |, ^ |
Bitwise AND/OR/XOR |
Shift |
<<, >> |
Bit shifting |
The basic principle: pick the LoopType for your loop's bottleneck operation — the slowest or most congested one.
Why? The optimal unroll factor follows N ≈ Latency × Throughput: enough independent operations in flight to hide the bottleneck's latency and keep its execution unit saturated.
Mixed operations (adds and multiplies in the same body):
-
Identify the critical path — dependent operations form a chain; independent ones overlap for free
-
Pick the slowest operation on that path:
acc += data[i] * weight[i]→ This is FMA, useDotProductacc += data[i]; acc *= factor;→ Multiply is slower, useMultiply- Mostly adds with occasional multiply →
Sum - Mostly multiplies with occasional add →
Multiply
-
Early exit trumps everything:
- With
ILP_BREAKorILP_RETURNin the body, branch prediction is usually the real bottleneck - Use
Search, whatever the arithmetic inside
- With
Quick decision tree:
Has early exit (break/return)? → Search
Doing acc += a * b (FMA)? → DotProduct
Doing acc += val? → Sum
Doing acc *= val? → Multiply
Doing min/max? → MinMax
Doing bitwise ops? → Bitwise
Unsure? → Search (safe default)
If all else fails, the ilp-loop-analysis clang-tidy check recognizes common loop patterns and suggests the right LoopType for you — with --fix it will even rewrite the loop. Still beta-quality, but worth a run. See tools/clang-tidy/.
The CPU profile headers are in cpu_profiles/ and contain instruction timing data used to compute optimal N values. Each profile includes a reference table:
| Instruction | Use Case | Latency | RThr | L×TPC |
| VFMADD231PS/PD | FMA | 4 | 0.50 | 8 |
| VADDPS/VADDPD | FP Add | 4 | 0.50 | 8 |
| VPMULLD | Int Mul | 10 | 1.00 | 10 |
Column definitions:
- Latency (L): Cycles from input ready to output ready
- RThr: Reciprocal throughput - cycles between starting new operations
- TPC: Throughput per cycle = 1/RThr
- L×TPC: The optimal unroll factor N
The formula: optimal_N = Latency × TPC
If FP add has L=4 and TPC=2, then N = 8 independent adds are needed to keep the pipeline saturated and hide the 4-cycle latency.
Creating custom profiles: Look up your CPU's instruction timings at uops.info or Agner Fog's tables, then create a header following the existing format in cpu_profiles/.
If you want to query the optimal unroll factor directly use...
constexpr auto N = ilp::optimal_N<ilp::LoopType::Sum, double>;Default Header values by type:
| LoopType | int32 | int64 | float | double |
|---|---|---|---|---|
| Sum | 4 | 4 | 8 | 8 |
| DotProduct | - | - | 8 | 8 |
| Search | 4 | 4 | 4 | 4 |
| MinMax | 4 | 4 | 8 | 8 |
| Multiply | 8 | 8 | 8 | 8 |
| Bitwise | 8 | 8 | - | - |
| Shift | 8 | 8 | - | - |
| Copy | 4 | 4 | 4 | 4 |
| Transform | 4 | 4 | 4 | 4 |
| Divide | - | - | 8 | 8 |
| Sqrt | - | - | 8 | 8 |
- C++20
- Header-only