How CPUs predict conditional and indirect branches, what a misprediction costs, the classic sorted-versus-unsorted example, branchless techniques, how the .NET JIT uses conditional moves and Dynamic PGO, and how to measure mispredictions.
A modern CPU doesn't wait to find out which way an if goes: its branch predictor guesses from recent history and the pipeline runs ahead speculatively. When data is sorted, a test like value >= 128 is false for a long run, then true for a long run, so the guess is almost always right. On random data it's right about half the time, and each misprediction throws away 15–20 cycles of work. Fixes: make data predictable (sort or partition), go branchless (conditional moves, masks, Math.Max, SIMD compares), or move rare cases out of the hot path. In .NET, check the disassembly because the JIT sometimes emits a conditional move already, and Dynamic PGO helps predict virtual calls.
foreach (var v in data)
{
if (v >= 128) sum += v;
}
if into a conditional move (if-conversion was added in .NET 8), which removes the branch entirely and makes both versions equally fast. That's why you verify with a benchmark and the disassembly.| Technique | How | When |
|---|---|---|
| Make data predictable | Sort, partition or group by type before the hot loop | Data processed many times or partitioning is cheap |
| Branchless arithmetic | sum += v & -(v >= 128 ? 1 : 0) style masks, Math.Max, Math.Clamp |
Small, cheap bodies where computing both sides is fine |
| SIMD | Vector128.GreaterThanOrEqual then mask and add |
Large arrays of primitives |
| Hot/cold splitting | Move rare paths (validation failures, throws) into separate non-inlined methods | Keeps hot code compact and predictable |
| Lookup tables | Replace a chain of conditions on a small input range with an array index | Parsers, classifiers |
| Avoid exceptions for control flow | Use TryParse patterns |
Exceptions are far more expensive than mispredictions |
Virtual and interface calls jump to an address loaded from memory. CPUs predict those too, and Dynamic PGO (default since .NET 8) records the most common target and emits a guarded devirtualisation: "if the object is Circle, call Circle.Area directly (and maybe inline it), else do the virtual call". Mixed-type collections in hot loops defeat both the predictor and PGO; grouping by type helps.
A log-ingestion service filtered events with several conditions on fields that were effectively random per event. Hardware counters showed about 12% of branches mispredicted in the filter loop. Restructuring the filter to compute a bitmask with SIMD comparisons over a batch of events, instead of nested ifs per event, doubled throughput of that stage.
Checks deep performance knowledge and, just as importantly, measurement discipline.
Green flags: explains speculation and the misprediction cost, the sorted-data effect, branchless trade-offs, mentions JIT conditional moves and Dynamic PGO, proposes benchmarking and hardware counters.
Red flags: recommends branchless code everywhere; claims sorting always helps; no idea how to measure; confuses branch prediction with caching.
cycle: 1 2 3 4 5 6 7 8 ...
branch: F D E <- outcome known here
next?: F D E <- which instruction? must guess at cycle 2
next?: F D E
With 15–20 stages and 4+ instructions per cycle, a core may have 100+ instructions in flight. Without prediction, every branch (about 1 in 5 instructions) would stall the whole machine.
if (x > 0) ... if (x > 10)) are learned.time ≈ work + (branches × misprediction rate × ~15-20 cycles)
10 million iterations, 1 branch each:
1% mispredicted -> 100,000 × 18 cycles ≈ 1.8M cycles (~0.6 ms at 3 GHz)
50% mispredicted -> 5,000,000 × 18 cycles ≈ 90M cycles (~30 ms)
| Feature | Effect |
|---|---|
| If-conversion (.NET 8+) | Simple if/ternary assignments may become cmov (x64) or csel (Arm64) |
| Block layout | Places likely code on the fall-through path; uses PGO data when available |
| Throw helpers | throw in a separate method keeps the hot method small; the BCL uses ThrowHelper classes for this |
| Dynamic PGO | Guarded devirtualisation for hot virtual and interface calls, and better block layout |
Math.Min, Math.Max |
Often compile to branchless instructions |
There's no [Likely] attribute in C#; structure code so the common case falls through, and let PGO do the rest.
[HardwareCounters(HardwareCounter.BranchMispredictions, HardwareCounter.BranchInstructions)] on Windows (needs admin for ETW).perf stat -e branches,branch-misses dotnet MyApp.dll.Speculative execution after a predicted branch is the basis of Spectre variant 1 (bounds-check bypass). Runtimes and operating systems include mitigations; application code rarely needs to do anything, but it's why the topic comes up in security-minded interviews.
DOTNET_JitDisasm=Branchy and check whether the JIT used a conditional move.perf stat or BenchmarkDotNet hardware counters.namespace BranchPrediction;
using System.Runtime.Intrinsics;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
public class BranchBenchmarks
{
private int[] _unsorted = [];
private int[] _sorted = [];
[GlobalSetup]
public void Setup()
{
var random = new Random(42);
_unsorted = Enumerable.Range(0, 1 << 20).Select(_ => random.Next(256)).ToArray();
_sorted = _unsorted.Order().ToArray();
}
[Benchmark(Baseline = true)] public long UnsortedBranchy() => Branchy(_unsorted);
[Benchmark] public long SortedBranchy() => Branchy(_sorted);
[Benchmark] public long UnsortedBranchless() => Branchless(_unsorted);
[Benchmark] public long UnsortedSimd() => Simd(_unsorted);
private static long Branchy(int[] data)
{
long sum = 0, count = 0;
foreach (var v in data)
{
if (v >= 128)
{
sum += v;
count++; // a slightly bigger body makes if-conversion less likely
}
}
return sum + count;
}
private static long Branchless(int[] data)
{
long sum = 0, count = 0;
foreach (var v in data)
{
int mask = -(int)((uint)(v - 128) >> 31 ^ 1); // all ones if v >= 128, else 0 (for 0..255)
sum += v & mask;
count += mask & 1;
}
return sum + count;
}
private static long Simd(int[] data)
{
ReadOnlySpan<int> span = data;
var threshold = Vector128.Create(128);
var sums = Vector128<int>.Zero;
var counts = Vector128<int>.Zero;
int i = 0;
for (; i <= span.Length - Vector128<int>.Count; i += Vector128<int>.Count)
{
var v = Vector128.Create(span.Slice(i, Vector128<int>.Count));
var mask = Vector128.GreaterThanOrEqual(v, threshold); // lanes are all ones or zero
sums += v & mask;
counts -= mask; // subtracting -1 adds 1
}
long sum = 0, count = 0;
for (var lane = 0; lane < Vector128<int>.Count; lane++)
{
sum += sums[lane];
count += counts[lane];
}
for (; i < span.Length; i++)
{
if (span[i] >= 128) { sum += span[i]; count++; }
}
return sum + count;
}
}
public static class Program
{
public static void Main() => BenchmarkRunner.Run<BranchBenchmarks>();
}
The per-lane int accumulators are safe here because each lane sees at most 2²⁰ / 4 values of at most 255.
Mistake: Assuming the sorted-array trick always reproduces. Why it is wrong: the JIT may if-convert a simple body into a conditional move, so the unsorted version is no longer slow and the demo proves nothing. Instead: check the disassembly and measure on your runtime and hardware.
Mistake: Making everything branchless. Why it is wrong: conditional moves add data dependencies and do work on both paths, so for predictable branches or expensive paths they are slower. Instead: go branchless only where the condition is unpredictable and both sides are cheap, and benchmark it.
Mistake: Sorting data just to help the predictor. Why it is wrong: sorting costs O(n log n); for a single pass it usually costs more than the mispredictions it saves. Instead: sort or partition only when the data is reused many times or is already nearly sorted.
Mistake: Using exceptions for expected failures in hot paths.
Why it is wrong: throwing and catching costs microseconds, thousands of times more than a misprediction.
Instead: use TryParse/TryGetValue patterns and keep throws for exceptional cases, ideally in throw helpers.
Mistake: Over-interpreting hardware counters. Why it is wrong: a high branch-miss percentage in a method that takes 1% of request time changes nothing users notice, and counters measured on a laptop or in a noisy VM may not match production. Misses can also be a symptom of a cache or data-layout problem rather than the cause. Instead: correlate counters with wall-clock time and the profiler's hot path, use production-like data, and confirm the improvement end to end (throughput, p95 latency).
Mistake: Mixing many concrete types behind an interface in a hot loop. Why it is wrong: indirect-call prediction and PGO's guarded devirtualisation work best when one or two types dominate. Instead: group items by type, or use a struct-based or switch-based design for the hottest paths.
Branch prediction is a good test of whether someone measures or guesses. The senior answer explains the mechanism and cost model, then immediately says "but check the JIT output and the counters", because modern JITs and CPUs often already handle the textbook case.
| Situation | Typical .NET example | Best move |
|---|---|---|
| Unpredictable, cheap condition | Filtering numeric arrays, parsers | Branchless or SIMD masks |
| Predictable condition | Validation that almost always passes | Keep the branch; move the rare path to a throw helper |
| Expensive rare path | Error handling, logging | Keep the branch, hot/cold split |
| Many types behind an interface | Pipeline of IHandler in ASP.NET Core middleware or rule engines |
Group by type, let Dynamic PGO devirtualise, or use a switch over a small set |
| Exceptions as flow control | int.Parse in a try/catch per row in a CSV import |
TryParse |
| Memory-bound loop | Iterating an EF Core result of reference types | Fix data layout first; branches are secondary |
In real services the wins are usually structural: keeping hot paths small, not using exceptions for control flow, letting Dynamic PGO devirtualise, and using vectorised BCL APIs for bulk filtering. Trade-off: branchless and SIMD code is harder to read and review, so it belongs only in proven hot paths with a benchmark in the repo. Security: speculation after a predicted branch is the basis of Spectre variant 1; the runtime and OS handle mitigations, but it explains why this topic appears in security interviews.
SIMD (single instruction, multiple data) lets one CPU instruction operate on several values at once, held in a vector register: a 128-bit register holds four ints, a 256-bit one holds eight. x64 offers SSE, AVX2 and AVX-512; Arm64 offers NEON. .NET exposes this through portable types (Vector128<T>, Vector256<T>, Vector512<T> and the size-agnostic Vector<T>), plus platform-specific intrinsics. First, use what's already vectorised: Span methods like IndexOf and SequenceEqual, and TensorPrimitives. Hand-write SIMD only for a measured hot loop over contiguous primitive data, with a scalar tail and a fallback when IsHardwareAccelerated is false.
Pipelining splits instruction processing into stages and overlaps them, like an assembly line: while one instruction executes, the next is decoding and the one after is being fetched. Modern cores are also superscalar (they issue several instructions per cycle to multiple execution units) and out-of-order (they run any instruction whose inputs are ready, using register renaming and a reorder buffer, then retire results in program order). The pipeline stalls when an instruction waits for a previous result (a data dependency), when a branch is mispredicted and work must be thrown away, or when data isn't in cache. In C#, a loop where each iteration depends on the last, like a single running sum, can't use that parallelism; multiple accumulators or SIMD can.
How to tell it (STAR)
**Situation:** At [company], our log ingestion service couldn't keep up at peak; CPU was at [95%] and the queue backlog grew every afternoon. **Task:** I needed to raise throughput without adding nodes. **Action:** Profiling pointed at the event filter, and hardware counters showed about [12%] branch mispredictions because the conditions depended on effectively random fields. I restructured batches into a struct-of-arrays layout and evaluated the filter with SIMD comparisons and masks, verifying with BenchmarkDotNet and the disassembly. **Result:** Filter throughput roughly doubled, the backlog disappeared, and we avoided [N] extra nodes.
Last updated 23 Oct 2026.