How deleting one if-statement made this filter 4x faster

2 months ago

A developer deleted one if-statement from ordinary code. It ran up to four times faster. Here's why that makes sense.

Ask

Ask about this presentation

Answers are generated from this presentation.

Chapters

  1. 0:00How deleting one if-statement made this filter 4x faster
  2. 0:07Slowest when it keeps half. Faster when it keeps almost all
  3. 0:21Sort the same numbers first, and it runs 4.5x faster
  4. 0:34Your processor guesses which way each if goes, and races ahead
  5. 1:02Delete the if. Always write, then move the cursor by 1 or 0
  6. 1:21It's a trade: keep almost nothing, and the plain version wins
  7. 1:32The slow part was never the work. It was the guessing
Show transcript

How deleting one if-statement made this filter 4x faster

How deleting one if-statement made this filter 4× faster.

The slow part was never the work.
Rust CPU performance one deleted if

A developer deleted one if-statement from ordinary code. It ran up to four times faster. Here's why that makes sense.

Slowest when it keeps half. Faster when it keeps almost all

Slowest when it keeps half. Faster when it keeps almost all.

The same filter, over the same million numbers.
0.59
1%
2.69
25%
slowest
3.94
50%keeps half
2.75
75%
1.49
99%
greyblake.com/blog/branchless-rust · the author’s benchmark · 1,000,000 numbers · milliseconds

The code is a filter: run through a million numbers, keep the ones past a cutoff. Yet it is slowest when it keeps half of them, and more than twice as fast when it keeps almost all. The input never changes.

Sort the same numbers first, and it runs 4.5x faster

Sort the same numbers first, and it runs 4.5× faster.

Same data, same cutoff, same code.
4.15
shuffled
≈ 4.5×
0.93
sorted
The obvious fix, reserving the output space up front: about 2% faster.
greyblake.com/blog/branchless-rust · the author’s benchmark · milliseconds

The obvious fix barely helped, about two percent. So copying was never the cost. The real clue: sort the same numbers first, and the slow case runs four and a half times faster. Only the order changed.

Your processor guesses which way each if goes, and races ahead

Your processor guesses which way each ‘if’ goes, and races ahead.

What the processor does behind the code.
The fork
if x > threshold
→ keep the number
→ skip the number
Guesses a path. Races ahead. →
Right guess
The raced-ahead work is kept.
Nearly free.
Wrong guess
The work already started
is thrown away.
a wrong guess ≈ 15–20× the check
At 50%, on shuffled data, the guess is a coin flip. Wrong about half the time. That wasted work, not the copying, is the slow part.

A modern processor does not finish one step before starting the next. At every if, the processor cannot wait to see which way to go. So it guesses, and races ahead. On sorted data the answer barely changes, so the guess is nearly always right. On shuffled data, at the halfway mark, the guess is a coin flip. The processor is wrong about half the time, and every wrong guess throws away the work it already started. That wasted work, not the copying, is the slow part.

Delete the if. Always write, then move the cursor by 1 or 0

Delete the ‘if’. Always write, then move the cursor by 1 or 0.

The yes-or-no becomes arithmetic.
// keep the ones past the cutoff
input.iter().filter(|&x| x > threshold).collect()
out[n] = x;  // always write
n += (x > threshold) as usize;  // 1 if kept, 0 if not
out.truncate(n);  // cut the tail
quoted from the article
1%
25%
50%
75%
99%
idiomatic filter branchless
worst case: 3.94 → 1.03 ms
greyblake.com/blog/branchless-rust · the author’s benchmark · milliseconds

So the developer deleted the decision. Always write the number down. Then move a cursor forward by the comparison: one when the number passes, zero when it does not. Cut the leftover tail. Nothing is left to guess, so the timing goes flat. Worst case, about four milliseconds down to one.

It's a trade: keep almost nothing, and the plain version wins

It’s a trade: keep almost nothing, and the plain version wins.

Best case traded for worst case.
0.59 ms
plain
vs
1.09 ms
branchless
at 1% kept · save it for a loop you measured and found slow
greyblake.com/blog/branchless-rust · the author’s benchmark

It is a trade. Keep one percent of the numbers, and the plain version wins. Deleting the decision gives up the best case to protect the worst. So save it for a loop you measured and found slow.

The slow part was never the work. It was the guessing

The slow part was never the work. It was the guessing.

greyblake.com/blog/branchless-rust

The lesson reaches past this filter. Sometimes the slow part of your code is not the work. It is your processor guessing wrong about what comes next.