Chapter 26
The -O Optimizer
--exe and its inspection twin --cpp accept -O. Everything under it is semantics-preserving: it changes how the compiled program computes, never what it computes. It is opt-in and off by default.
rakupp --exe prog.raku -o prog # no optimizer
rakupp --exe -O prog.raku -o prog # optimizer on
rakupp --cpp -O prog.raku # print the optimized C++
What the generated code looks like without it
By default the transpiler is faithful but generic: every value is a boxed Value, operators in value position go through applyArith, and every user-sub call packs its arguments into a ValueList — a heap allocation per call.
// sub fib($n) { $n < 2 ?? $n !! fib($n-1) + fib($n-2) }
static Value u_fib(ValueList __a) {
Value v_sn = rtPos(__a, 0);
return rtLtB(v_sn, Value::integer(2LL)) // condition: always inline
? v_sn
: applyArith("+",
u_fib(ValueList{applyArith("-", v_sn, Value::integer(1LL))}),
u_fib(ValueList{applyArith("-", v_sn, Value::integer(2LL))}));
}
For a hot recursive function that is two costs on every one of about 1.6 million calls: a ValueList allocation, and value-position applyArith calls that dispatch on the operator string before touching the operands.
Pass 1 — direct-arity calls
A sub whose signature is entirely plain required positional scalars — no named, slurpy, optional, defaulted or destructured parameters — gets a direct-Value overload, plus a boxed adapter so any unusual call site still resolves. C++ overload resolution picks the right one.
static Value u_fib(Value v_sn) { … } // fast
static Value u_fib(ValueList __a) { return u_fib(rtPos(__a, 0)); } // adapter
A call site takes the fast overload when it passes exactly the right number of plain positional arguments — no :name(…) pairs, no |@slurp. Multi subs, indirect calls through &fib, and method calls are untouched.
The same idea covers 37 named builtins, each of which has a real C++ function (rtBAbs, rtBUc, rtBSin, …) rather than a std::function in a map. Chapter 27 tells that story, because the reason it works is not the one that was first assumed.
Pass 2 — inline arithmetic
For the common operators the generator emits inline helpers instead of applyArith:
| ops | helper | fast path |
|---|---|---|
+ - * | rtAdd / rtSub / rtMul | native int64, overflow to bignum |
** | rtPow | integer power by squaring |
< <= > >= == != | rtLt … | int64 compare |
% %% div | rtMod / rtDivides / rtDiv | floored int64 |
~ | rtConcat | direct concat when both are Str |
eq ne lt gt le ge | rtEqS … | byte-wise when both are plain Str |
// src/Interpreter.h
inline Value rtAdd(const Value& l, const Value& r) {
long long z;
if (rtBothInt(l, r) && !rakupp::add_ovf(l.i, r.i, &z))
return Value::integer(z);
return applyArith("+", l, r); // Rats, bignums, mixed types, coercions
}
rtBothInt tests that both operands are VT::Int and neither has grown a bignum. Overflow promotes to bignum exactly as applyArith does.
The string comparisons matter more than the integer ones, because they sat late in applyArith's dispatch chain — about 118 nanoseconds against 10 for a direct call. The guard is that both operands are plain Str: no Version/IO/Buf tag and no enum identity. A tagged value falls back to the full chain, which preserves Version part-comparison, enum stringification, junction autothreading and Whatever currying.
With both passes plus pass 3's condition lane, fib becomes:
static Value u_fib(Value v_sn) {
return ([&]() -> bool { // condition lane
do { if (!(rtIntBox(v_sn))) break; // guard the box
return (v_sn.i < 2LL); } while (0); // compare as raw int64
return rtLtB(v_sn, Value::integer(2LL)); // guard failed
}())
? v_sn
: rtAdd(u_fib(rtSub(v_sn, Value::integer(1LL))),
u_fib(rtSub(v_sn, Value::integer(2LL))));
}
No heap allocation, no string dispatch. Under a real C++ optimizer that collapses to tight native-integer recursion.
Pass 3 — guarded native-int lanes
Passes 1 and 2 still build a boxed Value for every intermediate result: $sum += $_ * 2 - 1 constructs four of them per evaluation. Pass 3 removes them.
A straight-line integer expression whose leaves are int literals and plain scalar variables is computed in raw int64. Each leaf is tag-guarded at run time, each operation is overflow-checked, and the result is stored into the target's existing box with no Value construction at all:
// $sum += $_ * 2 - 1 (inside a native range loop)
{ bool __lok = false; do {
if (!(rtIntBox(v__t0) && rtIntSlot(v_ssum))) break;
long long __t1; if (rakupp::mul_ovf(v__t0.i, 2LL, &__t1)) break;
long long __t2; if (rakupp::sub_ovf(__t1, 1LL, &__t2)) break;
long long __t3; if (rakupp::add_ovf(v_ssum.i, __t2, &__t3)) break;
v_ssum.i = __t3; __lok = true;
} while (0);
if (!__lok) { v_ssum = rtAdd(v_ssum,
rtSub(rtMul(v__t0, Value::integer(2LL)),
Value::integer(1LL))); } }
Any guard failure, overflow, or domain failure falls through to the untouched boxed emission. That is sound because lane leaves are pure — literals and variable reads — so re-evaluating them on the slow path has no side effects.
The lane applies to statement-position assignment to a plain scalar, statement- position ++/--, and conditions. The store additionally requires rtIntSlot: not an enum-typed box, whose stringification is its name rather than its number.
Operations covered: + - * overflow-checked, unary minus, floored % mirroring rtMod bit for bit, %%, and the six comparisons. Everything else — Nums, strings, Rats, bignums, array elements, method calls — fails the lane at compile time or its guards at run time.
Three defaults that are not -O
Three changes look like optimisations and are shipped as defaults in every mode, because each fixes a correctness wart rather than adding speed.
In-place ~=. $s ~= … naively rebuilds the whole string each step, so n appends do quadratic work. The interpreter and Rakudo both build strings in linear time, so this was a wart:
// src/Interpreter.h
inline void rtCatAssign(Value& l, const Value& r) {
if (l.t == VT::Str && r.t == VT::Str) { l.s += r.s; return; }
l = applyArith("~", l, r);
}
The interpreter pairs it with sink context, so a loop body's assignment does not copy its growing result either.
.sort($key) extracts the key once per element. .sort takes either a 2-ary comparator or a 1-ary key extractor, and the difference is asymptotic: a comparator is supposed to run per comparison, but a key extractor runs once per element. Raku++ used to call the 1-ary block inside the comparator:
// after — a Schwartzian transform: n calls, then compare the keys
std::vector<Value> keys(items.size());
for (size_t i = 0; i < items.size(); i++)
keys[i] = callCallable(blk, {items[i]});
std::stable_sort(order.begin(), order.end(), [&](size_t x, size_t y) {
return valueCmp(keys[x], keys[y]) < 0;
});
Sorting codepoints by the length of their Unicode name:
| N | before | after | Rakudo |
|---|---|---|---|
| 8 K | 5.70 s | 0.29 s | 0.34 s |
| 64 K | 18.09 s | 0.68 s | 0.63 s |
| 128 K | still running at 95 s | 1.33 s | 1.09 s |
The ratio grows with log n, which is the shape the change predicts.
Never numify a string by throwing, which is Chapter 10's stod story and was found while profiling the comparator case above.
Forwarding the C++ optimization level
--exe compiles the generated C++ at -O2 by default; a level on the flag is passed through:
| flag | codegen passes | C++ compile |
|---|---|---|
| (none) | off | -O2 |
-O | on | -O2 |
-O3 / -Os / -O0 | on | that level |
*The integer passes only pay off with C++ inlining. At -O0 the rt* helpers become real calls with no fast path, so -O0 is slower* than the default. It is for inspecting the generated C++, not for speed.
-O is a speed switch and not a size lever: the binary is dominated by the statically linked runtime, mostly the Unicode tables, not by the program's own code. -Os shrinks nothing that matters and costs 20 to 50% of the lane speed-up. Use -O for speed and strip if size matters.
Measured
--exe, best of six after a discarded warm-up, startup-inclusive:
| Benchmark | --exe | --exe -O | speed-up | what -O reached |
|---|---|---|---|---|
| fib | 166.5 ms | 47.0 ms | 3.5× | direct-arity calls, condition lane |
| loopsum | 27.1 ms | 8.6 ms | 3.2× | the += lane |
| streq | 46.5 ms | 17.5 ms | 2.7× | int lanes atop inline eq |
| arrayops | 102.0 ms | 77.8 ms | 1.3× | map/grep body boxing |
| regex | 61.9 ms | 60.3 ms | 1.0× | the regex engine dominates |
| hash | 15.3 ms | 14.9 ms | 1.0× | hash slots, not scalars |
| sortnums | 49.9 ms | 48.6 ms | 1.0× | .sort dominates |
| bigint | 29.2 ms | 29.2 ms | 1.0× | BigInt multiply |
| strcat | 3.9 ms | 4.8 ms | 0.8× | ~= already linear by default |
The pattern is clear and worth stating: where the time is inside a runtime method — the regex engine, bignum multiply, .sort — -O cannot reach it. What it removes is boxing and allocation in the program's own code.
A dedicated showcase suite in tools/optbench/, each program written to lean on one pass, is compiled twice and checked byte-identical against the interpreter and Rakudo before being timed:
| benchmark | --exe | --exe -O | speed-up | rakudo |
|---|---|---|---|---|
| sieve | 1029.3 ms | 25.4 ms | 40.6× | 994.5 ms |
| powmod | 531.5 ms | 50.6 ms | 10.5× | 716.8 ms |
| intsum | 283.1 ms | 35.9 ms | 7.9× | 624.0 ms |
| fibcalls | 701.3 ms | 190.9 ms | 3.7× | 1353.3 ms |
| stringbuild | 22.3 ms | 21.9 ms | 1.0× | 204.7 ms |
sieve's whole inner loop — while $d * $d <= $n, if $n %% $d, $d++ — runs as raw int64 under pass 3, taking it from a tie with Rakudo at plain --exe to far ahead. stringbuild is flat because in-place ~= is default in both.
The box stays; only its per-operation cost goes
A common question: does a native-compiled my int $x become a bare C++ long long? No. The generated code keeps one uniform representation at every optimization level. my int $s compiles to Value v_ss.
- Default
--exe:$s = $s + $iisrtAdd, which constructs a freshValuefor the result. -Opass 3: the arithmetic runs as rawint64in registers and the result is written into the existing box's.islot. NoValueis constructed per operation — but the storage is unchanged, and loops still copy fullValues.
Why keep the box? Because the transpile is uniform: any expression must be able to flow anywhere — into a runtime function, an array element, say(), an untyped assignment — and Raku's my int is readable in Any context while a non-native Int can hold Nil or promote to bignum. Emitting a bare long long local is only sound once an analysis proves the variable never escapes into a Value context, which is a genuine escape-analysis pass and the natural successor to pass 3.
Correctness
-O is validated to produce output byte-for-byte identical to the interpreter:
- every benchmark program matches with
-Oon; - every deterministic example compiles with
--exe -Oand matches its golden output; - the arithmetic fallbacks are checked directly — int64 overflow to bignum,
Int/Nummixes, string coercion,<=>(left on the general path), and+=at the int64 boundary; - the lane fallbacks likewise:
+=/++/*=crossing int64, floored%with negative operands,%= 0, and comparisons whose intermediates overflow.
The design leans on the fallback. Anything the fast path does not recognise routes to the same runtime code the non--O build uses, which is why "identical output" is a checkable claim rather than a hope.
What is next
- value-position lanes — laneable integer expressions inside larger expressions (call arguments, list elements) still box;
Numlanes — the same trick fordouble, with tag guards and no overflow checks;- array-element lanes —
@a[$i]inside the lane, with a bounds and tag guard; - native int locals — the big one, and a real escape-analysis pass: prove a typed local never escapes into a
Valuecontext and never leavesInt, then emit a rawlong longwith no box at all, eliminating both the storage and the per-iteration copies.