Foundations to FrontierPart X: Frontier engineeringBezhig

F19: Performance engineering on a parallel machine: bounds, VLIW packing, SIMD, and batched tree traversal

10 exercises, each with a complete solution. 7 figures. 1 code listing.

On this page
  1. A. Prerequisites and observable goals
  2. B. The central idea: every cycle count has two ceilings
  3. C. Reading a kernel as a dependency graph
  4. D. VLIW packing: filling the slots, cycle by cycle
  5. E. Independent work: why one chain wastes the machine
  6. F. SIMD: one instruction, many lanes
  7. G. Scratch memory: the data you keep close
  8. H. Hiding latency: software pipelining
  9. I. Case study: batched traversal of a binary search tree
  10. J. Working like a performance engineer
  11. K. The lab
  12. L. A public challenge: Anthropic’s performance take-home
  13. Sources for this section (checked 17 October 2026)
  14. M. Independent practice
  15. N. Complete solutions: F19
  16. O. Retention and readiness

A. Prerequisites and observable goals#

Before

Loops, arrays, and functions in Python (C01–C02); binary trees (C08); instructions and caches (C09); powers of two and log⁡2\log_2. No calculus required.

After

Compute a critical-path bound and a resource bound for any kernel and say which one limits it; pack independent operations into VLIW bundles by list scheduling; vectorize across SIMD lanes; decide what to keep in scratch memory; build and cost a batched, branch-free tree traversal; run a measurement log you could defend in an interview.

Level: Frontier track (evidence lane). It is also the foundation for Anthropic’s public performance take-home, a challenge open to any reader (section L). All work uses this chapter’s own toy machine, which is deliberately different from any company’s interview problem.

B. The central idea: every cycle count has two ceilings#

A parallel machine runs one bundle of instructions per clock cycle. Each bundle has a fixed number of slots per engine: so many scalar ALU operations, so many vector operations, so many loads, and so many stores. Our toy machine allows, per cycle:

Enginealu (scalar)valu (vector, 4 lanes)loadstore
Slots per cycle4211

Every operation takes one cycle, and its result is usable in the next cycle, never the same one. Two things then decide how fast any program can possibly go:

  • The critical path. A chain of operations where each needs the previous result cannot finish in fewer cycles than its length. Adding hardware does not help.
  • The resource bound. If a program needs NeN_e operations on engine ee with ses_e slots, it needs at least ⌈Ne/se⌉\lceil N_e/s_e\rceil cycles. Clever ordering does not help.

The larger of the two is a lower bound: no schedule can beat it. Performance work is a loop of computing that bound, measuring the real count, finding which ceiling you are pressed against, and then changing the program so that ceiling moves. This is the “predict first, simulate second” habit from F01, applied to cycles.

C. Reading a kernel as a dependency graph#

Take y=(a+b)(c+d)+efy=(a+b)(c+d)+ef, with all six inputs loaded from memory and yy stored back. Each box below is one operation, and each arrow means “needs the result of”.

The example as a dependency graph. The longest chain has 5 operations, and the load engine has 6 operations for 1 slot.

The critical path is 5 cycles and the resource bound is ⌈6/1⌉=6\lceil 6/1\rceil=6 cycles, so the lower bound is 6. Yet the best schedule takes 9. Bounds are guarantees, not predictions. Here the loads must occupy cycles 0 through 5, and whatever is loaded last still needs three more steps (multiply, add, store) after it arrives. A tighter bound combines the two effects: time to finish the busiest engine, plus the shortest chain that must follow its last operation. Exercise F19.02 walks through it.

D. VLIW packing: filling the slots, cycle by cycle#

A VLIW (very long instruction word) machine has no hardware that reorders your work at run time. The program itself says which operations run together in each cycle. Deciding that is scheduling, and the standard method is list scheduling:

  1. Give every operation a height: the length of the longest chain from it to the end of the program.
  2. Each cycle, list the ready operations (all inputs finished in earlier cycles).
  3. Place them highest-first into any free slot of their engine. Whatever doesn’t fit waits.

Height-first means the operations on long chains start early, so the tail of the program is not left waiting on them.

The list schedule on the toy machine. The load engine is full for six cycles; the ALU sits mostly empty. That is what “load-bound” looks like.

Utilization is the fraction of an engine’s slots a schedule fills. Here the load engine is busy 6/96/9 of the time and the four ALU slots only 5/365/36. When one engine sits near 100% and the others are nearly idle, that engine is the bottleneck. Removing its work is the only way to go faster.

E. Independent work: why one chain wastes the machine#

A single chain of 6 dependent vector operations takes 6 cycles no matter how many vector slots exist, because each operation waits for the last. With 2 slots, half the vector engine sits idle. Two independent chains, such as two iterations of a loop that don’t depend on each other, interleave perfectly: still 6 cycles, but twice the work and 100% utilization. With 8 chains (4848 operations), the resource bound takes over: ⌈48/2⌉=24\lceil48/2\rceil=24 cycles.

This is the logic behind loop unrolling. Copy the loop body so several independent iterations are in flight at once, and the scheduler always has something ready to run. The lab’s independent_chains function and its utilization report let you watch the switch from latency-bound to resource-bound happen.

F. SIMD: one instruction, many lanes#

A vector instruction applies one operation to every lane of a vector at once. The toy machine has 4 lanes.

Adding 1,000 pairs takes 1,000 scalar additions, which is 250 cycles on 4 ALU slots. As vectors it takes 1000/4=2501000/4=250 vector additions, which is 125 cycles on 2 vector slots. SIMD pays when every lane does the same operation on data arranged side by side in memory (a structure of arrays). Two things break it:

  • Tails. 1,002 elements need 251 vectors; the last is only half full, so you mask it or finish it with scalar operations.
  • Different addresses per lane. A contiguous vector load fetches 4 neighbours in one operation. If each lane needs a value from a different place (a gather), a machine without a gather instruction must issue one scalar load per lane. Now the load engine, not the vector engine, sets the pace. This is exactly what happens in tree traversal.

G. Scratch memory: the data you keep close#

CPUs cache recently used data automatically. Many accelerators instead give you a small scratchpad and make you decide what lives there. That makes it a budget: every word kept resident is a load you never repeat, and every word costs space. Two questions guide every scratchpad decision:

  1. Is this value reused? Loading something once and using it many times from scratch turns repeated loads into free reads.
  2. What does keeping it cost? Space, plus any extra arithmetic needed to pick the right resident value, can outweigh the loads saved. Section I puts numbers on this trade-off.

H. Hiding latency: software pipelining#

Suppose each loop iteration loads a value, does two dependent vector operations, and stores the result: four steps in a chain. Run iterations one at a time and NN iterations cost 4N4N cycles. But iteration 2’s load doesn’t depend on iteration 1’s arithmetic. Start it one cycle later and the iterations overlap like an assembly line:

Software pipelining: once the pipe is full, one iteration finishes every cycle. Each cycle uses 1 load, 2 vector operations, and 1 store, exactly the toy machine’s limits.

After a short start-up, one iteration completes per cycle, so NN iterations take about N+3N+3 cycles instead of 4N4N. The limit is again a resource: one load slot and one store slot allow at most one iteration per cycle. Software pipelining is what unrolling becomes when you plan the overlap deliberately.

I. Case study: batched traversal of a binary search tree#

Now a real kernel. You have a sorted table of keys and a batch of queries: for each query, how many keys are smaller? (This is the heart of decision-tree inference, database indexes, and many lookups in engines.)

Step 1: put the tree in an array. The Eytzinger layout stores a complete binary search tree with the root at index 1 and the children of node ii at 2i2i and 2i+12i+1. There are no pointers, just arithmetic. For the 15 keys 1,2,…,151,2,\ldots,15:

index123456789101112131415
key841226101413579111315

The same tree, drawn and stored. Walking the drawing left to right reads the keys in sorted order.

Step 2: search without branches. Start at i=1i=1. At each level, go right exactly when the query is larger than the node:

i ← 2i+[ q>tree[i] ],i\ \leftarrow\ 2i+[\,q>\text{tree}[i]\,],

where [ ⋅ ][\,\cdot\,] is 1 if true and 0 if false. After DD levels, the path has spelled the answer in binary: rank=i−2D\text{rank}=i-2^D. For q=11q=11: i=1→3→6→13→26i=1\to3\to6\to13\to26, so the rank is 26−16=1026-16=10, and indeed ten keys (11 to 1010) are smaller. There is no if: the comparison result is the arithmetic. That matters because a vector lane cannot take its own branch, but it can add a 0 or a 1.

Step 3: move the whole batch in lockstep. Every query takes exactly DD steps, so process level by level: all queries do level 0, then all do level 1, and so on. Each lane of a vector carries one query. The work per level per vector is one compare and one index update, which is regular, independent, and schedulable.

Step 4: find the bottleneck. The index update is cheap. The expensive part is fetching tree[i]\text{tree}[i], because every lane’s ii is different, so every lane needs its own load. With 256 queries and 10 levels, that is 2,5602{,}560 scalar loads on 1 load slot: at least 2,5602{,}560 cycles, while the vector engine needs only 64×2×10=1,28064\times2\times10=1{,}280 operations (640 cycles). The kernel is load-bound.

For 256 random queries on a 10-level tree, the whole batch touches only 1 node at level 0, 2 at level 1, 4 at level 2, and so on. The top of the tree is shared.

Step 5: keep the top of the tree resident. Level ℓ\ell has only 2ℓ2^\ell nodes, so the top few levels are tiny and every query passes through them. Load them once into scratch memory and stop loading them per query. The price is that each lane must now select its node value from the resident candidates, which costs 2ℓ−12^\ell-1 vector selects per vector at level ℓ\ell. Loads fall linearly while selects grow exponentially, so there is a best number of levels to cache:

The lab’s cost model for 256 queries and a 10-level tree. Caching the top 5 levels balances the two engines and lowers the bound from 2,560 to 1,472 cycles. Caching 6 levels makes the vector engine the bottleneck.

This is the whole method in one picture: find the busiest engine, move work off it, and stop when another engine becomes the bottleneck. The model is simple on purpose. Real kernels add hashing, wrap-arounds, tails, and scheduling limits, but each of those changes the operation counts, not the reasoning.

J. Working like a performance engineer#

  • Bound before you touch code. Count operations per engine and the longest chain. Write the lower bound at the top of your log.
  • Build tools early. A per-engine utilization report takes about 30 lines and tells you which ceiling you are pressed against. Anthropic has written that how candidates invest in tooling is part of what its take-homes measure.
  • One change, one measurement. Keep a log with columns for change, cycles, delta, which bound moved, and whether the tests pass. It becomes your write-up and the story you tell in a design review or an interview.
  • Correctness is non-negotiable. Re-run the full correctness check after every change. A faster wrong answer is not an optimization.

K. The lab#

The lab ships with its starters and tests in the companion code’s frontier_code folder (bezhig.com/book), and needs only Python’s standard library. starters/perf_lab.py has three blanks: critical_path (F19.L1), list_schedule (F19.L2), and search_batch_lockstep (F19.L3). Everything else (graph checking, heights, the schedule verifier, utilization, the Eytzinger builder, and the cost model) is provided for you to read and use.

cd frontier_code
$env:MANUAL_IMPL = "starters"          # PowerShell
python -m unittest tests.test_perf -v
python verify_f19.py                   # the numbers quoted in this chapter

In bash or zsh, set the variable with export MANUAL_IMPL=starters instead.

L. A public challenge: Anthropic’s performance take-home#

Anthropic open-sourced its original performance-engineering take-home, and in January 2026 it told the story of the test. It is a simulated accelerator with VLIW bundles, SIMD vector operations, and a scratchpad you manage by hand, running a batched tree-traversal kernel scored in cycles. The first version, used from early 2024, gave candidates 4 hours and a multicore machine; version 2 cut it to 2 hours without multicore; Anthropic retired version 2 after Claude Opus 4.5 matched the best human 2-hour score. The public repository is therefore a showcase, not practice for the live screen. It invites an email for a solution under 1,487 cycles, warns that new models may move that bar, and accepts a result only if the tests/ folder is unchanged. The live test, as of January 2026, is a set of puzzles on a constrained instruction set, scored by instruction count, with only a validity checker supplied: no debugger, so you build your own tools, and how you invest in them is part of what is measured. Everything in sections B–J transfers directly. The machine, the kernel, and the numbers do not, and working them out is the challenge.

  1. Recon (one session, before studying). Clone the repository, run its submission test, record the starting cycle count, and read the README and the simulator code until you can say what one bundle may contain. Write down three things you don’t understand. Don’t optimize yet; the goal is to make this chapter’s ideas feel necessary.
  2. Foundations. Read C09 on instructions and caches, then T01 on finding the limiting resource, and the roofline section of G06. Then work this chapter and pass all 19 lab tests on your own starter code.
  3. Bound the real kernel. Using the simulator’s slot limits, count operations per engine for the reference kernel and compute its lower bound by hand. Compare it with the measured baseline.
  4. Tooling, as the live test demands. Build a per-engine utilization report for the simulator’s traces before your first optimization. Then the drill that matches the live test as Anthropic described it in January 2026: invent a tiny instruction set of your own (five or six instructions and a few registers), write a tracer that prints machine state after each instruction, and solve three puzzles of your own design for the fewest instructions, using only a checker that says valid or invalid.
  5. Timed attempts. Do one 2-hour attempt first, the time version 2 allowed, and log every change. Expect it to be harder than version 2 was: this repository restores the slowest starting kernel, while version 2 began from a kernel about 8 times faster. Then continue with unlimited time. Re-run the correctness tests after every change.
  6. Validate like a reviewer. None of the public submissions under 1,300 cycles in the repository’s first day were valid: language models had edited the tests. Keep a clean clone, confirm that git diff origin/main tests/ prints nothing, and run python tests/submission_tests.py before you believe any number.
  7. Write it up. Your log becomes a short, honest write-up: what bounded the kernel at each stage, what moved it, and the command that reproduces the final count.

Sources for this section (checked 17 October 2026)#

M. Independent practice#

F foundation C standard course work T transfer S optional stretch L runnable lab or project. Attempt the complete set before opening its solutions.

F19.01FSolution

For the example y=(a+b)(c+d)+efy=(a+b)(c+d)+ef on the toy machine, state the critical path and the resource bound, and name the bottleneck engine.

F19.02CSolution

Explain why no schedule of the example beats 9 cycles, even though the lower bound is 6. Which load should go last, and why?

F19.03CSolution

A loop body is a chain of 6 dependent vector operations. How many cycles do 1, 2, and 8 independent copies take on 2 vector slots? What is the vector utilization for one copy?

F19.04CSolution

Compare adding 1,000 pairs as scalars (4 ALU slots) and as 4-lane vectors (2 vector slots). How many vectors do 1,002 elements need, and what must happen to the last one?

F19.05TSolution

Using the Eytzinger table for keys 1..151..15, trace the branch-free search for q=11q=11 and q=0q=0. Give each final index and rank.

F19.06TSolution

The shared-levels figure shows 1,2,4,…,63,106,146,1721,2,4,\ldots,63,106,146,172 distinct nodes per level for 256 queries. Why exactly 2ℓ2^\ell at the top, and why fewer than 256 even at level 9?

F19.07TSolution

With the lab’s cost model for 256 queries and depth 10, compute loads, vector operations, and the bound with 0, 5, and 6 cached levels. Explain why 6 is worse than 5.

F19.08SSolution

For the software-pipelining example (load, two dependent vector operations, store), how many cycles do 100 iterations take sequentially and pipelined? Which resource limits the pipelined rate?

F19.09LSolution

Complete F19.L1–L3 until all 19 tests in tests.test_perf pass with MANUAL_IMPL=starters. Log the first failing test and your first incorrect step.

F19.10SSolution

Design the measurement log for an optimization project: its columns, what you record before the first change, and which utilization numbers tell you whether to attack loads or vector operations next.

Optional small hints

F19.02: whichever load is last, count the operations that still depend on it. F19.05: [ q>tree[i] ][\,q>\text{tree}[i]\,] is 0 or 1. F19.07: use the formulas in traversal_cost’s docstring.

N. Complete solutions: F19#

Compare the first step where your work differs. A corrected answer is not yet an independent success.

F19.01Show solution

Two ceilings

The longest chain is load →\to s1 →\to m1 →\to y →\to store, so the critical path is 5\boxed{5} cycles. Six loads on one load slot give a resource bound of 6\boxed{6}. The ALU needs ⌈5/4⌉=2\lceil5/4\rceil=2 cycles and the store engine 1. The lower bound is 6 and the load engine is the bottleneck.

Back to F19.01
F19.02Show solution

The last load still has work behind it

The six loads need cycles 0–5, so some load finishes in cycle 5 at the earliest. If the last load is le or lf, three dependent steps still follow: m2, y, and the store in cycles 6, 7, and 8. That is 9 cycles. If lc or ld went last, four steps would follow (s2, m1, y, store) for 10 cycles. So the optimum is 9\boxed{9}, with le/lf loaded last. List scheduling finds this because their chains are shorter (lower height), so they are naturally scheduled last.

Back to F19.02
F19.03Show solution

From latency-bound to resource-bound

One chain: 6\boxed{6} cycles, vector utilization 6/(2×6)=50%6/(2\times6)=\boxed{50\%}. Two chains: still 6\boxed{6} cycles with both slots full (100%). Eight chains: 48 operations on 2 slots give 24\boxed{24} cycles. The critical path (6) no longer matters; the vector engine does.

Back to F19.03
F19.04Show solution

Four lanes, two slots

Scalar: ⌈1000/4⌉=250\lceil1000/4\rceil=\boxed{250} cycles. Vector: 1000/4=2501000/4=250 vectors on 2 slots gives 125\boxed{125} cycles, twice as fast. For 1,002 elements, ⌈1002/4⌉=251\lceil1002/4\rceil=\boxed{251} vectors. The last holds only 2 real elements: mask the unused lanes, or pad the arrays so the extra lanes compute harmless values you ignore, or finish those two with scalar operations.

Back to F19.04
F19.05Show solution

The path spells the rank

q=11q=11: 11>811>8 gives i=3i=3; 11>1211>12 is false, so i=6i=6; 11>1011>10 gives i=13i=13; 11>1111>11 is false, so i=26i=26. Rank =26−16=10=26-16=\boxed{10}. For q=0q=0: every comparison is false, so i=2,4,8,16i=2,4,8,16 and the rank is 16−16=016-16=\boxed{0}. No key is smaller than 0, which checks out.

Back to F19.05
F19.06Show solution

Branches split the batch, but only so far

At level ℓ\ell there are only 2ℓ2^\ell nodes, so no batch can touch more than that; random queries spread across all of them near the top, giving exactly 1,2,4,…1,2,4,\ldots. Deeper down there are more nodes than queries could ever fill evenly. Random queries also cluster: several land in the same subtree and share a node. So even at level 9 (512 nodes), the 256 queries touch only 172 distinct nodes.

Back to F19.06
F19.07Show solution

Moving work off the load engine, then too far

With 64 vectors: no caching gives 2,560 loads and 64×2×10=1,28064\times2\times10=1{,}280 vector operations, so the bound is max⁡(2560, 640)=2,560\max(2560,\,640)=\boxed{2{,}560} cycles. Caching 5 levels: loads =256×5+⌈31/4⌉=1,288=256\times5+\lceil31/4\rceil=1{,}288; selects =64×(0+1+3+7+15)=1,664=64\times(0+1+3+7+15)=1{,}664; vector operations =1,280+1,664=2,944=1{,}280+1{,}664=2{,}944 (1,472 cycles). The bound is max⁡(1288,1472)=1,472\max(1288,1472)=\boxed{1{,}472}. Caching 6 levels adds 64×3164\times31 more selects, making 4,928 vector operations (2,464 cycles), while loads drop only to 1,040. The bound jumps to 2,464\boxed{2{,}464}: the vector engine has become the bottleneck.

Back to F19.07
F19.08Show solution

One iteration per cycle, once the pipe is full

Sequentially each iteration’s 4 steps take 4 cycles: 400\boxed{400} cycles. Pipelined, iteration kk starts in cycle kk and finishes 3 cycles later, so 100 iterations end after about 103\boxed{103} cycles. Every cycle in the steady state uses 1 load, 2 vector operations, and 1 store. The single load slot and the single store slot cap the rate at one iteration per cycle; the two vector slots are also exactly full.

Back to F19.08
F19.09Show solution

The reference implementation

The reference is solutions/perf_lab.py in the companion code’s frontier_code folder. Compare only after all tests pass or after a full, honest attempt. Common first failures: counting a node’s own depth as 0 instead of 1 (the chain test fails); letting an operation use a result from the same cycle (verify_schedule rejects it); and forgetting to subtract 2D2^D at the end of the traversal.

Back to F19.09
F19.10Show solution

A log you can defend

Columns: date, change (one sentence), cycles, change from previous, lower bound, bottleneck engine and its utilization, correctness tests passed (yes/no), and notes. Before the first change, record the baseline cycles, the hand-computed lower bound, and the utilization of every engine. If load utilization is near 100% and vector utilization is low, remove or reuse loads. If the vector engine is saturated, remove vector work: cheaper selects, fewer compares, or strength-reduced arithmetic. If nothing is saturated, the critical path is the limit, so create more independent work by unrolling or pipelining.

Back to F19.10

O. Retention and readiness#

Without notes, compute the two bounds for a kernel you invent with 8 loads and a 4-step chain, then explain aloud why caching tree levels eventually stops paying. Revisit F19.02 and F19.07 in a week. Log misses under the error families in LM (Part I); “counting” slips go under bookkeeping.