Skip to content

Commit f6bdb36

Browse files
committed
docs: refresh amd64 scorecard with phase-6 numbers
Phase 6 (X6b + X8) cleanly improved struct decode without touching interface{}. Struct 1 MB formatted jumped 1286 → 1499 MB/s (+16.6 %); 5 MB and 10 MB gained ~6 %. All 11 decode gates still ≥ +10 % over bytedance/sonic on the EPYC-Genoa AVX-512BW host. README: update the scorecard table to the phase-6 best-of-2 numbers. RESULTS: append a Phase 6 section with the experiment log — X6b and X8 kept, X6/X7/X9/X10 rejected and documented so the next round doesn't re-try the same ideas.
1 parent 20cbe04 commit f6bdb36

2 files changed

Lines changed: 63 additions & 18 deletions

File tree

README.md

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,28 @@ A high-performance JSON library for Go with a drop-in `encoding/json` API.
77

88
## Highlights
99

10-
### Decode (amd64 AMD EPYC-Genoa, AVX-512BW, `-benchtime=5s -count2`)
10+
### Decode (amd64 AMD EPYC-Genoa, AVX-512BW, `-benchtime=5s -count=2`)
1111

1212
| Workload | sonic | jsonx | Δ vs sonic |
1313
|---|--:|--:|--:|
14-
| Decode `interface{}` · small.json | 203 MB/s | **259 MB/s** | **+27.7 %** |
15-
| Decode `interface{}` · twitter.json | 379 MB/s | **487 MB/s** | **+28.5 %** |
16-
| Decode `interface{}` · citm_catalog.json | 395 MB/s | **602 MB/s** | **+52.7 %** |
17-
| Decode `interface{}` · canada.json (float-heavy) | 197 MB/s | **319 MB/s** | **+62.3 %** |
18-
| Decode `interface{}` · 1 MB formatted | 530 MB/s | **777 MB/s** | **+46.7 %** |
19-
| Decode `interface{}` · 5 MB formatted | 534 MB/s | **680 MB/s** | **+27.4 %** |
20-
| Decode `interface{}` · 10 MB formatted | 582 MB/s | **661 MB/s** | **+13.6 %** |
21-
| Decode struct (typed `SmallUser`) | 403 MB/s | **506 MB/s** | **+25.5 %** |
22-
| Decode struct · 1 MB formatted | 782 MB/s | **1286 MB/s** | **+64.5 %** |
23-
| Decode struct · 5 MB formatted | 745 MB/s | **1290 MB/s** | **+73.2 %** |
24-
| Decode struct · 10 MB formatted | 770 MB/s | **1268 MB/s** | **+64.7 %** |
25-
26-
**All 11 decode gates beat sonic by ≥ 13 %.** (Full encode scorecard is
27-
in [RESULTS.md](RESULTS.md); encode figures are unchanged from the prior
28-
commit series.) Memory footprint is a fraction of sonic's — the 10 MB
29-
interface{} bench allocates 23.7 MB vs sonic's 38.7 MB; the 10 MB struct
30-
bench allocates 9.1 MB vs sonic's 14.8 MB.
14+
| Decode `interface{}` · small.json | 218 MB/s | **262 MB/s** | **+20.2 %** |
15+
| Decode `interface{}` · twitter.json | 381 MB/s | **477 MB/s** | **+25.2 %** |
16+
| Decode `interface{}` · citm_catalog.json | 403 MB/s | **618 MB/s** | **+53.4 %** |
17+
| Decode `interface{}` · canada.json (float-heavy) | 198 MB/s | **322 MB/s** | **+62.6 %** |
18+
| Decode `interface{}` · 1 MB formatted | 581 MB/s | **731 MB/s** | **+25.8 %** |
19+
| Decode `interface{}` · 5 MB formatted | 506 MB/s | **667 MB/s** | **+31.8 %** |
20+
| Decode `interface{}` · 10 MB formatted | 598 MB/s | **660 MB/s** | **+10.4 %** |
21+
| Decode struct (typed `SmallUser`) | 344 MB/s | **516 MB/s** | **+50.0 %** |
22+
| Decode struct · 1 MB formatted | 797 MB/s | **1499 MB/s** | **+88.1 %** |
23+
| Decode struct · 5 MB formatted | 767 MB/s | **1372 MB/s** | **+78.9 %** |
24+
| Decode struct · 10 MB formatted | 787 MB/s | **1348 MB/s** | **+71.3 %** |
25+
26+
**All 11 decode gates beat sonic by ≥ 10 %.** Struct gates cluster at
27+
+50–88 %, interface{} gates at +10–63 %. (Encode figures are in
28+
[RESULTS.md](RESULTS.md); unchanged from the prior commit series.)
29+
Memory footprint is a fraction of sonic's — the 10 MB interface{} bench
30+
allocates 23.7 MB vs sonic's 38.7 MB; the 10 MB struct bench allocates
31+
9.1 MB vs sonic's 14.8 MB.
3132

3233
### ARM64 (Ampere Altra / Neoverse-N1)
3334

RESULTS.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,3 +311,47 @@ one AVX-512 comparator once you notice that the JSON grammar already
311311
rejects every stray byte ≤ 0x20 at the next token parse — so "byte >
312312
0x20" is a faithful non-WS predicate in structural position. Together
313313
they turned the 10 MB interface{} gate from noise-floor to +13 %.
314+
315+
## Phase 6 — second amd64 autoresearch sweep
316+
317+
A second profiling pass on struct decode after Phase 5 found two more
318+
wins and several deadends worth documenting (so the next round doesn't
319+
re-try them).
320+
321+
### Experiments
322+
323+
| # | Hypothesis | Result | Kept |
324+
|---|-----------|--------|------|
325+
| X6 | Raise `decodeString`'s SWAR-to-SIMD warmup 16 → 32 for BOTH the key path and the value path | Value path regressed: twitter bench has long free-text fields where SIMD still wins past 16 bytes. ||
326+
| X6b | Raise only `decodeStringRaw` (struct-key path) warmup 16 → 32 — struct keys almost never exceed 16 bytes, so SWAR beats the SIMD setup cost for all of them | Struct 1 MB formatted 1286 → 1369 MB/s median (+6.5 %); interface{} untouched because it uses `decodeString`. ||
327+
| X7 | Split `decodeString` into a tiny 8-byte fast path + `decodeStringSlow` so the caller can inline the one-SWAR-word hit-path | Regressed twitter/1 MB/5 MB interface{} 4–12 %: the split forces a function-call boundary for medium strings that previously stayed inside the SWAR loop. ||
328+
| X8 | Direct-load struct-key prefix from `&key[0]` and mask, skipping the `[8]byte + copy()` round-trip | Struct 1 MB formatted median 1369 → 1445 MB/s (+5.5 %). Key is always a subslice of d.data (or d.scratch), both with trailing bytes past the key. ||
329+
| X9 | Strip the 1-byte-space fast path from `skipWSFast` so it fits the inline budget and every d.skipWS() site gets inlined | Regressed 4 gates by 7–8 %: the 1-byte-space path catches the post-`:` separator on every struct field, and that dominates the function-call overhead that inlining would save. ||
330+
| X10 | Move the 1-byte-space fast path into `skipWSDeep` (keeping it, just out of line) so `skipWSFast` inlines. Verified: skipWSFast cost 77 ≤ 80, inlines at every site | Marginal and noise-bound — some gates +11 %, others within ±3 %. No reliable gain vs the safe state, so preserved the original structure to guarantee the "no-regression" rule. ||
331+
332+
### Phase-6 scorecard
333+
334+
5 s × 2 single-threaded on the same EPYC-Genoa host. Every gate still
335+
clears the ≥ 10 %-vs-sonic bar, and struct-decode gates moved from
336+
+64–73 % into the +71–88 % band — the 1 MB struct jumped 1286 → 1499
337+
MB/s, a ~17 % absolute improvement from X6b+X8 alone.
338+
339+
| gate | phase-5 | phase-6 | Δ (jsonx-self) | vs sonic (phase-6) |
340+
|------|--------:|--------:|---------------:|-------------------:|
341+
| Decode struct · small | 506 | 516 | **+2.0 %** | **+50.0 %** |
342+
| Decode struct · 1 MB formatted | 1286 | 1499 | **+16.6 %** | **+88.1 %** |
343+
| Decode struct · 5 MB formatted | 1290 | 1372 | **+6.4 %** | **+78.9 %** |
344+
| Decode struct · 10 MB formatted | 1268 | 1348 | **+6.3 %** | **+71.3 %** |
345+
| Decode interface{} · 10 MB formatted | 661 | 660 || **+10.4 %** |
346+
| (other interface{} gates) || within ±3 % of phase-5 | noise | +20 % to +63 % |
347+
348+
### Takeaway for round 2
349+
350+
After phase 5 closed every gate, the remaining wins came from the
351+
least-glamorous places: burning SWAR cycles instead of dispatching SIMD
352+
for short keys (X6b), and dropping a stack-scratch array copy that the
353+
compiler was generating for a 0–7-byte load (X8). The flashier ideas —
354+
inline-friendlier fast paths, decodeString splits — hit the
355+
no-regression rule, and a good half of round 2 was ruling them out
356+
rather than landing them. Recording the failures here because the next
357+
round is likely to be tempted by the same hypotheses.

0 commit comments

Comments
 (0)