Strength Reduction and Algebraic Identities
Replace an operation with a cheaper one that computes the identical value. The real content is not that shifts beat multiplies on some 1990s CPU — it is that `x + 0` is unconditionally `x` for integers and is not valid for IEEE-754 floats, which is why `-ffast-math` exists and why it changes what a program means.
Should I write x << 1 instead of x * 2, and why does the compiler refuse to simplify some of my floating-point arithmetic?
IR instructions annotated with an operand type. The type is what makes the pass possible: the same syntactic rewrite is valid for int and invalid for float, so an untyped IR could not perform it soundly. AtlasLang restricts every identity to i.type === 'int' for exactly this reason, even though the language has no floats — the restriction documents the argument.
The rewritten form must produce the identical value for every input the operand type admits. Not "for the values that occur in practice" and not "up to rounding" — identical, for the whole domain. x + 0 satisfies this for every integer type; x + 0.0 does not satisfy it for IEEE-754, because it maps negative zero to positive zero. x * 2 becoming a shift is valid where the target defines the shift to agree with multiplication over the full range, which for unsigned is unconditional and for signed depends on the language's overflow rules.
Key points
- The legality condition is exact identity of value over the entire operand domain — not approximate equality and not equality on typical inputs.
x + 0is unconditionallyxfor integers and is not valid for IEEE-754 floats, because of negative zero. That single example is the lesson.-ffast-mathis a semantics flag, not a speed flag: it changes which programs the compiler is compiling, and its effects can escape the translation unit.- Signed
x / 8is notx >> 3: division truncates toward zero and arithmetic shift rounds toward negative infinity, so they disagree for negative operands. - Hand-writing shift tricks for multiplication is not worth doing: the compiler has the target cost table and you do not, and the source loses its intent.
- The version of strength reduction that is worth real speed is the induction-variable one inside loops, which replaces a per-iteration multiply with a per-iteration add.
The part that is genuinely about cost, and how small it is
The folklore version of strength reduction is a list of tricks: multiply by a power of two becomes a shift, divide by a constant becomes a multiply by a magic reciprocal, x % 2 becomes a mask. All of those are real transformations that real compilers do. What has changed is how much any of them is worth on a current machine, and the honest answer is: usually very little, and the compiler already knows.
A modern out-of-order core executes an integer multiply in about three cycles, fully pipelined, so a multiply issued every cycle costs one cycle of throughput. A shift costs one cycle of latency. The difference exists and it is two cycles of latency on an operation that is almost never the critical path — and the compiler will pick whichever one its cost model prefers for the specific target, which it knows and you probably do not. Writing x << 1 instead of x * 2 in source does not usually make the output different; it makes the source harder to read and it makes the intent — multiply by two — invisible.
Integer division is the one place where the cost gap is still large: a division is on the order of twenty to forty cycles and is not fully pipelined, and the reciprocal-multiply trick genuinely wins. That transformation is not something to write by hand either; it requires computing a magic number and a shift with a correctness proof, and every mainstream compiler does it for a constant divisor.
The part that is about semantics, which is the part that matters
The interesting content of this pass is not cost, it is which rewrites are permitted at all. x + 0 -> x is unconditional for every integer type in every language: addition of the additive identity returns the operand, for all values, with no exceptions. That is a fact about the integers, not about the compiler.
For IEEE-754 floating point it is false. If x is negative zero, x + 0.0 is positive zero, and the two are distinguishable: 1.0 / -0.0 is negative infinity, 1.0 / 0.0 is positive infinity. So a compiler that rewrites x + 0.0 to x has changed what the program computes for one input. It is not a rounding difference; it is a different value.
The list continues, and each entry is a program someone has been surprised by. x * 0.0 is not 0.0 — it is NaN when x is NaN and negative zero when x is negative. x / x is not 1.0, for zero and for NaN. (a + b) + c is not a + (b + c), because floating-point addition is not associative, which is why a compiler may not reassociate a sum to vectorize it without permission. Every one of these is a rewrite that is valid over the reals and invalid over the floats.
This is where -ffast-math comes in, and it deserves stating precisely: it is not a performance flag, it is a semantics flag. It tells the compiler to optimize as though floating-point arithmetic were real arithmetic — associative, with no NaN, no infinities, and no signed zero. Under it the rewrites above become legal, code gets faster, and programs that depended on IEEE-754 behavior stop working. It also, on some toolchains, changes the behavior of code in other translation units by setting flush-to-zero mode at startup, which is why it is one of the few flags whose effects escape the file it was applied to.
| Rewrite | Integers | IEEE-754 floats |
|---|---|---|
x + 0 → xspec | Valid, unconditionally | Invalid: -0.0 + 0.0 is +0.0, which is distinguishable from -0.0 |
x * 1 → xspec | Valid, unconditionally | Valid — multiplication by one preserves sign, NaN and infinity |
x * 0 → 0spec | Valid, unconditionally | Invalid: NaN * 0.0 is NaN, and -1.0 * 0.0 is -0.0 |
x / x → 1spec | Invalid — x may be zero, which traps | Invalid: 0.0 / 0.0 and NaN / NaN are both NaN |
(a + b) + c → a + (b + c)spec | Valid for wrapping integers; in C, signed overflow is undefined so the compiler may assume it either way | Invalid: addition is not associative, and the two groupings give different results |
x * 2 → x << 1target | Valid for unsigned; for signed, valid under the language's overflow rules | Not applicable — no shift on floats; the compiler adjusts the exponent instead |
x / 8 → x >> 3spec | Valid for unsigned; INVALID for signed, because shifting rounds toward negative infinity and division truncates toward zero | Valid, since 8 is a power of two and the exponent adjustment is exact |
What AtlasLang actually rewrites, and what its restriction is documenting
AtlasLang's strength-reduction pass performs the additive and multiplicative identities and nothing else: x + 0, x - 0, x * 1, x / 1, 0 + x, 1 * x, and x * 0 to zero. Every one of them is unconditional — and the pass guards them with if (i.type !== 'int') continue.
That guard is doing two jobs. The narrow one is to stop "a" + "" being treated as an additive identity: it happens to be one for strings today, and relying on that would break the moment a string type gained a different empty value. The broad one is documentation. AtlasLang has no floating-point type, so the guard can never fire on a float — and it is written anyway, so that the reason the identities are unconditional is visible in the code rather than assumed.
The rewrite mechanism is worth noticing too. Rather than replacing the instruction with a copy, the pass builds a map from destination register to replacement value, rewrites every use through it, and then deletes the defining instructions. That is the same shape as [[copy-propagation]] and [[common-subexpression-elimination]] — record a substitution, apply it everywhere, drop the orphans — which is not a coincidence: most value-level optimizations are substitutions with different conditions on when the substitution is allowed.
%2 = int %1 + 0 %3 = int %2 * 1 print %3
print %1
Both operands are integers, and for every integer value v, v + 0 = v and v * 1 = v. The identity holds over the entire domain of the type with no exceptional values, so substituting %1 for %2 and then for %3 cannot change any computed result.
The type is an IEEE-754 float. %2 = float %1 + 0.0 may not be rewritten to %1, because when %1 is negative zero the addition produces positive zero and the two behave differently under division and under copysign. A compiler performs this rewrite only when told that IEEE semantics are not required — which is what -ffast-math and LLVM's per-instruction fast-math flags say, and it changes what the program means rather than how quickly it means it.
Induction variables: the version that is worth real speed
The transformation the term originally named is not about single expressions at all. Classical strength reduction operates on *induction variables* inside a loop: a value computed as a multiplication of the loop counter is replaced by a value updated with an addition each iteration.
A loop that computes base + i * 8 on every iteration is doing a multiply per iteration. If i increases by one each time, the address increases by eight each time, so the multiply can be replaced with an add on a value carried around the loop. That is a real win, it applies to essentially every array access in every loop after [[lowering]] turns indexing into address arithmetic, and it is the transformation that makes loop-heavy code competitive — see [[loop-transformations]].
The legality condition is different in kind from the identity rewrites: it depends on proving that the value really is an affine function of the loop counter, that the loop counter really does increase by a constant, and that the derived value does not overflow in a way the language cares about. That last condition is why induction-variable work in C is entangled with signed-overflow undefined behavior — the compiler is allowed to widen an int induction variable to a pointer-sized one precisely because signed overflow is undefined, and cannot make the same move for an unsigned, whose wraparound is defined.
How it works
The steps, in the order the compiler takes them.
- For each binary instruction, check the operand type; identities that hold for one type do not automatically hold for another.
- Test the operands against the identity elements for the operator — zero for addition and subtraction, one for multiplication and division, zero for multiplication's annihilator.
- Record a substitution from the instruction's destination register to the surviving operand, rather than rewriting in place.
- After the scan, apply every substitution to every use in the function, then delete the instructions whose destinations were substituted.
- For the induction-variable form, first prove the value is an affine function of a loop counter with a constant step, then introduce a new variable initialised before the loop and incremented by the step-scaled constant inside it.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A numerical result changes at a higher optimization level or after enabling fast math, and the difference is not a small rounding error but a sign flip or a
NaNthat used to be caught. Something that depended on IEEE-754 was reassociated. - Signed division by a power of two is replaced by an arithmetic shift by hand, and every negative input is off by one. The symptom is a computation that is correct for positive test data and wrong in production.
- A
NaNcheck stops working under fast math because the compiler provedx != xis always false — it is not, forNaN, which is exactly what the check was for. - A library compiled with fast math sets flush-to-zero at process start, and an unrelated library's denormal handling changes. The bug appears only when both are linked into the same binary.
- A hand-written shift is applied to a value that later becomes wider than expected, and the shift count exceeds the type width — undefined behavior in C, and in practice a value that is unchanged, zero, or arbitrary depending on the target.
When it helps
- Constant divisors: replacing a division with a reciprocal multiply is a genuine twenty-cycle-scale win and is done automatically for every constant divisor.
- Address arithmetic inside loops, via induction-variable reduction, which is where the transformation earns its name and most of its value.
- Cleaning up after other passes:
x + 0andx * 1are rarely written by hand but are produced constantly by inlining, specialization and constant propagation.
When it hurts
- Written by hand in source, where it obscures intent, is often wrong for signed types, and is redundant because the compiler would have done it — with better information about the target.
- Under fast math, where the additional rewrites are legal only because the flag redefined the language, and any code depending on IEEE-754 behavior is now silently wrong.
What it costs
Every one of these is paid by something.
- Enabling fast-math rewrites buys real speed — reassociation is what allows a floating-point reduction loop to be vectorized at all — and pays with IEEE-754 conformance:
NaNhandling, signed zero, and reproducibility of results across optimization levels are all forfeited. - Induction-variable strength reduction buys a multiply per iteration and pays with an extra live value carried around the loop, which raises register pressure at the point in the program where pressure is most expensive.
- Implementing the full algebraic simplifier buys a long tail of small wins and costs a great deal of implementation surface with a nasty failure mode: LLVM's InstCombine is tens of thousands of lines, and a single wrong identity in it is a silent wrong-code bug across every program compiled.
What else you could do
What a different compiler or language does instead, and when that is better.
- Let the backend decide. Instruction selection has the target cost model and can pick
lea, a shift, or a multiply per target — writing the source as the operation you mean and letting[[instruction-selection]]choose is strictly better informed. - For a divisor known only at run time but reused many times, compute the reciprocal once yourself and multiply — the manual version of what the compiler does for constants, and worth it in a hot loop.
- Where reassociation is what you need, request it locally rather than globally:
#pragma clang fp reassociate(on)and OpenMPreductionclauses grant permission for a specific region instead of the whole program. - Use an integer or fixed-point representation where exactness matters. Money in floating point is the standard example of a problem that algebraic rewriting makes worse and a representation change makes disappear.
See it for yourself
The flag, dump or tool that shows you this directly.
- Toggle strength reduction alone at
/compilers/passesonlet y = x + 0;and watch the addition disappear while everything else stays. - Compiler Explorer on
int f(int x){ return x / 8; }versusunsigned f(unsigned x){ return x / 8; }: the unsigned version is one shift, the signed version has extra instructions to fix up the rounding. clang -S -O2 -ffast-mathversusclang -S -O2on a floating-point reduction loop, diffed — the vectorized version appears only with the flag, because reassociation is what makes it legal.- LLVM IR carries the permission per instruction: look for
fadd fastorfadd reassoc nszinclang -S -emit-llvm -ffast-mathoutput. That is the flag being recorded on the operation rather than applied globally. - GCC:
-fdump-tree-forwprop-detailsshows algebraic simplifications;-fopt-info-loopreports induction-variable work.
Plausible wrong readings
Stated the way a confident engineer states them.
- "Shifts are faster than multiplies, so I should write shifts." On a current core the multiply is a few cycles, fully pipelined, and the compiler picks the better encoding per target anyway. What you lose is the reader's ability to see that you meant multiplication.
- "
-ffast-mathjust makes floating point faster." It makes the compiler compile a different language, one where addition is associative andNaNdoes not exist. Programs that relied on either are now wrong. - "
x / 2andx >> 1are the same." For unsigned, yes. For signed, they differ for every negative odd value, because one truncates toward zero and the other toward negative infinity. - "The compiler cannot simplify my float arithmetic because it is not smart enough." It is not permitted to. The identities are false over IEEE-754, and it is being correct rather than timid.
Misconceptions
The claim, and what is actually true.
NaN. Approximation is not the issue; identity over the domain is.-ffast-math is the well-known exception. It is why it is separated from -O3 rather than being part of it, and why it warrants a deliberate decision per project.Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Some operations can be replaced by cheaper ones that give exactly the same answer — adding zero, multiplying by one, dividing by a power of two. The rule is that the answer must be the same for every possible input, and that rule is what makes these rewrites fine for whole numbers and not fine for floating-point numbers.
practical
Write the operation you mean and let the compiler choose the encoding. If you are tempted to hand-optimize arithmetic, check the disassembly first — the transformation you had in mind has usually already happened. If floating-point results change between builds, look for a fast-math flag before looking for a bug; and if you need reassociation for speed, grant it locally with a pragma rather than globally for the project.
advanced
The general machinery is an algebraic simplifier over a typed IR with per-operation permission flags — which is why LLVM attaches nsw, nuw, exact, fast, nsz, reassoc and contract to individual instructions rather than to the module. That granularity is what lets a compiler honour IEEE-754 in one function and reassociate freely in another, and what lets a language decide its own overflow semantics: nsw on an add is the frontend saying "signed overflow is undefined here, so you may assume it does not occur", and it is the single flag that most determines how well C loop code optimizes — see [[ub-and-optimization]].
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
If you were asked this in an interview
- Is
x + 0alwaysx? Answer forintand fordouble, and say why the answers differ. - A colleague replaces
x / 8withx >> 3in a function taking a signed int. What breaks? - What does
-ffast-mathactually permit, and why is it not enabled at-O3?