A Go sandbox for re-running published benchmarks and dissecting why A beats B — not just on paper, but at the level of cache lines, allocations, and syscalls.
Most benchmark blog posts hand you a verdict: "X is 3× faster than Y." This repo is the opposite. It's a workshop where each experiment is reproduced from scratch, dialed in across a range of inputs, and profiled until the systems-level reason behind the number is obvious.
Reading a benchmark and believing a benchmark are different things. The goal here is to:
- Reproduce the published result locally, on real hardware.
- Stress it across input sizes, access patterns, and concurrency until the curve breaks.
- Explain the delta through CPU cache behaviour, allocations, branch prediction, syscall cost, GC pressure, etc.
- Generalise the takeaway into a rule of thumb worth carrying into production code.
Every folder under play/ is one such investigation.
A CRUDBase interface implemented two ways:
| Impl | Backing store | Get | Add | Delete |
|---|---|---|---|---|
CustomMap |
map[string]any |
O(1) hash | O(1) hash | O(1) hash |
CustomSlice |
[]any |
O(N) scan | O(1) append | O(N) shift |
The question. For very small N (think 5–50 elements), the textbook says "use a map". Production code disagrees often enough — a linear slice scan beats a hash map until N gets big enough that the constant-factor advantage of cache-friendly contiguous memory loses to algorithmic complexity. Where does that crossover actually sit?
What we're hunting:
- The crossover N where map overtakes slice.
- The cost of
runtime.mapaccess(hashing + pointer chasing) vs a tight loop over contiguous memory the prefetcher loves. - Allocation differences (
mapgrows in buckets;slicedoubles). - How
interface{}boxing skews both.
Run it:
go test -bench=. -benchmem -benchtime=5s ./play/...go-play/
├── go.mod
└── play/
├── fetcher.go # experiment subject (map vs slice CRUD)
└── fetcher_test.go # benchmark harness
Each new investigation should live in its own subpackage under play/ with:
- A short doc comment at the top of the file stating the claim being tested and the source (blog post, paper, talk).
- The minimal implementation(s) under comparison — no production noise.
- A
_test.gowithBenchmark*functions covering at least 3 input sizes. - A note in this README under Current experiments with the hypothesis and the answer once known.
# Single benchmark, verbose
go test -bench=BenchmarkX -benchmem -benchtime=5s ./play/...
# CPU + memory profiles
go test -bench=. -cpuprofile=cpu.out -memprofile=mem.out ./play/...
go tool pprof -http=:8080 cpu.out
# Compare two implementations with benchstat
go install golang.org/x/perf/cmd/benchstat@latest
go test -bench=. -count=10 ./play/... > old.txt
# ...make a change...
go test -bench=. -count=10 ./play/... > new.txt
benchstat old.txt new.txt
# Inspect generated assembly for a hot path
go build -gcflags="-S" ./play/... 2>&1 | less
# Escape analysis — see what's hitting the heap
go build -gcflags="-m -m" ./play/... 2>&1 | lessThings to pull in next, each with a clear "A vs B, why" framing:
sync.MapvsRWMutex + map— at what read/write ratio doessync.Mapactually win?- Channels vs mutexes for producer/consumer — the famous "share memory by communicating" tradeoff, quantified.
strings.Buildervs+=vsbytes.Buffer— where does each one's overhead come from?- Goroutine pools vs unbounded spawning — when does scheduler contention dominate?
json.Marshalvseasyjsonvssonic— reflection cost, codegen wins, SIMD on hot fields.atomicvsmutexfor counters — cache-line ping-pong under contention.- Slice preallocation —
make([]T, 0, N)vs growing — measure realloc + copy cost.
- Every benchmark must be reproducible by a stranger with
go test -bench=.... - No production patterns until the experiment shows they earn their weight.
- The README is the source of truth for what was learned, not the commit history.
- When the result contradicts the blog post that inspired it, write down why — that's the whole point.