Optimizespec

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.

The question

Should I write x << 1 instead of x * 2, and why does the compiler refuse to simplify some of my floating-point arithmetic?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

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.

What this phase may assume or do

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 + 0 is unconditionally x for integers and is not valid for IEEE-754 floats, because of negative zero. That single example is the lesson.
  • -ffast-math is 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 / 8 is not x >> 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

targetLatency and throughput numbers are per-microarchitecture. Three-cycle integer multiply is representative of recent x86-64 and AArch64 cores; a small in-order embedded core may have no multiplier at all and synthesise multiplication in software, where the shift really is dramatically cheaper. The generalisation that survives every target is "the compiler knows the cost table and you are guessing", not any particular cycle count.

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.

The same rewrite, two number systemsspec
RewriteIntegersIEEE-754 floats
x + 0xspecValid, unconditionallyInvalid: -0.0 + 0.0 is +0.0, which is distinguishable from -0.0
x * 1xspecValid, unconditionallyValid — multiplication by one preserves sign, NaN and infinity
x * 00specValid, unconditionallyInvalid: NaN * 0.0 is NaN, and -1.0 * 0.0 is -0.0
x / x1specInvalid — x may be zero, which trapsInvalid: 0.0 / 0.0 and NaN / NaN are both NaN
(a + b) + ca + (b + c)specValid for wrapping integers; in C, signed overflow is undefined so the compiler may assume it either wayInvalid: addition is not associative, and the two groupings give different results
x * 2x << 1targetValid for unsigned; for signed, valid under the language's overflow rulesNot applicable — no shift on floats; the compiler adjusts the exponent instead
x / 8x >> 3specValid for unsigned; INVALID for signed, because shifting rounds toward negative infinity and division truncates toward zeroValid, 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.

The additive identity, and the reason it needs a type
Before
%2 = int %1 + 0
%3 = int %2 * 1
print %3
After
print %1
Legal only when

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.

Illegal when

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

typicalMainstream compilers implement induction-variable strength reduction inside a broader scalar-evolution analysis (LLVM's SCEV, GCC's IVOPTs) that also drives unrolling, vectorization and bounds-check elimination. AtlasLang does none of it: its strength reduction is the algebraic identities only, and its loops are unanalysed. That is a real gap, not a simplification of something present.

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 NaN that 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 NaN check stops working under fast math because the compiler proved x != x is always false — it is not, for NaN, 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 + 0 and x * 1 are 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: NaN handling, 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 OpenMP reduction clauses 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/passes on let y = x + 0; and watch the addition disappear while everything else stays.
  • Compiler Explorer on int f(int x){ return x / 8; } versus unsigned 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-math versus clang -S -O2 on 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 fast or fadd reassoc nsz in clang -S -emit-llvm -ffast-math output. That is the flag being recorded on the operation rather than applied globally.
  • GCC: -fdump-tree-forwprop-details shows algebraic simplifications; -fopt-info-loop reports 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-math just makes floating point faster." It makes the compiler compile a different language, one where addition is associative and NaN does not exist. Programs that relied on either are now wrong.
  • "x / 2 and x >> 1 are 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.

Replacing multiplies with shifts is a worthwhile source-level optimization.
It is a decision the backend makes with the target cost table in hand. Writing it yourself costs readability, risks being wrong for signed types, and changes nothing about the output on a mainstream target.
Floating point is just approximate, so small rewrites are harmless.
The rewrites in question are not approximations, they are different values: positive zero instead of negative zero, a number instead of NaN. Approximation is not the issue; identity over the domain is.
Optimization flags do not change what a program computes.
Ordinarily true, and -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.

specThe floating-point restrictions come from IEEE-754 and from the language standard, not from the compiler. C and C++ require IEEE-conforming behavior by default in their usual configurations; Java specifies it explicitly; Python's floats are C doubles. A compiler that reassociates without permission is non-conforming, not clever.
targetWhich operation is actually cheaper is a property of the microarchitecture. Integer multiply is roughly three cycles on recent x86-64 and AArch64 cores and fully pipelined; on small embedded cores without a hardware multiplier it is a software routine costing tens of cycles, where shift-based rewriting is a large win. Cycle counts do not transfer between targets.
implementationAtlasLang performs only the additive and multiplicative identities, guarded on integer type, and has no induction-variable analysis at all. LLVM does this work in InstCombine plus SCEV-driven loop passes and GCC in forwprop plus IVOPTs; both perform vastly more rewrites and both gate the floating-point ones on per-instruction fast-math flags.

If you were asked this in an interview

  • Is x + 0 always x? Answer for int and for double, and say why the answers differ.
  • A colleague replaces x / 8 with x >> 3 in a function taking a signed int. What breaks?
  • What does -ffast-math actually permit, and why is it not enabled at -O3?

Connections