01 article

Push Ifs Up and Fors Down: The Rewrite That Loses at 50/50 Data

The beloved refactor (hoist the branch to the caller, batch the loop) just got a category-theory proof and a Rust benchmark in the same month. The proof says it's legal; the benchmark says it loses at 50/50 data, and the disassembly explains why.

Push Ifs Up and Fors Down: The Rewrite That Loses at 50/50 Data

Push Ifs Up and Fors Down: The Rewrite That Loses at 50/50 Data

The idiom is four years old and it just had its second viral moment. It started with a November 2023 note from Alex Kladov (matklad) and a rule in TigerBeetle's Tiger Style guidelines: when you split a big function, keep the branching in the caller and push the loops into batch helpers. In October 2026, Debasish Ghosh published a long essay giving the rule an algebraic treatment, and it hit the front page of Hacker News. Then someone at blog.zxce3.net did what idiom essays almost never do: measured whether the rewrite makes code faster. It doesn't. Not at 50/50 data, not at 100% data, and the reason why is the best part of the story.

The rule, in two moves

Push ifs up. If a function branches on its input, move that branch to the caller. The stock example: a function that takes an Option<Walrus>, checks for None internally, and does nothing when it finds one. Push the check up and the caller discards the None values while the function takes a plain Walrus. The type signature now states the precondition, the input space gets narrower, and the function has one less state to think about.

Push fors down. If a function processes one item at a time and callers loop over it, give the function a batch form. Instead of calling frobnicate(walrus) in a loop, call frobnicate_batch(walruses) and let the loop live inside. Setup gets paid once, the inner loop is branch-free and cache-friendly, and the compiler gets a shot at vectorizing. The two moves compose:

// if down: the worker branches on every call
for w in walruses {
    if let Some(w) = w { frobnicate(w) }
}

// if up, for down: the caller filters once, the worker sees plain values
let values: Vec<Walrus> = walruses.into_iter().flatten().collect();
frobnicate_batch(&values);

matklad's motivation was clarity first: control flow is where bugs live, so concentrate it in one branching function and let everything else be straight-line subroutines. Performance came second, and that ordering matters for what happens below.

The idea isn't new, it's just spelled differently elsewhere

Query optimizers have run this playbook for decades. Selections and projections execute as early as possible; expensive joins wait until the inputs are small. The vocabulary is inverted because a plan tree grows from the leaves: "push down a predicate" in database terms means run it early, the same direction as "push the if up" in a call stack. Vectorized execution engines are the fors-down move at database scale. Volcano-style iterators call next() once per row, paying per-call overhead each time; batch engines call an operator once per thousand rows and run a tight inner loop. Same idea, different costume.

The business-software version is the n+1 query problem. An ORM loop that fires one database call per record is a for loop sitting upstream of work that wants to be batched. Pushing that loop into a WHERE IN clause or a bulk insert buys orders of magnitude. At the database boundary, fors-down isn't folklore, it's just correct engineering.

What the algebra says (and where it draws the line)

Ghosh's essay does the part most posts skip: it shows when the rewrite is legal, not just when it looks tidy. Option<A> is a coproduct, 1 + A. A function that takes an Option and branches inside is, by the universal property of coproducts, exactly a pair of functions, one for each case. Pushing the if up factors that pair apart: the caller keeps the trivial summand, the core function becomes the interesting one.

The familiar "filter before you map" advice gets the same treatment, with a warning. The law is filter p . map f == map f . filter (p . f), and notice the predicate changes type along the way. Both sides are still one pass, and the rewrite only saves work when p . f reduces to a cheap predicate on the input, typically when p inspects a field f never touches.

The limits come with the territory. A condition can leave a loop only when it's loop-invariant; a per-element test can move to a boundary and get recorded in a type, but it cannot vanish. A selection can move below a join only when it references one side. And fors-down is a cost transformation, not a meaning-preserving equivalence: you changed the arrow from A -> B to [A] -> [B] and you'd better want the batch.

The benchmark: the branch moves, it doesn't vanish

The zxce3 write-up built the two shapes in Rust (rustc 1.97.0, -O, a 4-core Xeon at 2.1 GHz), ran them over 4 million Option<u32> values where each is Some with probability P, and reported the best of 15 runs:

P(Some)Branch in loop (ms)filter_map + batch (ms)
0%4.561.50
5%7.864.98
25%15.7313.20
50%22.8125.03
75%15.0920.40
100%6.348.98

Read the shape, not just the numbers. The rewrite wins big when data is mostly None: the filter races through and the batch loop does almost nothing. It loses when data is mostly Some, because the in-loop check is perfectly predicted by the branch predictor and costs about nothing, while the extra pass and the output buffer are pure overhead. And at 50/50 it still loses, which is the case everyone assumes hoisting rescues you from. The filter_map path contains the same unpredictable branch as the original loop. In the author's words: "I moved the misprediction, I did not remove it."

Branchless source is a request, not a guarantee

The textbook fix for unpredictable branches is branchless compaction: always write the value, advance the output index by the validity bit. That version, written naturally with Option, ran in 21.7 ms at 50/50, indistinguishable from filter_map. The disassembly explained it: LLVM lowered unwrap_or to a conditional jump that skips the payload read, because a None payload is unspecified and the compiler is allowed to not touch it. Storing values and validity flags in two separate arrays removed the obstacle. Every element became a defined u32, the flag became a u8, and compaction ran flat at 2.9 ms regardless of P. Layout beat syntax, and nobody would know without reading the assembly.

Keep the rule, drop the folklore

The benchmark doesn't invalidate the idiom; it invalidates the performance guarantee people bolted onto it. Keep pushing ifs up for the reasons its authors give: fewer states in play, control flow in one place, preconditions carried in types where the compiler can enforce them. Keep pushing fors down where batching is real, especially at the database boundary, where per-record round trips dwarf any branch predictor story. Reuse tilts it further: filtering once and running several passes over the compacted array amortizes the filter, and in the same benchmark the filter_map version tracked single-pass cost across three passes instead of tripling.

Two cautions for the code-review pile. A filter that copies a large collection can cost more than the branch it saved, so batch only when the batch actually happens. And in the AI-generated era this idiom makes a useful review rubric: agents produce nested ifs and inline loops at industrial scale, and "who owns this branch, and does this loop belong in the caller" catches real problems. Just don't cite the rewrite as a speedup you didn't measure. The algebra tells you which rewrites are legal; only a benchmark on your own data distribution tells you which ones are faster.

Comments