Key Takeaways (re-measured)

  • False sharing is real and larger than this article originally claimed: ~9-24x from padding alone (~24x on the M5, ~9x on the VPS), measured correctly (separate goroutines writing separate fields - not the same field, which was the original benchmark's bug)
  • The "RAM is ~60x slower than L1" story is real, and gets worse the more dependent the access pattern is: ~12-14x for independent random indices, ~72-84x for a dependent access chain (pointer chasing) where the CPU can't prefetch ahead
  • Sorting data to help branch prediction measured no benefit for a side-effect-free counter - the compiler turned that "branch" into a branchless conditional-select on its own, so there was no misprediction to fix. Add a side effect (writing matches to an output slice) and the branch survives compilation - there sorting was a real ~5.3x/~4.8x win. Check the generated assembly before assuming either way
  • That win still doesn't justify sorting in order to get it: the saved ~2.1ms is about 10x cheaper than the ~21.7ms sorting itself costs. And a hand-rolled branchless filter beats sorting outright on the clean machine (VPS) - faster than even the presorted branching version, no sort required
  • AoS vs SoA and a Robin Hood hash table both held up as real, directionally correct optimizations - just at 1.1-2.7x, not the 3-7x originally claimed
  • Apple Silicon's cache line is formally 128 bytes, not 64 - but 64-byte padding still fully eliminated false sharing in this benchmark. See the dedicated section below before assuming that generalizes

Table of Contents

The Numbers That Matter

L1 Cache:    4 cycles     (~1ns)      32KB
L2 Cache:    12 cycles    (~3ns)      256KB
L3 Cache:    40 cycles    (~10ns)     8MB
RAM:         200+ cycles  (~60ns)     32GB

Cache line size: 64 bytes on x86_64. 128 bytes on Apple Silicon - see below.

These are the textbook latency figures - not something this article measured directly, since they describe silicon, not Go code. What is measured below is what these numbers translate to in practice: the "RAM is ~60x slower than L1" gap is real, but whether your code actually pays it depends heavily on the access pattern, not just on whether a cache miss occurs at all.

False Sharing: The Silent Killer

False sharing occurs when multiple CPU cores modify different variables that happen to share the same cache line. This forces cache line invalidation across cores, causing significant performance degradation.

The problem is subtle: your variables might be logically independent, but if they happen to land in the same fixed-size cache-line block, updating one invalidates the cache for all others on that line. Two addresses within 64 bytes of each other aren't automatically in the same line - cache lines tile memory in fixed blocks starting from address 0, so two addresses can be a few bytes apart yet straddle a line boundary into different blocks; what actually matters is which 64-byte-aligned block each one falls into, not raw proximity. More on exactly how that tiling works in the section on cache-line size below.

Detection requires careful benchmarking with concurrent access patterns. The performance drop isn't visible in single-threaded tests, only under parallel load.

// BROKEN: 4 counters on one cache line - false sharing under
// concurrent access from different goroutines
type CountersUnpadded struct {
    requests uint64
    errors   uint64
    latency  uint64
    timeouts uint64
}

// FIXED: each counter on its own cache line
type CountersPadded struct {
    requests uint64
    _        [56]byte // pads to 64 bytes
    errors   uint64
    _        [56]byte
    latency  uint64
    _        [56]byte
    timeouts uint64
    _        [56]byte
}

Measured - 4 goroutines, each incrementing its own field via atomic.AddUint64, on a 10-core Apple M5 and a 2-core x86 VPS (go test -bench=. -benchmem -count=3, full code and setup on GitHub Gist):

                    M5 (10 cores)      VPS (2 cores)
unpadded            10.2 ns/op          11.6 ns/op
padded (64B)         0.43 ns/op          1.26 ns/op

Padding is ~24x faster on M5, ~9x faster on the VPS

Worth being precise about the setup, since it's easy to benchmark the wrong thing here: false sharing specifically means different goroutines writing to different fields that happen to share a cache line - goroutines contending on the same field is just ordinary atomic contention and would show a padding "fix" doing nothing useful. Measured correctly, the effect turned out considerably bigger than this article's original claim (6.4x) - a reminder that "trust cache-friendly code will help" cuts both ways: sometimes the unverified number undersells the effect.

Measure Cache Misses (Linux)

# Profile cache misses
perf stat -e cache-misses,cache-references ./myapp

# Detailed cache analysis
perf record -e cache-misses ./myapp
perf report

# Go benchmark with perf
go test -bench=. -benchtime=10s &
pid=$!
perf stat -p $pid -e L1-dcache-load-misses,L1-dcache-loads

How Big Is a Cache Line, Really?

The padding above assumes a 64-byte cache line, which holds on x86-64 - but not universally. When this article went to Hacker News, commenters pointed out real exceptions: Apple's M-series chips are documented at 128 bytes, and other architectures (POWER, s390x) use different sizes again. One commenter asked a sharper question nobody in the thread answered: Apple's own documentation lists 64 bytes for L1 and 128 for L2 - so which one governs cache-coherency traffic between cores?

That's answerable by measurement, not by reading the spec sheet, so the false-sharing benchmark above was run a second way - padding each field 128 bytes apart instead of 64 - on the same M5:

CountersPadded    (64B apart):   0.43 ns/op
CountersPadded128 (128B apart):  0.42 ns/op

No measurable difference. On this chip, on this access pattern, 64-byte padding already eliminates false sharing completely - going to 128 bytes adds nothing. A reasonable explanation is that the M5's inter-core coherency protocol operates on 64-byte lines even though L2 prefetching or some other subsystem is specified at 128 - but that's a hypothesis this benchmark doesn't prove. Confirming which subsystem the 128-byte figure actually describes needs hardware performance counters, not wall-clock timing; a runtime measurement like this one can show that the effect isn't there, not explain the mechanism underneath.

One thing worth ruling out before trusting that comparison at all: whether the struct's own base address happened to land on a cache-line boundary. Checked directly - unsafe.Pointer address mod 64/128, forcing each allocation onto the heap by storing the pointer in a package-level slice rather than runtime.KeepAlive (which doesn't reliably prevent stack allocation the way an actual escaping reference does) - and it turns out Go's size-class allocator does align these: all 40 of 40 CountersPadded (256 bytes) landed 64-byte aligned, all 40 of 40 CountersPadded128 (512 bytes) landed 128-byte aligned. An earlier pass of this check reported zero alignment out of twenty and was almost certainly measuring a stack-allocated copy instead of the heap object this benchmark actually uses - concurrent access from other goroutines is what forces these particular structs to escape in the real benchmark, which a standalone alignment check doesn't automatically reproduce unless it goes out of its way to force the same escape. Either way, the offset argument from the previous paragraph is what actually matters and holds regardless of base alignment - the heap alignment observed here is a bonus guarantee for this specific case, not something to rely on for smaller structs, stack-allocated values, or fields embedded inside a larger struct, where the base address is set by something else's layout instead of the allocator's size class.

What this result does not license: extrapolating to POWER, s390x, or any chip that wasn't tested. The takeaway that generalizes is narrower than "64 bytes is enough" - it's don't hardcode a padding size without checking the target architecture. Go doesn't expose the line size as a public numeric constant you can read - the standard library's own copy lives in the unexported internal/cpu package. The practical fix for portable code is golang.org/x/sys/cpu.CacheLinePad: embed it as a field and it pads to the right size for the build's GOARCH internally, without you having to hardcode or look up the number yourself (64 on x86, 128 on arm64 and ppc64, 256 on s390x, per its own source). That's a real cost worth knowing about: 128 bytes is picked to be safe across an entire architecture family, and this article's own M5 measurement above found 64 already fully eliminates false sharing on this specific chip - for four uint64 counters the difference is noise, but pad every element of a large sharded array the same way and the portable choice is paying for twice the memory this particular machine needed.

Data-Oriented Design: Array of Structs vs Struct of Arrays

// Array of Structs (AoS) - Cache UNFRIENDLY
type Entity struct {
    ID       uint64    // 8 bytes
    X, Y, Z  float64   // 24 bytes
    Velocity float64   // 8 bytes
    Health   int       // 8 bytes
    Type     string    // 16 bytes
    // Total: 64 bytes per entity
}

type WorldAoS struct {
    Entities []Entity // Each entity in different cache lines
}

func (w *WorldAoS) UpdatePositions(dt float64) {
    for i := range w.Entities {
        // Loads 64 bytes but only uses 16 bytes (X, Velocity)
        // Wastes 75% of the cache line
        w.Entities[i].X += w.Entities[i].Velocity * dt
    }
}
// Struct of Arrays (SoA) - only partly cache-friendly
type WorldSoA struct {
    IDs        []uint64
    Positions  [][3]float64 // X,Y,Z together - 24 bytes/entry
    Velocities []float64
    Healths    []int
    Types      []string
}

func (w *WorldSoA) UpdatePositions(dt float64) {
    // Still loads Y and Z it never touches - Positions[i] is 24 bytes,
    // not 8. 64/24 = ~2.7 positions per cache line, not 8.
    for i := range w.Positions {
        w.Positions[i][0] += w.Velocities[i] * dt
    }
}

WorldSoA above is only half-separated: it moved position out of the entity struct, but kept X, Y, and Z bundled together in a [3]float64, so UpdatePositions still loads 24 bytes per entry to use 8. That's not really struct-of-arrays for this access pattern - it's AoS with a smaller struct. Real SoA for this specific loop pulls X into its own slice:

// Struct of Arrays, fully separated - X gets its own slice
type WorldSoAFull struct {
    IDs        []uint64
    Xs, Ys, Zs []float64 // each contiguous on its own
    Velocities []float64
    Healths    []int
    Types      []string
}

func (w *WorldSoAFull) UpdatePositions(dt float64) {
    // Loads exactly the 16 bytes/entry this loop uses (X, Velocity) -
    // Ys and Zs exist for other code but never enter this cache line.
    for i := range w.Xs {
        w.Xs[i] += w.Velocities[i] * dt
    }
}

Measured - 100,000 entities, timing just the position update, all three variants (go test -bench=. -count=5):

              M5 (10 cores)      VPS (2 cores)
AoS            ~0.74 ns/entity    ~1.39 ns/entity
SoA (partial)  ~0.71 ns/entity    ~1.28 ns/entity  (noisy: ±12% CV on M5)
SoA_full       ~0.60 ns/entity    ~1.20 ns/entity  (clean: <2% CV on M5)

AoS/SoA_full: ~1.22x on M5, ~1.16x on VPS.

Real SoA measures cleaner than the partial version (its run-to-run variance drops from ~12% to under 2% on the M5) and edges out a slightly better ratio - but "slightly" is the honest word. ~1.2x is still nowhere near the originally claimed 7x, and finishing the separation didn't unlock a dramatically bigger win, just a more trustworthy small one. At 100,000 entities the whole working set is a few MB either way - well inside L2/L3 on both machines - so there isn't much of a hierarchy gap left for the layout to exploit. A working set that overflows the CPU's last-level cache would be the fairer test of how big this can get; that's not what's measured here.

Random Access: Independent vs. Dependent

// Linear access
func SumLinear(data []int) int {
    sum := 0
    for i := range data {
        sum += data[i]
    }
    return sum
}

// Random access via a shuffled index list
func SumRandom(data []int, indices []int) int {
    sum := 0
    for _, idx := range indices {
        sum += data[idx]
    }
    return sum
}

Measured - 32M ints (256MB, well past any CPU's last-level cache), go test -bench=. -count=3:

              M5 (10 cores)      VPS (2 cores)
linear         7.7 ms/op           22.8 ms/op
random        99.0 ms/op          268.0 ms/op

Random is ~12.4x slower on M5, ~14.3x slower on the VPS

That's the corrected number - an earlier pass of this benchmark discarded SumLinear/SumRandom's return value instead of assigning it anywhere, and measured no difference between linear and random. The result was unused, so the measurement was unreliable; the fix is the standard one, assigning every result to a package-level variable so the computed value can't be treated as safely discardable. That restored a real, substantial gap between the two. Independent random access is genuinely expensive, just less catastrophically than the dependent case below.

One thing this comparison doesn't isolate cleanly: SumRandom also reads indices itself - another 256MB array, read linearly - which SumLinear never touches at all. So the ~12-14x isn't purely "random vs. sequential access to the same data"; SumRandom is reading roughly twice the total memory traffic (256MB of data scattered plus 256MB of indices sequential) that SumLinear is. That extra linear stream is cheap compared to the scattered one, so it isn't likely to be most of the gap - but it's a second variable this benchmark doesn't control for, on top of the access pattern itself.

There's also a second cost riding along with every cache miss here that this article hasn't named yet: address translation. 256MB of ints at the OS's usual 4KB page size is 65,536 distinct pages - far more than any CPU's TLB (translation lookaside buffer, the cache that maps virtual to physical addresses) can hold entries for at once. A scattered access into a page the TLB doesn't currently have cached needs a page-table walk on top of whatever the data cache miss already costs - so both this random-access benchmark and the dependent pointer-chasing one below are paying TLB-miss cost layered on top of cache-miss cost, not cache misses in isolation. Nothing here separately measures how much of either multiplier is TLB versus data cache; it's flagged as a real, uncounted piece of the total rather than folded into the cache-miss story as if the two were the same thing.

SumRandom's iterations still have no dependency on each other: the CPU knows every index it needs before it starts (they're sitting in a sequential array), so an out-of-order core can have multiple loads to scattered addresses in flight at once - memory-level parallelism hides part of the latency, which is consistent with random landing at ~12-14x rather than the ~72-84x exceeds-LLC/L1 ratio measured below for a genuinely dependent access chain. The moment each access depends on the result of the previous one - pointer chasing, e.g. following a linked structure - the CPU can't prefetch or reorder around it at all, because it doesn't know the next address until the current load actually returns:

// A single-cycle permutation of [0,n): following it touches every
// index exactly once, and each step depends on the value read at the
// previous one - unlike SumRandom's independent indices, the CPU
// can't know next[idx] until it has already loaded next[idx] for the
// current idx. No memory-level parallelism to hide behind.
func BuildChase(n int) []int {
    perm := rand.Perm(n)
    next := make([]int, n)
    for i := range n {
        next[perm[i]] = perm[(i+1)%n]
    }
    return next
}

func ChaseSum(next []int, steps int) int {
    idx, sum := 0, 0
    for range steps {
        idx = next[idx]
        sum += idx
    }
    return sum
}

Measured - three sizes chosen to fit in L1, fit in L2, and exceed last-level cache respectively:

                    M5 (10 cores)         VPS (2 cores)
fits in L1 (4KB)     0.94 ns/step          1.58 ns/step
fits in L2 (256KB)   2.14 ns/step          4.50 ns/step
exceeds LLC (64MB)  67.5  ns/step        133.3  ns/step

exceeds-LLC/L1 ratio: ~72x on M5, ~84x on the VPS

Careful with the label on that last row: 67.5ns and 133.3ns/step confirm the working set has exceeded whatever cache level was actually tested (64MB, past this article's assumed last-level cache size on both machines) - not that every one of those misses specifically landed in DRAM rather than some larger cache level neither machine's spec sheet was checked against here. "Slower than the cache we sized against" is the claim the benchmark actually supports; "definitely DRAM" would need the same kind of hardware-counter confirmation the false-sharing section above says it doesn't have either.

This is the off-cache-latency story in closer to its full force - a dependent access chain pays roughly 5-6x more per step than independent random access already does (~72-84x here vs. ~12-14x above). Both are real and worth avoiding on a hot path; the dependent case is the one to be genuinely alarmed about. If your code's random-access pattern looks like a linked list, a tree without an implicit array layout, or anything where you can't know the next address without dereferencing the current one, the full cache-miss cost applies - memory-level parallelism has nothing to hide behind when each load depends on the last.

Hot/Cold Data Splitting

// BROKEN: Hot and cold data mixed
type User struct {
    ID       uint64    // HOT: accessed frequently
    Score    int       // HOT: accessed frequently
    Name     string    // COLD: rarely accessed
    Email    string    // COLD: rarely accessed
    Address  string    // COLD: rarely accessed
    Bio      string    // COLD: rarely accessed
    // One User = 80 bytes, but we often only need 16 bytes
}

func TopUsers(users []User) []uint64 {
    // The comparisons only touch Score, but sort.Slice moves whole
    // 80-byte User values as it reorders the slice - that's where most
    // of the wasted bandwidth actually goes, not the comparisons
    sort.Slice(users, func(i, j int) bool {
        return users[i].Score > users[j].Score // Cache thrashing!
    })
}

// FIXED: Separate hot and cold data
type UserHot struct {
    ID    uint64
    Score int
    Cold  *UserCold // Pointer to cold data
}

type UserCold struct {
    Name    string
    Email   string
    Address string
    Bio     string
}

// Sorting now touches 24 bytes per user instead of 80 - not
// independently benchmarked here, but the same mechanism as the
// AoS-vs-SoA measurement above: less data touched per comparison
// during a sort means fewer cache lines pulled in for no reason.

What NUMA-Aware Design Can Look Like

Unlike the sections above, nothing here is independently benchmarked in this article - NUMA effects only show up on multi-socket or multi-node hardware, which neither the M5 nor the 2-vCPU VPS is. Take this as a sketch of the shape a NUMA-aware worker pool takes, not as a claim that it's worth building before you've profiled a real NUMA machine and confirmed cross-node memory traffic is actually your bottleneck.

// Check NUMA topology
// $ numactl --hardware
// node 0 cpus: 0-23
// node 1 cpus: 24-47

// Pin goroutine to specific CPU
func PinToCPU(cpuID int) {
    runtime.LockOSThread()
    
    var cpuSet unix.CPUSet
    cpuSet.Zero()
    cpuSet.Set(cpuID)
    
    tid := unix.Gettid()
    unix.SchedSetaffinity(tid, &cpuSet)
}

// NUMA-aware worker pool
type NUMAPool struct {
    workers [][]chan Task // workers[numa_node][worker_id]
}

func (p *NUMAPool) Submit(task Task) {
    // Hash task to NUMA node for data locality
    node := hash(task.Key) % len(p.workers)
    worker := rand.Intn(len(p.workers[node]))
    p.workers[node][worker] <- task
}

func (p *NUMAPool) StartWorker(numaNode, workerID int) {
    // cpusPerNode comes from parsing `numactl --hardware` (or
    // /sys/devices/system/node/) at startup, not a hardcoded constant -
    // core counts per node vary by machine and this is exactly the kind
    // of assumption that silently breaks portability if baked in.
    cpuID := numaNode*cpusPerNode + workerID
    PinToCPU(cpuID)
    
    for task := range p.workers[numaNode][workerID] {
        processTask(task)
    }
}

There Was No Branch: the Compiler Already Removed It

func CountCondition(data []int) int {
    count := 0
    for _, v := range data {
        if v > 128 {
            count++
        }
    }
    return count
}

// Branchless: no comparison, just arithmetic. (v-129)>>63 arithmetic-
// shifts the sign bit across all 64 bits - -1 when v<=128, 0 when
// v>128 - so 1 plus that is 0 or 1. Correct for any int, not just
// 0-255 like a v>>7&1 trick would be - except within 129 of
// math.MinInt, where v-129 itself underflows and wraps positive.
func CountConditionBranchless(data []int) int {
    count := 0
    for _, v := range data {
        count += 1 + (v-129)>>63
    }
    return count
}

Measured - 1M random ints in [0,256), go test -bench=. -benchmem -count=3, comparing unsorted data, data sorted once before the timed loop starts, and the branchless version - every call's result assigned to a package-level variable so an unused-value optimization can't quietly change what's being measured, the way it did for the random-access benchmark above:

                      M5 (10 cores)      VPS (2 cores)
random data            247 µs/op           670 µs/op
presorted              247 µs/op           670 µs/op
branchless             488 µs/op           797 µs/op

Two findings, not one. First: sorting to help branch prediction made no measurable difference on either machine - directly contradicting this article's original claim of a 2.7x improvement from it. But that's not actually a predictor story: checking the generated code on the M5 (go build -gcflags=-S) shows the compiler turned CountCondition's if v <= 128 into a single conditional-select instruction (CSINC on ARM64) rather than an actual conditional branch. There's no branch here to mispredict in the first place, sorted or not. Go's compiler does the equivalent if-conversion on amd64 too (a CMOV there instead of CSINC) for code shaped like this, though that specific disassembly wasn't independently re-checked on the VPS for this article - so this result says nothing about branch prediction quality on either chip, only that the compiler's if-conversion made the question moot for this exact comparison.

Second, and this one still cuts against conventional performance-tuning advice: the hand-written branchless version was slower - about 2x on the M5, about 1.2x on the VPS - not faster, even though the compiler had already converted the "branch" version to branchless code (CSINC on the M5's ARM64, almost certainly CMOV on the VPS's amd64) on its own. CountConditionBranchless's explicit subtract-shift-add sequence apparently costs more than the single compare-and-select the compiler generated for the plain if. The lesson isn't "branches beat branchless" here - there wasn't a branch to beat - it's that hand-rolled branchless tricks aren't free either, and can lose to whatever the compiler already does with an ordinary conditional. Worth checking the generated assembly before assuming either version needs help.

One number that is dramatic and consistent on both machines: sorting isn't free, and doing it inside the hot path instead of once beforehand costs far more than any branch-prediction benefit could ever return -

sort_every_call (re-sorts a fresh copy on every call):
  M5:  ~21.7 ms/op  (vs. 247 µs/op presorted - ~88x slower)
  VPS: ~35.5 ms/op  (vs. 670 µs/op presorted - ~53x slower)

If "sort first" is genuinely useful for your workload - and for this side-effect-free counter it measured as not being useful at all - sort once and reuse the result, not on every call in the hot path.

Add a Side Effect, and the Branch Comes Back

A reader (Kejun Huang, who covered this article in his Nodes to Nanoseconds newsletter) pointed out the gap in the finding above: CountCondition's branch disappeared specifically because count++ has no side effect. Go's branch-elimination pass (canSpeculativelyExecute in cmd/compile/internal/ssa/branchelim.go) explicitly refuses to fuse memory operations, divides, or anything else with side effects into a conditional-select - its own comment says so: "don't fuse memory ops, Phi ops, divides (can panic), or anything else with side-effects." A conditional that writes to memory should keep its branch, sorted-data benefit and all.

// FilterCondition appends matches instead of counting them - that
// append is a memory write inside the if, which canSpeculativelyExecute
// won't touch. Unlike CountCondition, this keeps a real branch.
func FilterCondition(data []int, out []int) []int {
    out = out[:0]
    for _, v := range data {
        if v > 128 {
            out = append(out, v)
        }
    }
    return out
}

Checked the generated code on both machines before trusting this (go build -gcflags=-S): FilterCondition compiles to a real conditional jump on both - BLE on the M5's ARM64, JLE on the VPS's amd64 - no CSINC/CMOV in sight. That's the control case the counting version never was.

Measured - same 1M random ints in [0,256), go test -bench=. -benchmem -count=5, unsorted vs. presorted:

                      M5 (10 cores)      VPS (2 cores)
filter_random_data    2578 µs/op         4358 µs/op
filter_presorted        485 µs/op          915 µs/op

This time sorting is a real, large win: ~5.3x faster presorted on the M5, ~4.8x on the VPS, each consistent within its own 5-run set (under 2% run-to-run variance on the M5, under 2.5% on the VPS). An earlier version of this VPS number read ~1223 µs/op presorted (~3.7x) from a run made in a separate session - cross-session drift on a shared 2-vCPU box turned out to be larger than the within-session run-to-run variance this article already flags elsewhere, so the number above is from the most recent clean joint run and is the one to trust. The direction and rough size of the effect never changed, only the second decimal digit of the multiplier. The reader's correction was right, and it sharpens the original finding rather than overturning it: sorting for branch prediction isn't a dead technique, it's a technique that only pays off when the branch actually survives to run time. count++ gets compiled away; append-on-match doesn't. Check the generated assembly, not just the surface shape of the if statement, before deciding which case you're in.

That win is still not a reason to sort data specifically to help branch prediction. The saved time here is 2578 − 485 = 2093 µs, about 2.1 ms - and sorting a fresh 1M-element copy costs ~21.7 ms/op, measured two sections up. That's roughly 10x more expensive than the benefit it buys for a single filter pass, and the breakeven is concrete: 21.7ms / 2.093ms ≈ 10.4, so sorting only pays for itself once the same sorted data gets filtered on the order of 11+ times before it's discarded or goes stale. Filter it once and throw the sorted copy away, and the sort cost dwarfs everything it bought. The two findings agree with each other, not against: sort once ahead of time and reuse it enough, or already have it sorted for another reason, and predictable branches are close to free. Sort in order to filter it once, and you paid ten times the prize to enter the raffle.

A rough plausibility check on the 2.1 ms, since 5.3x is on the high end of what misprediction numbers usually look like: uniform random data around a mid-range threshold mispredicts close to 50% of the time, and a misprediction on a modern out-of-order core costs on the order of 15-20 cycles of pipeline flush. At 1M elements that's roughly 500,000 mispredictions × 15-20 cycles ≈ 7.5-10M cycles, which at a P-core clock around 4 GHz works out to roughly 1.9-2.5 ms - the measured 2.09 ms sits inside that range. Not a precise derivation (real clock speed and per-misprediction cost weren't independently measured here, just cited as typical figures), but the order of magnitude holds up as a sanity check, not just a stopwatch reading.

Thanks to Kejun for the catch - this is exactly the kind of correction that makes an article more honest rather than less, and it's now baked into the benchmark suite instead of sitting in an inbox.

The type doesn't matter either: FilterConditionFloat64 (threshold 0.5 on [0,1), same ~50% selectivity) compiles to the same shape of real branch on both machines - FCMPD+BLE on the M5, UCOMISD+JLE on the VPS, no CSINC/CMOV - and measures, if anything, a slightly larger effect:

                      M5 (10 cores)      VPS (2 cores)
filter_random_data    3178 µs/op         5714 µs/op
filter_presorted        500 µs/op        1401 µs/op

~6.4x faster presorted on the M5, ~4.1x on the VPS - float comparison (FCMPD/UCOMISD) costs a bit more per branch than an integer compare, which plausibly widens the misprediction penalty slightly, but the mechanism and the direction are identical to the int result above. The type was never load-bearing for this finding.

Kejun's issue #16 (linked above) doesn't itself publish source or exact numbers for the sorted-vs-unsorted case - that's in his earlier issue #13, which in turn cites a Rust post by greyblake ("Branchless Rust: Making a Filter 4x Faster by Removing an if") that does: 1M f64 uniform on [0,100), threshold 50.0, shuffled vs. sorted. Rust measured 4.15ms shuffled, 0.93ms sorted - ~4.5x. Ported the same setup to Go (FilterAbove, same range and threshold, same idiomatic filter-shaped branch) and ran it on both machines:

                      M5 (10 cores)      VPS (2 cores)
shuffled                 3.24 ms/op         6.45 ms/op
sorted                   0.44 ms/op         1.46 ms/op

~7.3x on the M5, ~4.4x on the VPS - same direction as Rust's ~4.5x, similar order of magnitude on the VPS, larger on the M5. Not a claim that Go and Rust produce identical numbers (different compilers, different codegen, no attempt made to match Rust's build flags or measurement methodology) - just that the underlying effect reproduces end to end, from the original Rust post through Kejun's citation of it to Go on two unrelated machines.

The Fourth Variant: a Branchless Filter

CountConditionBranchless earlier lost to the compiler's own branch-eliminated code - a real branch there cost nothing to begin with, so hand-rolled arithmetic just added overhead. FilterCondition is different: it has a real, ~2ms-costing misprediction to beat. Does the same branchless trick win here, where there's an actual prize?

// Unconditional write, conditional index - no branch left for
// canSpeculativelyExecute to refuse, unlike FilterCondition.
func FilterConditionBranchless(data []int, out []int) []int {
    out = out[:len(data)]
    j := 0
    for _, v := range data {
        out[j] = v
        j += 1 + (v-129)>>63
    }
    return out[:j]
}

Confirmed on both machines that this compiles with no branch on v - only the loop's own bounds check, same as every for range loop. Measured against both branching variants, unsorted and presorted:

                       VPS (2 cores, clean: <2.5% CV, all four together)
filter_random_data       4358 µs/op
filter_presorted           915 µs/op
branchless_random_data     752 µs/op
branchless_presorted       730 µs/op

On the VPS, branchless wins outright - faster than even the presorted branching version (752 µs vs. 915 µs), and essentially sort-invariant as predicted (752 µs vs. 730 µs, within this run's noise). This is the opposite result from the counter: there, the branch was already gone and hand-rolled branchless just added cost; here, the branch is real, the misprediction is real money, and paying to avoid it outright beats even sorting to reduce it. The M5 told the same directional story - branchless clearly beat the unsorted case and landed roughly sort-invariant - but this machine had been running heavy back-to-back benchmarks for a while by this point in the session and its numbers here carry more run-to-run noise (15-29% CV, well above the <2% seen on it earlier) than is worth reporting a precise multiplier for. The VPS is the number to trust for this specific comparison.

One bound worth stating explicitly: this whole comparison ran at ~50% selectivity, which is close to the worst case for a branch predictor - a near-coin-flip branch is exactly what it can't learn a pattern for. At a skewed selectivity (1% or 99% matching, like greyblake's 99% row two sections up, which ran 2.6x faster than his 50% row in the source Rust post) an unsorted branching version's predictor does much better on its own, since the branch mostly goes the same way - narrowing or erasing whatever edge the branchless version has there. This result says branchless wins at the branch predictor's worst case, not that it wins at every selectivity; it wasn't re-measured across the selectivity range here.

Cache-Conscious Hash Table

// Standard map - random memory access
m := make(map[uint64]uint64)
// Cache misses everywhere

// Cache-friendly Robin Hood hashing
type RobinHoodMap struct {
    buckets []bucket
    mask    uint64
}

type bucket struct {
    key      uint64
    value    uint64
    distance uint16 // uint8 overflows silently past ~255-probe sequences at high load factor
    occupied bool
}

func (m *RobinHoodMap) Get(key uint64) (uint64, bool) {
    idx := key & m.mask
    distance := uint16(0)
    
    // Linear probing = cache-friendly access pattern
    for {
        b := &m.buckets[idx]
        
        if !b.occupied {
            return 0, false
        }
        
        if b.key == key {
            return b.value, true
        }
        
        // Robin Hood: poor keys don't travel far
        if b.distance < distance {
            return 0, false
        }
        
        idx = (idx + 1) & m.mask
        distance++
    }
}

Worth being precise about what idx := key & m.mask actually is: not a hash function, just the key's own low bits used directly as the bucket index. That only spreads keys evenly across buckets because this benchmark's keys are full-width, uniformly random uint64s (rand.Uint64()) - the low bits of a uniformly random 64-bit value are themselves uniformly distributed, more or less by luck of what the input happens to be, not because the map does any mixing. Sequential IDs, small integers, or pointer-derived keys - all common in real code - would cluster hard in the low bits and wreck this map's probe-sequence assumptions in a way the benchmark below never exercises. A real implementation would hash the key first; this one is measuring Robin Hood displacement specifically, on input it's already friendly to.

Also worth noting for anyone comparing this against the standard library map on a specific Go version: since Go 1.24, the built-in map is implemented as a Swiss Table (a different bucket/group layout with SIMD-friendly probing) rather than the older bucket-and-overflow design - a materially different profile than what the numbers below would have measured on Go 1.23 and earlier.

Measured - 100,000 keys, and this is the one place the original claim's methodology mattered as much as the number: a sparser hash table has shorter probe sequences pretty much by definition, so comparing one arbitrary Robin Hood load factor against Go's stdlib map is comparing speed without comparing the memory it costs to get there. Swept across three bucket counts instead (go test -bench=. -benchmem -count=3):

                          M5 (10 cores)   VPS (2 cores)   bucket memory
stdlib map                    6.0 ns/op      22.6 ns/op   (not directly comparable)
RobinHood, 76% load          12.8 ns/op      21.5 ns/op   3 MB
RobinHood, 38% load           3.9 ns/op       9.9 ns/op   6 MB
RobinHood, 19% load           2.2 ns/op       7.2 ns/op   12 MB

The "3x faster" story only holds at the sparsest setting tested (19% load, ~2.7x on M5, ~3.1x on the VPS) - and that's 12MB of buckets for 100,000 entries, several times what Go's map uses for the same data. At 76% load - close to what a production system sizing for memory efficiency would actually run - Robin Hood was measurably slower than the standard library on the M5, and roughly a wash on the VPS. This isn't a reason to avoid Robin Hood hashing; it's a reason to report speed and memory together, since trading one for the other is the entire mechanism behind the win.

Example: Analytics Pipeline

// BEFORE: Object-oriented design
type Event struct {
    Timestamp time.Time
    UserID    uint64
    Action    string
    Value     float64
    Tags      map[string]string
}

func ProcessEvents(events []Event) {
    for _, e := range events {
        // Each event access = potential cache miss
        if e.Action == "purchase" {
            updateRevenue(e.Value)
        }
    }
}

// AFTER: Data-oriented design
type EventBatch struct {
    Timestamps []int64   // Unix timestamps
    UserIDs    []uint64
    Actions    []uint8   // Enum instead of string
    Values     []float64
    
    // Tags stored separately (cold data)
    TagIndices []uint32
    TagKeys    []string
    TagValues  []string
}

func ProcessEventsBatch(batch *EventBatch) {
    // Process actions in cache-friendly way
    for i, action := range batch.Actions {
        if action == ActionPurchase {
            updateRevenue(batch.Values[i])
        }
    }
}

// Not independently benchmarked in this article - the same AoS-vs-SoA
// mechanism measured above (touching only the fields you use, not a
// whole 80-byte struct per iteration), applied to a different shape
// of data. Expect a real but more modest win than a from-scratch
// number would suggest, per the measured AoS/SoA result earlier.

Layout for Future SIMD/Assembly - Not Plain Go

type Vec3 struct {
    X, Y, Z float32
    _       float32 // pads the struct to a 16-byte size, so 4 fit exactly in one 64-byte cache line
}

func AddVectors(a, b []Vec3, result []Vec3) {
    for i := range a {
        result[i].X = a[i].X + b[i].X
        result[i].Y = a[i].Y + b[i].Y
        result[i].Z = a[i].Z + b[i].Z
    }
}

Be clear-eyed about what this buys in the code actually shown above: nothing, and arguably a loss. AddVectors is a plain scalar Go loop - the Go compiler doesn't auto-vectorize this shape into SIMD instructions, so nothing here reads the padding field or benefits from the 16-byte size. What the loop actually does is load and store 16 bytes per Vec3 to use 12 of them - the exact same "loads N bytes, wastes some of them" pattern the AoS section earlier in this article argues against. Padding to a SIMD-friendly size only pays off if something downstream actually processes these vectors with SIMD - hand-written assembly, cgo into a vectorized C/Rust routine, or a library that does its own vectorized traversal over this layout. Plain Go arithmetic on a slice of these structs, like the loop above, gets none of that benefit and pays the extra-bytes-per-line cost for it.

Worth being precise about what that padding field does: it makes Vec3 16 bytes in size, so 4 consecutive values pack exactly into one 64-byte cache line with no waste. It does not force 16-byte alignment - Go struct alignment is set by the widest field's natural alignment (4 bytes for float32 here), and a zero-size field contributes nothing to that. A previous version of this article claimed a separate [0]byte-based "alignment trick" forced 64-byte alignment on an arbitrary struct; that doesn't do anything in Go - a zero-size field has no size to influence layout with, and there's no compiler magic elsewhere in the standard toolchain that reads it specially. If you actually need a guaranteed-aligned allocation in Go, there isn't a portable way to request that from the language - you'd allocate an oversized buffer and slice into it at a computed aligned offset, or use unsafe with platform-specific assumptions.

Benchmarking Cache Performance

func BenchmarkCacheLineSize(b *testing.B) {
    // Detect cache line size experimentally
    data := make([]int64, 1024*1024)
    
    for stride := 1; stride <= 128; stride *= 2 {
        b.Run(fmt.Sprintf("stride_%d", stride), func(b *testing.B) {
            for range b.N {
                // Touch memory at stride intervals
                for j := 0; j < len(data); j += stride {
                    data[j]++
                }
            }
        })
    }
    // A sharp change around stride=8 is consistent with a 64-byte
    // cache line - the drop is a signal, not a proof; see the M5's
    // formally-128-byte cache line above for why it doesn't always
    // read straight off a stride benchmark.
}

Rules for Cache-Friendly Go

  1. Pack hot data together: Frequently accessed fields in same cache line
  2. Pad for concurrent access: Separate goroutine data by cache lines - read the target's actual line size instead of hardcoding 64, per the finding above
  3. Prefer arrays over linked lists: Sequential > random access
  4. Use smaller types: int32 vs int64 if range allows
  5. Sort before processing: helps predictable branches, but only when the branch survives compilation - a side-effect-free conditional (a plain counter increment) can get compiled into a branchless conditional-select, in which case sorting does nothing; a conditional with a memory write (appending matches to a slice) keeps its branch, and sorting was a real ~5.3x/~4.8x win in the tests above. Check the generated assembly to know which case you're in
  6. Pool allocations: Reuse allocations when profiling shows allocation/GC pressure - reuse alone doesn't guarantee better cache locality
  7. Profile, don't guess: Use perf, pprof, and benchmarks

The Performance Optimization Recipe

1. Profile: Find cache misses with perf
2. Restructure: AoS → SoA for hot paths
3. Pad: Eliminate false sharing
4. Pack: Group frequently accessed data
5. Prefer sequential access patterns hardware prefetchers can exploit
6. Measure: Verify with benchmarks

Actual measured effect sizes (this article's own benchmarks, not
production case studies - see the table above): false sharing
elimination gave the largest real win (~9-24x), followed by dependent
random access / pointer chasing (~72-84x, but that's a different thing
from plain independent random access, which cost ~12-14x). Robin Hood
hashing and AoS→SoA are real but load- and size-dependent, more modest
than originally claimed. "Sort for branch prediction" didn't reproduce
for a side-effect-free counter - the compiler had already removed the
branch - but reappeared as a real ~5.3x/~4.8x win once the loop body
had a side effect (a memory write) the compiler couldn't optimize away.
"Branchless is faster" actively reproduced backwards: the hand-written
branchless version lost to the compiler's own branchless code on both
machines.
Profile first; the effect size on your workload is not guaranteed by
any number in this article.

Remember: Modern CPUs are fast. Memory is slow. The gap grows every year - but the benchmarks above are also proof that guessing which optimization matters, and by how much, doesn't work reliably even for someone who's read the theory. Cache-friendly layout matters when a workload is actually memory-bound. Measure first, then optimize the data layout your hot path actually touches.

What Actually Held Up

Optimization Technique Originally Claimed Measured (M5 / VPS) Verdict
False Sharing Elimination 6.4x ~24x / ~9x Real, bigger than claimed
Array of Structs → Struct of Arrays (fully separated) 7x ~1.22x / ~1.16x Real at this size, but not measured memory-bound - working set fits in L2/L3
Robin Hood hashing vs stdlib map 3x (unqualified) 2.2-2.7x at 19% load; slower than stdlib at 76% load Real, but load-factor-dependent - report memory with it
Independent random access ("prefetcher can't help") 20x slower than linear ~12-14x slower than linear (both machines) Real, in the right direction, roughly the right order of magnitude
Dependent random access (pointer chasing, exceeding cache) not covered in the original article ~72-84x slower than an L1 hit The "RAM is ~60x slower" story, in the access pattern where it actually applies in full
Sort data for branch prediction (side-effect-free counter) 2.7x (8.2ns → 3.1ns) No measurable difference, either machine Not reproduced - the compiler removed the branch (CSINC/CMOV)
Sort data for branch prediction (branch kept alive by a memory write) not covered in the original article ~5.3x / ~4.8x Real and large once the branch can't be compiled away
Branchless arithmetic vs. a branch 3.6x faster (8.2ns → 2.3ns) ~2x slower on M5, ~1.2x slower on the VPS Wrong direction - the compiler's own branchless conditional-select beat the hand-written branchless code on both machines

All of the above: Go 1.27.1 on both machines, go test -bench=. -benchmem, -count=3 for most sections and -count=5 for the AoS/SoA and branch-prediction follow-ups, source in the GitHub Gist linked throughout this article. Worth flagging since it's directly relevant to the Robin Hood/stdlib-map comparison above: Go 1.27.1 has the Swiss Table map, not the pre-1.24 bucket implementation - the "stdlib map" row in that section's numbers is measuring the newer map.

Cache locality and GC pressure are separate concerns, worth not conflating: a layout change can improve locality without changing how much work the collector does, and reducing allocations reduces GC work independently of layout. []Struct vs. []*Struct is the clearest example - it can change traversal locality substantially without necessarily changing allocation counts, or vice versa depending on how the slice itself gets built. Treat them as two separate things to profile, not one bundled benefit.