|
| 1 | +# onebrc-probe — trie vs RAM-table methods and outcomes |
| 2 | + |
| 3 | +> Measurement report. All numbers are **measured** on this machine, not |
| 4 | +> projected. Corpus: `/tmp/brc10m.txt`, 10,000,000 rows, seed 42, |
| 5 | +> sha256 `f1853caa30a765883aa655be1c304d956ad8b03e19b3557df2af431d9a955691`. |
| 6 | +> Metric: `throughput_mrows_s` (rows / compute-time). **Compute-only** — |
| 7 | +> `main.rs` reads the file (`fs::read`, line 91) BEFORE `Instant::now()` |
| 8 | +> (line 94), so file I/O and any mmap lever are OUTSIDE the timer. |
| 9 | +> Build: `.cargo/config.toml` pins `target-cpu=x86-64-v3` (AVX2) unless a |
| 10 | +> row says `native`. 4 workers. |
| 11 | +> |
| 12 | +> **Statistical note (corrected after adversarial review).** An earlier |
| 13 | +> draft of this report headlined single **best-of-3** numbers. Three review |
| 14 | +> agents (truth-architect, overclaim-auditor, brutally-honest-tester) |
| 15 | +> correctly flagged that best-of-N reports the luckiest run and hides the |
| 16 | +> spread — at run-to-run variance of ~8–13%, that is a real reporting sin. |
| 17 | +> This version reports **median / min / max / sd over n=11** (v3) and n=7 |
| 18 | +> (native) per lane. Cardinality is ~400 stations (`gen.rs STATION_COUNT`); |
| 19 | +> results are for THIS workload, THIS machine, ONE corpus — see "Scope" at |
| 20 | +> the end. |
| 21 | +> |
| 22 | +> **Lane S provenance.** Lane S (SWAR) shipped separately (PR #637, merged to |
| 23 | +> `main`); this branch is rebased on top of it, so `lane_s` and its |
| 24 | +> `lane_s_agrees_with_lane_a` parity test are present here and all lanes in the |
| 25 | +> ladder — `a c r f t8 t s` — are runnable. S is kept in the ladder because the |
| 26 | +> report's whole point is the full RAM-table-vs-trie comparison, and S is the |
| 27 | +> fastest RAM-table method. |
| 28 | +
|
| 29 | +## The methods (group-by-aggregate, min/max/sum/count per station) |
| 30 | + |
| 31 | +Every lane runs the SAME workload and the SAME newline-aligned |
| 32 | +`chunk_bounds` split + commutative merge. What varies is (1) how a record's |
| 33 | +delimiters are found/parsed and (2) how the station identity becomes an |
| 34 | +accumulator slot — the "trie vs RAM-table" axis. |
| 35 | + |
| 36 | +| Lane | Scan / parse | Group-by structure | Family | |
| 37 | +|---|---|---|---| |
| 38 | +| **A** scalar | byte-wise `;`/`\n`, int parse | `BTreeMap<String,Stats>` | baseline, 1 thread | |
| 39 | +| **C** threads | byte-wise, int parse | per-worker `BTreeMap`, merge | baseline, N threads | |
| 40 | +| **R** radix | byte-wise, int parse | flat SoA table, slot = `hash & 0xFFFF` | RAM flat table (control) | |
| 41 | +| **F** Morton | byte-wise, int parse | flat SoA table, slot = FNV-1a → nibble-interleaved 16-bit Morton tile | RAM flat table (substrate-native) | |
| 42 | +| **T8** byte-trie | byte-wise, int parse | 256-ary arena trie, one level per name byte | trie | |
| 43 | +| **T** nibble-trie | byte-wise, int parse | 16-ary arena trie (HHTL `NiblePath`-faithful), 2 levels per byte | trie | |
| 44 | +| **S** SWAR | **SWAR** `;`/`\n` (haszero u64 trick) + **branchless** int parse | flat SoA table (reuses F verbatim) | RAM flat table + SWAR | |
| 45 | + |
| 46 | +All lanes are parity-checked: **every lane produces aggregates identical to |
| 47 | +lane A** on a generated corpus (unit tests `lane_a_and_lane_c_agree…`, |
| 48 | +`lane_f_and_lane_r_agree_with_lane_a…`, `both_tries_agree_with_lane_a…`, |
| 49 | +`lane_s_agrees_with_lane_a`, plus a forced-collision probe on the shared |
| 50 | +table). Verified in-code, not asserted. |
| 51 | + |
| 52 | +## The outcomes (10M rows, 4 workers, mrows/s) |
| 53 | + |
| 54 | +**v3 (x86-64-v3), n=11 per lane:** |
| 55 | + |
| 56 | +| Lane | median | min | max | sd | vs C (median) | |
| 57 | +|---|---:|---:|---:|---:|---:| |
| 58 | +| C threads + BTreeMap | 31.2 | 29.4 | 32.0 | 0.7 | 1.0× (ref) | |
| 59 | +| T 16-ary nibble trie | 54.2 | 50.4 | 54.7 | 1.4 | 1.7× | |
| 60 | +| T8 256-ary byte trie | 58.3 | 54.5 | 66.5 | 3.8 | 1.9× | |
| 61 | +| F flat Morton table | 84.6 | 75.1 | 86.3 | 3.6 | 2.7× | |
| 62 | +| R flat radix table | 87.7 | 61.2 | 89.1 | 8.6 | 2.8× | |
| 63 | +| **S SWAR + flat table** | **103.9** | 76.6 | 105.5 | 7.9 | **3.3×** | |
| 64 | + |
| 65 | +**native (target-cpu=native), n=7, controlled same-session (F, S only):** |
| 66 | + |
| 67 | +| Lane | median | min | max | sd | |
| 68 | +|---|---:|---:|---:|---:| |
| 69 | +| F flat Morton table | 74.0 | 65.8 | 84.0 | 6.3 | |
| 70 | +| **S SWAR + flat table** | **96.9** | 90.6 | 106.2 | 5.3 | |
| 71 | + |
| 72 | +## What the numbers actually say (each claim scoped to its evidence) |
| 73 | + |
| 74 | +1. **SWAR (S) is the one real, robust win. [supported]** At the median, S |
| 75 | + beats F by **+23% on v3** (103.9 vs 84.6) and **+31% on native** (96.9 vs |
| 76 | + 74.0). The gap (≈19 mrows/s) is ~2.4× S's own sd and clears F's max |
| 77 | + (86.3) at the median. Caveat kept honest: S is the noisier lane — its |
| 78 | + *worst* run (76.6) dips below F's median, so the guarantee is "typically |
| 79 | + +~25%, occasionally ties F," not a hard floor. The earlier best-of-3 |
| 80 | + draft happened to draw an unlucky S run (77.4) that made the number look |
| 81 | + cherry-picked; the n=11 median vindicates the SWAR win but only with the |
| 82 | + spread disclosed. |
| 83 | + |
| 84 | +2. **The trie is slower than the flat table here — but this is the arena-trie |
| 85 | + IMPLEMENTATION, not "the trie idea," and the distinction matters. |
| 86 | + [supported, confounded]** T (54.2) and T8 (58.3) medians both sit far |
| 87 | + below F (84.6) and R (87.7); the gap is large and robust across n=11. But |
| 88 | + two confounds are uncontrolled and inflate the trie's cost: (a) `Trie` |
| 89 | + carries `fanout` as a **runtime struct field** (`descend` computes |
| 90 | + `node*self.fanout+sym`), losing the strength-reduction/monomorphization |
| 91 | + the flat table gets from its `const SLOTS`; (b) `descend` does an |
| 92 | + **in-loop arena realloc** (`children.extend(...)` per new node, 256×u32 = |
| 93 | + 1 KB/node for T8) *inside the timed scan*, while the flat table allocates |
| 94 | + once up front. So the honest claim is: **this arena-trie is not |
| 95 | + competitive with the flat table on dense small-cardinality group-by** — |
| 96 | + NOT "the trie is falsified." The direction (a trie chases ~10–20 |
| 97 | + dependent loads/record vs the table's ~1 hash + 1 near-L1 slot) is |
| 98 | + plausible, but a const-fanout, pre-sized-arena trie was not built, so the |
| 99 | + idea itself is untested. This does contradict the earlier-session |
| 100 | + hypothesis that the HHTL trie is what reached ~90 — no trie variant here |
| 101 | + reaches the flat table's throughput. |
| 102 | + |
| 103 | +3. **Morton (F) vs plain radix (R): no measurable difference. [supported — |
| 104 | + corrected from the prior draft]** R actually medians *slightly above* F |
| 105 | + (87.7 vs 84.6), and that 3.1-mrows/s difference is well inside R's sd |
| 106 | + (8.6). The nibble-interleave is a **no-op on throughput** (possibly a |
| 107 | + marginal negative). The prior draft's "F beats R by a hair" was wrong and |
| 108 | + is retracted. The big structural win is flat-SoA-table-vs-BTreeMap |
| 109 | + (R−C ≈ +56 median), not the addressing scheme. |
| 110 | + |
| 111 | +4. **`target-cpu=native` gives no benefit for these lanes — and this table |
| 112 | + contains NO SIMD lane, so it says nothing about AVX-512. [narrow claim |
| 113 | + supported; the broad one retracted]** Controlled same-session, native F |
| 114 | + (74.0) and S (96.9) medians are *below* their v3 counterparts (84.6, |
| 115 | + 103.9) — native did not help and if anything ran slightly slower (likely |
| 116 | + codegen/thermal, within the noise band). The defensible statement is |
| 117 | + "the compiler's `native` flag does not speed up these SCALAR/SWAR lanes." |
| 118 | + The prior draft's "native SIMD is noise" overreached: lane S is *SWAR* |
| 119 | + (scalar u64 tricks), not vector SIMD, and the actual SIMD lane (B) is |
| 120 | + feature-gated and absent from this table. This probe cannot adjudicate |
| 121 | + any AVX-512 claim — it runs no AVX-512. |
| 122 | + |
| 123 | +5. **mmap (lever a) is not measurable in this harness and was not faked. |
| 124 | + [verified in code]** The timer starts after `fs::read` (main.rs:91 → :94); |
| 125 | + mmap is a wall-clock / 13 GB-allocation lever for the full 1B file, on an |
| 126 | + axis this metric does not observe. Measuring it needs an end-to-end |
| 127 | + wall-clock mode + a memmap2 dep, which breaks the std-only, zero-dep |
| 128 | + contract of lanes A/C/F/R/T/S. |
| 129 | + |
| 130 | +## The honest bottom line |
| 131 | + |
| 132 | +For a dense, ~400-cardinality group-by at 10M rows on this machine: **flat |
| 133 | +SoA table + SWAR scan/parse is the fastest method measured (~104 mrows/s |
| 134 | +median, +~25% over the plain-scalar flat table); the arena-trie lanes are |
| 135 | +the slowest of the non-baseline group.** The Morton interleave buys nothing |
| 136 | +over plain radix. Native codegen buys nothing over v3. |
| 137 | + |
| 138 | +**What this does NOT establish (explicit conjecture, unmeasured here):** |
| 139 | +- That a trie is the wrong structure *in general* — only that THIS arena |
| 140 | + trie loses on THIS workload; a const-fanout/pre-sized variant is untested. |
| 141 | +- That the trie "wins at prefix routing" — no prefix-routing / ancestor-query |
| 142 | + benchmark exists in this crate. That is the HHTL cascade's claimed job, |
| 143 | + but it is a CONJECTURE here, not a result. |
| 144 | +- That ~400-cardinality dense group-by is "the substrate's own aggregation |
| 145 | + shape" — unmeasured; `lane_f.rs` itself flags high-cardinality as a |
| 146 | + different regime. |
| 147 | + |
| 148 | +## Scope / how to reproduce |
| 149 | + |
| 150 | +Single machine, single corpus, one cardinality (~400 stations), one row |
| 151 | +count (10M). Not a claim about other CPUs, other cardinalities, or the full |
| 152 | +1B-row file. |
| 153 | + |
| 154 | +```bash |
| 155 | +cd crates/onebrc-probe |
| 156 | +cargo build --release |
| 157 | +target/release/onebrc-probe gen /tmp/brc10m.txt 10000000 42 # if absent |
| 158 | +for lane in a c r f t8 t s; do |
| 159 | + for i in $(seq 1 11); do target/release/onebrc-probe run /tmp/brc10m.txt $lane 4; done |
| 160 | +done # take median + min/max/sd per lane, not best-of-N |
| 161 | +# native: RUSTFLAGS="-C target-cpu=native" cargo build --release --target-dir target-native |
| 162 | +``` |
0 commit comments