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
-
TypedCodectyped mode), theConfig/SlotFillknobs, the carry-headroom mechanism — is in gatling.md. The two facts that matter for every flow below: -
Zero-copy. Inter-thread messages are a slot pointer +
(chunk_id, seg_id)(WorkItem,gatling.rs:163), never aVec<u8>copy. The slot is held alive in the collector'sInFlightrecord (gatling.rs:182) until every segment of the chunk is done. -
Backpressure on pool exhaustion. The reader allocates up to
ring_slotsand then blocks onslot_return_rx.recv()(gatling.rs:324) until the collector recycles one. A fast reader cannot outrun slow workers.
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:
- gzip with flush markers (pigz
-i, BGZF): a sync-flush emits an empty stored block — the byte pattern00 00 FF FF(LEN=0x0000, NLEN=0xFFFF).deflate_scan::find_next_flush(lgz/src/deflate_scan.rs:54, SIMD AVX512 → AVX2 → scalar) scans for it;find_next_valid_flush(:187) confirms each candidate with aprobe_decodeto reject a coincidental pattern. - bzip2 — boundaries are always present. Every block starts with the 48-bit magic
0x314159265359(the digits of π,lbzip2/src/lib.rs:28).block_scan::find_next_block(lbzip2/src/block_scan.rs:37) searches for it at the bit level (bzip2 blocks are not byte-aligned): a 64-bit big-endian sliding window, 16 candidate bit-shifts checked per 2-byte step. So bzip2 never needs speculation — the magic is the boundary.quick_verify_block(:157) reads ~73 header bits to drop false positives. - zip — the container already lists every entry.
central_dirparses the End-Of-Central-Directory record at the tail (EOCD_SIG = 0x06054b50,lzip-parallel/src/central_dir.rs:12) andwalk_cd_entries(:107) yields oneEntryLocationper file with its absolutelocal_header_offset. No boundary search at all, by design — and so no speculative resync inlzip-parallel, deliberately.
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:
- 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 toFORWARD_SEARCH_LIMIT = 64 * 1024bytes (:39). The split itself now runs on N cores. - Probe-decode confirm. A candidate offset is only accepted if
probe_decode(:131) actually decodes at leastPROBE_THRESHOLD = 4096bytes (:32) from it (up toPROBE_LIMIT = 32768,:35) vialinflate::inflate_segment. Garbage candidates are rejected and the scan continues; cuts closer than the threshold apart are deduplicated. -
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.
- Pass 1 (parallel): decode every segment with a zeroed
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:
decode_concatenated_members(speculative.rs:407) — multiple gzip members concatenated (1f 8b 08headers). Each member is fully independent, so this is embarrassingly parallel and needs no window stitching. ReturnsNoneif only one member is found.speculative_decode(speculative.rs:256) — the per-core resync above. Works on any gzip stream; the universal-but-slower path.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:
- split —
xml_split_typed(osm-katana/src/xml_reader.rs:98) returnsSplit<(usize, usize)>(byte ranges). It callsfind_safe_slot_end(znippy-zoomies/src/xml.rs:217) to locate the last complete element (viamemmem::rfindon closing tags like</way>and/>for self-closing elements), then divides the consumed region into N candidate segments, each snapped forward to a top-level element start (find_top_level_start,:191). The straddling tail past the last complete element becomes gatling's carry, prepended to the next slot. - transform —
encode_decoded_to_pbf(xml_to_pbf.rs:166) VTD-parses the middle and PBF-encodes it, returning aPbfSegmentcarrying the encoded blobs plus aleft_stub/right_stub(the partial elements at the segment edges). - sink —
PbfTypedSink(xml_to_pbf.rs:497) forwards each segment's blobs in strict stream order, andflush_stub(:521) re-parses the element that straddles a cut by concatenating[prev_stub, left_stub].finishflushes the trailing stub.
Source codecs differ only in the split:
- Raw XML —
RawPbfTypedCodec(xml_to_pbf.rs:395), byte-range segments. - bzip2 XML —
Bz2PbfTypedCodec(xml_to_pbf.rs:465) mapslbzip2::chunk::split_chunkblock boundaries to bit-granular(start_bit, end_bit)segments, so decode parallelizes across bzip2 block boundaries; the decoded XML then flows through the same boundary-trimming. - gzip XML —
gz_split(xml_to_pbf.rs:593) uses lgz flush boundaries.
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):
- The unique wins are the two markerless gzip rows — arbitrary, no markers and concatenated members. No mainstream gzip tool parallelizes a hostile, hint-free stream;
lgzdoes it with the per-core speculative resync from §Where to cut. This is the differentiator. - On zip/jar the win is over stock
unzip(single-thread); the capability itself (parallel across central-directory entries) is not exotic, but it is ours by default. - On bzip2, the C
lbzip2is a genuine peer — it also finds block magic and decodes every block in parallel, so it ticks the same box.pbzip2only parallelizes files it compressed (multi-stream). We match the best-in-class here rather than beating it. - igzip (ISA-L) is faster per core, but on a single stream — it does not multi-core a decode at all. That is the GB/s-vs-scaling distinction recorded in the bencher (
xtask/examples/nornir-bench.rs, theLgzarm): igzip owns raw single-stream throughput; znippy-zoomies owns scaling the unscalable input.
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 |