fused-decompress-parse · git:20260613.c30c40a · 2026-06-13 · sha256 484f1f935489b32a
fused-decompress-parse git:20260613.c30c40aA
Immutable. This exact content is served forever at /api/v1/blob/484f1f935489b32a.
--- name: fused-decompress-parse description: How to ingest compressed RDF (gzip / zstd / bzip2) fast in Rust by FUSING decompression with the parallel parse instead of decompress-to-memory-then-parse, and which codec to choose. Use when working on sparq-core's compressed ingest (load_reader_parallel, build_external_ntriples_parallel, sparq-cli's open_reader), choosing gzip vs zstd vs bzip2, deciding whether to build a parallel decompressor, or designing the client decode matrix (Solid/JS). Grounded in this project's MEASURED ~13× streaming-vs-ideal finding. --- # Fused decompress + parse for RDF ingest [OPUS-4.8] Authored from measured research. Source of truth: `research/custom-parsers-baseline.md` (the "Compressed ingest" section + the post-fix measurements) and `research/fast-ingestion.md`. Verify before quoting. ## The headline finding (and the bug it exposed) A naïve **streaming** ingest (decoder → parser, one parse round per `read()`) was measured **3.5–5× SLOWER than just decompressing to RAM first** — the opposite of what streaming should cost. Two compounding causes, both fixed by reusing code that already existed: 1. **Per-`read()` flush bug.** `Graph::load_reader_parallel` flushed a parse+sharded-merge round on *every* `read()` call, and decompressors return small reads (gzip ~0.38 MB/read, zstd ~1.6 MB/read) into its 32 MiB buffer. The parallel parser+merge machinery is amortised for ~32 MiB blocks, not 0.4 MB ones. **Fix: a producer thread that fills the full block across `read()` calls** before handing it to the parser. 2. **No pipelining.** Decode and parse ran additively. **Fix: run decode on its own thread feeding a bounded channel**, so it overlaps the rayon parse + dict merge — the exact 3-stage pipeline that already existed in `build_external_ntriples_parallel` (sparq-core `lib.rs`) and just wasn't used by the in-memory path. After both fixes (same machine/harness): gzip streaming **5.589 → 0.661 s (8.5×)**, zstd streaming **3.947 → 0.576 s (6.9×)**, and streaming now **matches or beats two-stage in every paired run** while never materialising the decompressed copy. ## The fusion bound — what "ideal" means Ideal pipelined ingest = `max(decode, parse+build)`, because a perfect pipeline hides the smaller stage under the larger: - gzip: `max(0.396, 0.296) ≈ 0.40 s` - zstd: `max(0.140, 0.296) ≈ 0.30 s` vs the old streaming 5.59 / 3.95 s = **~13–14× available** and two-stage 1.12 / 1.14 s = 2.8–3.8×. The lesson: **most of the cost was plumbing, not parsing.** A cooler run landed zstd streaming at 0.359 s — inside the ideal band. Note the residual ~1.3–1.5× gap over ideal is the parts that *cannot* overlap the decode: the final `finish_sharded` remap + the 6-permutation sort after the last block. Thermal state on a fanless M1 moves every absolute number; the *ratio* (streaming ≥ two-stage) was stable across all runs. ## Codec choice — measured, not assumed - **zstd beats gzip on both axes.** On the real slice: zstd −3 = 12.6× ratio and decodes at **1,236 MB/s**; gzip −6 = 11.7× and **438 MB/s** (zstd decode is 2.8× faster *and* compresses smaller). Prefer zstd for sparq-controlled paths. - **bzip2 is the real enemy.** The actual Wikidata "truthy" dump is `.bz2` (42.8 GB → 1.08 TB, ~25× ratio). Single-stream `bzcat` = **123 MB/s ≈ 1.06 M/s** → ~147 min for the full file regardless of downstream speed; it was 70% of E2E wall-time. **Verdict (stands): recompress `.bz2` → `.zst` once** (`zstd -9 -T0`, parallel, one-time) rather than building a parallel bzip2 decoder. After that, ingest is parse/sort-bound, projected ~30 min for 9.4 B triples — RDFox-competitive (their 24 min). ## When parallel DECOMPRESSION is (and isn't) worth it **Usually not.** Single-thread zstd decode (1,236 MB/s) already **outruns the full 8-thread parse+build (585 MB/s)**, and gzip decode (438 MB/s) is roughly at parity. So block-parallel decompression (multi-frame zstd / multi-member gzip / `lbzip2`-style block-parallel bzip2) buys nothing once decode is hidden under parse by the pipeline above. It matters **only** for bzip2 sources — and even there the recompress-to-zstd verdict wins over a parallel bzip2 decoder. Don't build a parallel decompressor before proving decode is the binding stage. ## Implementation notes that bit this codebase - The producer thread requires `R: Read + Send`; `sparq-cli`'s `open_reader` returns `Box<dyn Read + Send>` for this reason. - Two-stage carries ~0.43 s of pure materialisation overhead (allocating + faulting a fresh decompressed buffer and re-validating UTF-8) that a fused path handing newline-aligned blocks straight to the parser avoids. - Regression cover is mandatory: short reads, mid-line read boundaries, EOF without trailing newline, empty input, parse-error propagation (`load_reader_parallel_short_reads_match_sequential`). - gzip uses `flate2` `MultiGzDecoder` (multi-member aware); zstd via the `zstd` crate. Magic-byte sniffing (not file extension) is the project convention for detecting compression (see also `sparq-hdt` `.hdt.gz` detection). ## Client decode matrix (Solid / JS) — design note zstd's value does **not** hinge on browser-native `Content-Encoding`: Solid apps decode zstd in JS (fzstd / zstd-wasm), unlocking the full feature set including **custom-vocabulary dictionaries**. Consumer matrix: - Browser native: gzip (universal), br, zstd where shipped (Chrome 123+; verify Firefox/Safari current status). - Browser JS-level (Solid apps): zstd via JS/WASM decoder — dictionaries usable. - Server-to-server (prod-solid-server → sparq sidecar): native zstd both sides, dictionaries trivially shareable. A custom-dictionary protocol needs a way for clients to OBTAIN the dict (e.g. a `/dictionary` endpoint keyed by dataset generation; dictionary id echoed in a response header). Design-level only. See `research/custom-parsers-ADDENDUM-zstd-js-clients.md`. Reproduce: `bench/parse/README.md`.