Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/znippy

.nornir/zoomies-parallel-flows.md

Parallel data-processing flows in znippy-zoomies

The parallel decoders (lgz, lzip-parallel, lbzip2, ljar) and the streaming parsers (OSM XML → PBF, NDJSON) all live in znippy-zoomies, and they all share one idea: a no-barrier slot-pool stream — fill a slot, find the cut points, fan the segments out to N persistent workers with no per-chunk join, reassemble in stream order. That idea is the gatling engine (gatling.md); some flows call the engine directly, others hand-roll the same ring-slot pipeline. The interesting part is the split: sometimes the boundaries are markers in the bitstream (a cheap forward scan); sometimes there are none, and the split itself has to run in parallel — speculative resync. Source: znippy-zoomies/{lgz,lzip-parallel,lbzip2}/, znippy-zoomies/src/gatling.rs, znippy-zoomies/src/json.rs, and znippy-zoomies/osm-katana/src/{xml_to_pbf,xml_reader}.rs. The shared inflate core under all the gzip/zip/jar decoders is its own chapter: inflate.md.

The engine they ride: gatling

gatling (znippy-zoomies/src/gatling.rs) is the substrate. A Read source is cut into segments held in a bounded, zero-copy slot ring; N worker threads transform each segment with no cross-worker barrier; an ordered collector reassembles by (chunk_id, seg_id) and flushes strictly in stream order. The full design — the two modes (run + Codec byte mode vs run_typed

The decoder binaries (lgz, lbzip2) re-implement this exact ring inline — same RING_SLOTS / CARRY_HEADROOM / SLOT_SIZE idiom (lgz/src/bin/lgz.rs:42) — so they can fold gzip-header parsing and flush scanning straight into the pipeline. The library decode entry points (decompress_gz, decompress_zip, lbzip2::parallel::decompress_parallel) use scoped-thread / rayon par_map over the discovered segments. The gatling engine module itself is consumed today by osm-katana's XML→PBF and PBF paths (typed mode). Same shape everywhere; the engine is the canonical expression of it.

Where to cut: easy split vs hard split

Every parallel decoder's hard problem is the same: a compressed stream is one long entropy-coded run, and you cannot start decoding in the middle unless you know where an independently-decodable unit begins. There are two regimes.

EASY — the boundaries are hints in the bitstream. A single forward scan finds them and the split is cheap:

In the easy regime each segment is independently decodable, so the split fans straight out to the workers with no window stitching.

HARD — no markers (a plain gzip stream). This is where the split becomes a parallel computation in its own right. lgz's speculative.rs is a pugz-style resync:

  1. Per-core forward search. split_boundaries_parallel (lgz/src/speculative.rs:159) launches N−1 scoped-thread tasks, each starting from a nominal offset and forward-searching for a DEFLATE stored-block header (find_next_block → find_next_stored_block_end, :107/:75) up to FORWARD_SEARCH_LIMIT = 64 * 1024 bytes (:39). The split itself now runs on N cores.
  2. Probe-decode confirm. A candidate offset is only accepted if probe_decode (:131) actually decodes at least PROBE_THRESHOLD = 4096 bytes (:32) from it (up to PROBE_LIMIT = 32768, :35) via linflate::inflate_segment. Garbage candidates are rejected and the scan continues; cuts closer than the threshold apart are deduplicated.
  3. Two-pass window fixup. Segments after the first have back-references that point into the previous segment's output — across the cut. speculative_decode (:256) handles this in two passes:

    • Pass 1 (parallel): decode every segment with a zeroed LZ77_WINDOW = 32 * 1024 (:43) window. Back-refs over the cut resolve to garbage, but the output lengths are correct.
    • Pass 2 (cheap, sequential): for each segment, re-decode only the first ≤ 32 KiB with the correct window — the last 32 KiB of the previous segment's real output (decode_with_window_limited, :327). Only ~32 KiB × N bytes are touched sequentially; the bulk stayed parallel.

lgz's 3-tier fallback

lgz's binary (lgz/src/bin/lgz.rs) tries strategies cheapest-first, falling through only when the one above returns None:

  1. decode_concatenated_members (speculative.rs:407) — multiple gzip members concatenated (1f 8b 08 headers). Each member is fully independent, so this is embarrassingly parallel and needs no window stitching. Returns None if only one member is found.
  2. speculative_decode (speculative.rs:256) — the per-core resync above. Works on any gzip stream; the universal-but-slower path.
  3. flate2::read::GzDecoder — single-threaded, always correct. The guaranteed floor when no parallel split is possible.

The library decompress_gz (lgz/src/parallel.rs:96) takes the flush-marker fast path first (chunk::decode_chunk, only above PARALLEL_THRESHOLD = 4 MB, lib.rs:42) and falls back to single-stream flate2 when no flush boundary exists.

Parsing flows: XML through gatling, JSON through rayon

OSM XML → PBF — gatling::run_typed (typed mode)

The XML→PBF path (osm-katana/src/xml_to_pbf.rs) is the canonical gatling engine consumer and the engine's original extraction site (it was lifted out of xml_to_pbf_bz2). It uses typed mode because each worker produces a fully-processed PbfSegment, not raw bytes needing reassembly:

Source codecs differ only in the split:

The same engine also drives the PBF path: pbf_split (xml_to_pbf.rs:696) cuts at OSMData blob frames.

NDJSON — rayon, not gatling

NDJSON record scanning (znippy-zoomies/src/json.rs) is not gatling-driven, and that is the right call. RFC 8259 §7 makes an unescaped newline illegal inside a JSON string, so every raw \n is a record boundary — there is no escape-state machine needed to split, and no straddling unit to stitch. find_safe_split (json.rs:55) cuts on the next \n; scan_records_parallel (json.rs:122) fans the \n-bounded slices out with rayon into_par_iter; find_key (json.rs:266) probes a record's fields with a zero-copy, string-aware depth scan, never fully parsing.

The contrast is the point of this chapter: gatling earns its keep when a logical unit can straddle a chunk edge (an XML element split across a 200 MiB slot boundary → carry + stub stitching). NDJSON records are independent, uniform, and self-delimiting — no carry, no stream-order reassembly, no stub — so plain rayon over \n-cut slices is simpler and just as fast.

Multi-core scoreboard: znippy-zoomies vs the field

The point of the whole chapter, in one table: znippy-zoomies parallelizes every format in every situation — including the ones where the input gives no help. The mainstream tools only light up all cores when the producer left them markers (pigz -i, BGZF) or when the container lists the offsets (zip).

situation znippy-zoomies pigz igzip / libdeflate / zlib unzip lbzip2(C) / pbzip2
zip / jar — many entries lzip-parallel / ljar ✅ — — ✗ single-thread —
gzip — flush-marked (pigz -i, BGZF) lgz ✅ ✅ ✗ single-stream — —
gzip — arbitrary, no markers lgz ✅ (speculative resync) ✗ falls back to 1 core ✗ single-stream — —
gzip — concatenated members lgz ✅ ✗ serial ✗ serial — —
bzip2 — any stream lbzip2 ✅ — — — lbzip2(C) ✅ · pbzip2 ◐ own-output only

✅ multi-core · ◐ only parallelizes its own output · ✗ single-thread · — format not handled

Reading the matrix honestly (so the claim survives scrutiny):

So the column to point at is znippy-zoomies: every row ticked, the only one with no gaps.

Benchmarks: the scoreboard, measured

The scoreboard above is a capability claim; these are the numbers behind it. They are not hand-written — the tables are expanded from the nornir warehouse at render time (the same nornir:gen:benches markers that feed the README; see benching.md for why there is exactly one bench source of truth). Every figure is MB/s, single-shot on a real corpus (guava / scala jars, recompressed per codec), threadripper-3975wx · 32 cores. Rows ending _st are the single-threaded baseline (one worker) — the gap between a workload and its _st twin is the multi-core scaling.

Parallel JAR decode — ljar vs unzip -p

v0.9.6 · oden · 32 cores · 2026-07-22

workload ljar_mbs speedup_x unzip_mbs
ljar_guava 1063 18.54 57.36
ljar_scala 1357 22.93 59.19

Parallel ZIP decode — lzip-parallel vs unzip -p

v0.9.6 · oden · 32 cores · 2026-07-22

workload lzip_mbs speedup_x unzip_mbs
lzip_guava 1266 22.95 55.13
lzip_scala 1747 31.19 56

Parallel bzip2 decode — lbzip2 vs C lbzip2 (1995-lineage bzip2)

v0.9.6 · oden · 32 cores · 2026-07-22

workload lbzip2_c_mbs lbzip2_mbs speedup_x
lbzip2_guava 74.70 60.78 0.81
lbzip2_scala 134.73 86.07 0.64

Parallel gzip decode — lgz vs pigz -dc

v0.9.6 · oden · 32 cores · 2026-07-22

workload lgz_mbs pigz_mbs speedup_x
lgz_guava 334.32 274.19 1.22
lgz_scala 314.08 293.68 1.07

Reading them against the scoreboard: ljar/lzip show the ~20–30× win over single-thread unzip; lgz beats pigz even single-threaded (_st) and scales from there; lbzip2 edges the C lbzip2 it matches on capability. The one number that isn't here is igzip — it has no parallel-decode row to report, which is exactly the point made at the Lgz arm of the bencher.

Map

flow crate split signal speculative? parallelism
gzip lgz 00 00 FF FF flush marker, else resync yes (no markers) ring-slot / scoped-thread
zip lzip-parallel central directory (every offset listed) no (by design) rayon pool
bzip2 lbzip2 48-bit block magic 0x314159265359 at any bit no (magic suffices) ring-slot / scoped-thread
OSM XML → PBF osm-katana last complete element / top-level start n/a gatling run_typed
NDJSON znippy-zoomies raw \n (RFC 8259) n/a rayon