Quant developer HFT course · Module 1: Reading hot code
Branches, indirect calls and inlining
The question
This loop has an if inside it. When is that if expensive, and when is it essentially free?
The pipeline guesses
A modern core works on many instructions at once, each at a different stage: fetched, decoded, waiting for its inputs, executing, retiring. To keep that pipeline full it cannot wait for a branch to be decided before fetching the instructions after it, so it guesses. A branch predictor, trained on the history of each branch, picks a direction and the core carries on down that path speculatively.
When the guess is right, the branch costs almost nothing. When it is wrong, everything fetched after the branch is thrown away and the core restarts from the correct target. On current server cores that costs on the order of fifteen to twenty cycles, every time.
A toy three-stage pipeline. The CPU guessed the branch would go to A and started on it; when the jump resolves the other way, that work is discarded and B starts from the front. Real cores have far deeper pipelines, so the loss is larger.
-
Step 1 / 4
cmp and jne enter the pipeline.
-
Step 2 / 4
Before jne has executed, the core guesses the branch goes to A and starts fetching A1.
-
Step 3 / 4
jne executes: the guess was wrong. A1 and A2 are thrown away.
-
Step 4 / 4
B1 starts again from the front of the pipeline. The cycles spent on A are lost.
Predictable and unpredictable branches
What matters is not whether a branch is taken but whether its direction follows a pattern the predictor can learn:
- A loop condition that is true a thousand times and false once: predicted correctly almost every time.
- An error check that never fires: free.
if (q.instrument == wanted)where one quote in a hundred matches: nearly free.if (q.side == BID)on a feed where bids and asks alternate at random: wrong about half the time.
The same code can be fast on one input and slow on another. A classic demonstration is summing the values above a threshold in an array: on sorted data the branch is predictable and the loop is fast; on the same data shuffled it can be several times slower.
When a branch really is unpredictable and both sides are cheap, you can remove it by computing both and selecting:
// branchy
if (q.side == 0) bid_qty += q.qty; else ask_qty += q.qty;
// branch-free: the side selects which counter to add to
std::int64_t qty[2] = {0, 0};
qty[q.side] += q.qty;
Compilers often do this themselves with a conditional move. Check the generated code before rewriting; a branch-free version that does more work can be slower when the branch was predictable.
C++20's [[likely]] and [[unlikely]] tell the compiler which side to lay out as the straight-line path. They help the code layout, so the common case falls through; they do not change the hardware predictor.
Calls the compiler cannot see through
A direct call to a function whose body the compiler can see is usually inlined in hot code: the body is pasted at the call site and optimised together with the caller. Inlining removes the call, but the real gain is what it enables. Constants propagate into the body, dead branches disappear, loops can be vectorised across what used to be a call boundary.
An indirect call (through a function pointer, a virtual function, or a std::function) cannot normally be inlined because the target is not known at compile time. That costs the jump itself, which the CPU must also predict, and it costs every optimisation inlining would have allowed. The next two lessons look at the two most common sources of indirect calls.
Indirect branches are predicted too. If the same call site always goes to the same target, the predictor learns it and the jump is cheap. A call site that dispatches across many targets in an irregular pattern pays for mispredictions as well.
In practice
For hot code: keep the common path straight, push error handling into separate functions marked cold or [[unlikely]], keep the functions the hot path calls visible to the compiler (in headers, or with link-time optimisation), and let measurements, not intuition, decide whether a branch is worth removing. Module 5 returns to the pipeline in more depth.
Check yourself
Question 1 of 3
Which branch is most likely to be expensive?
Cost comes from misprediction, and misprediction comes from branches with no learnable pattern. Random sides are close to a coin flip; the others are heavily biased one way.
Question 2 of 3
What does [[unlikely]] change?
The attribute is a hint to the compiler about layout and optimisation. The hardware predictor learns from history regardless.
Question 3 of 3
Why does inlining often help more than the cost of a call suggests?
A call on its own is a handful of cycles. The larger gain is optimisation: the compiler sees the caller and callee together and can simplify both.
In the interview
Say your own answer out loud first, then open the one we would give.
What is a branch misprediction and what does it cost?
The core predicts the outcome of each branch so it can keep fetching and executing ahead of it. When the guess is wrong, the speculative work is discarded and it restarts on the right path, which costs on the order of fifteen to twenty cycles on current cores. Predictable branches cost almost nothing; branches with no pattern can dominate a tight loop.
How would you deal with an unpredictable branch in a hot loop?
First confirm it with a profiler or the branch-miss counters. If both sides are cheap, compute both and select, or index with the condition, which compilers can turn into conditional moves. If the data can be grouped or sorted so that the branch becomes predictable, that is often better still. Then measure again, because branch-free code that does more work can lose.
Why are indirect calls slower than direct ones?
The target is not known at compile time, so the compiler cannot inline it and loses the optimisations that inlining enables. At run time the CPU also has to predict the target. A call site that always goes to the same place predicts well; one that varies does not.