Note
A branch predictor that segfaults, and what replacing it cost
I wrote a branch predictor for a course contest built on the CBP-2 infrastructure. It was a local-history predictor combined with a bimodal one, and I published a number for it: 5.020 MPKI.
That number cannot have come from the code in the repository. The code segfaults.
What was wrong
Two things, and the first is not a tuning problem.
#define BHR_num 22
#define PT_BITS 22
unsigned int BHRTable[BHR_num]; // 22 elements
unsigned char PTable[1 << PT_BITS]; // 4 MiB
// in predict(), and again in update():
BHRTable[history ^ (b.address & ((1 << PT_BITS) - 1))]
BHRTable holds 22 elements and is indexed by a value that ranges up to
4,194,303. The intent is visible: it should have been a table of per-branch
local history registers, sized 1 << n, whose contents index PTable. What
it does instead is read, and in update() write, past the end of the array.
UndefinedBehaviorSanitizer on the unmodified file:
my_predictor.h:51:5: runtime error: index 296892 out of bounds for type 'unsigned int[22]'
my_predictor.h:83:4: runtime error: index 296892 out of bounds for type 'unsigned int[22]'
my_predictor.h:84:4: runtime error: index 296892 out of bounds for type 'unsigned int[22]'
my_predictor.h:85:4: runtime error: index 296892 out of bounds for type 'unsigned int[22]'
AddressSanitizer: SEGV ... in my_predictor::predict(branch_info&)
Line 51 is the read in predict(). Lines 83 to 85 are the read-modify-writes in
update().
Built at -O3 without sanitizers and run over all 20 traces, three times each:
60 runs, 60 exits with status 139. No MPKI line was printed once. Whatever
produced 5.020 was not this file.
The second problem is arithmetic. The contest budget, stated in the
infrastructure’s own header comment, is 32 KB. PTable alone is 4 MiB.
The third is that the selection between the two components was not a chooser:
if (PTable[u.index] == 3 || PTable[u.index] == 4) {
u.direction_prediction (bimodal_prediction);
} else {
u.direction_prediction (local_prediction);
}
That tests whether the local counter is near the middle of its range and falls back to bimodal if it is. It has no state of its own and learns nothing about which component is right for a given branch.
The rebuild
Same design intent, corrected structure, inside the budget.
lht— local history table, 2^12 entries of 15 bits, indexed by PC. Each entry is one branch’s own outcome history.lpt— local pattern table, 2^15 3-bit saturating counters, indexed by the local history XORed with PC bits.bim— bimodal table, 2^15 2-bit counters, indexed by PC.chooser— 2^14 2-bit counters indexed byPC ^ GHR. Trained only when the two components disagree: incremented when local was right, decremented when bimodal was.ghr— a 16-bit global history register.
lht 4096 x 15 = 61,440 bits
lpt 32768 x 3 = 98,304 bits
bim 32768 x 2 = 65,536 bits
chooser 16384 x 2 = 32,768 bits
ghr = 16 bits
-----------
total = 258,064 bits = 32,258 bytes
budget = 262,144 bits = 32,768 bytes
Counters are stored one per unsigned char for code simplicity, so the process
occupies far more than 32 KB. The budget is counted in the bits the design
needs, which is the number the contest constrains. To keep that honest the file
carries a compile-time guard rather than a claim in a comment:
static_assert (TOTAL_BITS <= 262144u,
"predictor state exceeds the CBP-2 budget of 32 KB = 262,144 bits");
Raising LPT_BITS to 22 — the original’s table size — fails the build.
Results
Baseline is the contest’s own sample predictor, a 32,768-entry gshare with a
history length of 15, reconstructed from the header comment that describes it.
All 20 traces, g++ -O3, Apple clang 16, arm64.
| trace | gshare | hybrid | delta |
|---|---|---|---|
| 164.gzip | 12.473 | 10.893 | −1.580 |
| 175.vpr | 13.415 | 12.737 | −0.678 |
| 176.gcc | 11.254 | 11.460 | +0.206 |
| 181.mcf | 15.837 | 16.015 | +0.178 |
| 186.crafty | 5.837 | 6.543 | +0.706 |
| 197.parser | 10.008 | 10.738 | +0.730 |
| 201.compress | 7.831 | 7.475 | −0.356 |
| 202.jess | 1.562 | 0.913 | −0.649 |
| 205.raytrace | 2.756 | 0.950 | −1.806 |
| 209.db | 3.909 | 3.516 | −0.393 |
| 213.javac | 2.267 | 1.886 | −0.381 |
| 222.mpegaudio | 2.188 | 1.873 | −0.315 |
| 227.mtrt | 2.657 | 1.142 | −1.515 |
| 228.jack | 3.033 | 1.513 | −1.520 |
| 252.eon | 1.807 | 0.838 | −0.969 |
| 253.perlbmk | 2.554 | 1.862 | −0.692 |
| 254.gap | 3.926 | 2.718 | −1.208 |
| 255.vortex | 1.222 | 0.385 | −0.837 |
| 256.bzip2 | 0.094 | 0.060 | −0.034 |
| 300.twolf | 21.489 | 19.873 | −1.616 |
| mean | 6.3060 | 5.6695 | −0.6364 |
−10.1% on the arithmetic mean, winning on 16 traces of 20. gshare uses a quarter of the budget to do it; the hybrid uses 98.4%.
Where it loses, and why that is the interesting part
It loses on gcc, mcf, crafty and parser. Those are the traces whose
branches correlate with the global path rather than with their own history, and
a local-plus-bimodal predictor has no structure that captures a global pattern.
The chooser can route around a branch the local component handles badly, but it
can only choose between two components that are both blind in the same way.
This is a structural limit, not a tuning deficit, and it is the argument for TAGE-style designs: geometric history lengths exist precisely so that one predictor can hold both the short local correlation and the long global one.
Ablations
Five configurations, fixed before any of them ran, each measured on all 20 traces.
| cfg | bits | mean MPKI | note |
|---|---|---|---|
| A | 122,896 | 6.4360 | half the budget — the only config that loses to gshare |
| B | 237,584 | 5.7468 | |
| C | 258,064 | 5.6695 | shipped |
| D | 237,584 | 5.6725 | LPT index concatenated instead of XORed |
| E | 237,584 | 6.2511 | chooser indexed by PC only |
The single largest effect is E against B: indexing the chooser by PC alone
rather than PC ^ GHR costs 0.50 MPKI. The component the original design
was missing entirely turns out to be worth more than any of the table sizings.
D against B says XOR-folding the local history with the PC and concatenating them are worth the same to within 0.07 MPKI at equal size. C against D is 0.003, which is a tie; C was taken for its slightly larger tables.
What this measurement does not establish
- The five configurations were chosen before measuring, but the winner was selected on the same 20 traces it is reported on. There is no held-out set.
- The gshare baseline is reconstructed from the description in the infrastructure’s header comment, not from the contest’s original sample file, which is not in the repository.
- One run per trace for the reported numbers. The simulation is deterministic, so repeated runs agree, but nothing here measures run-to-run variance in anything else.
- MPKI is mispredictions per 1000 instructions over a fixed 100M-instruction trace. It is not a cycle count, and nothing here models the pipeline cost of a misprediction or the latency of the predictor itself.
The part worth keeping
The original number was not merely wrong. It was unreproducible, and one command would have shown that:
g++ -fsanitize=address,undefined -g -O1 -o predict predict.cc trace.cc
A result from a program with undefined behaviour is not a weak result. It is not a result. The cost of finding that out was a sanitizer flag; the cost of not finding out was publishing a number for two years.