Gunnar

nordisk/znippy

.nornir/zoomies-parallel-flows.md

# Parallel data-processing flows in znippy-zoomies
<!-- nornir:class: reasoning -->

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](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](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`
+ `TypedCodec` typed mode), the `Config`/`SlotFill` knobs, the carry-headroom
mechanism — is in [gatling.md](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 a `Vec<u8>` copy. The slot is held alive
  in the collector's `InFlight` record (`gatling.rs:182`) until every segment of
  the chunk is done.
- **Backpressure on pool exhaustion.** The reader allocates up to `ring_slots`
  and then **blocks** on `slot_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

![where to cut: the EASY case (pigz/BGZF flush markers in gzip, the 48-bit bzip2 block magic, the ZIP central directory) lands every segment boundary with one forward scan, so segments fan straight out to workers; the HARD case (a plain gzip stream with no markers) parallelizes the SPLIT itself — each core guesses a nominal offset, forward-searches up to 64 KiB for a stored-block header via find_next_block, confirms with probe_decode (decode ≥ 4 KiB or reject and scan on), N−1 cores find N−1 cuts via split_boundaries_parallel, then a two-pass window fixup decodes every segment with a zeroed 32 KiB window in parallel and cheaply re-stitches the LZ77 window sequentially; lgz tries a 3-tier fallback cheapest-first: concatenated members, speculative decode, then single-thread flate2](assets/zoomies_split.svg)

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 pattern `00 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 a `probe_decode` to 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_dir` parses the
  End-Of-Central-Directory record at the tail (`EOCD_SIG = 0x06054b50`,
  `lzip-parallel/src/central_dir.rs:12`) and `walk_cd_entries` (`:107`) yields
  one `EntryLocation` per file with its absolute `local_header_offset`. **No
  boundary search at all**, by design — and so **no speculative resync** in
  `lzip-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:

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

![parsing flows: OSM XML → PBF runs through gatling::run_typed — a Reader fills a 200 MiB slot with 32 MiB carry headroom over 6 slots, xml_split_typed finds the last complete element (find_safe_slot_end) and cuts N segments at top-level <node>/<way> starts (a .bz2 source produces bit-granular segment ranges instead), no-barrier workers decode+VTD-parse+PBF-encode each segment into a PbfSegment with left/right stubs, and the PbfTypedSink forwards blobs in strict stream order while stitching the element that straddles each cut from [prev_stub, left_stub] via flush_stub; separately, NDJSON record scanning uses rayon (NOT gatling): RFC 8259 makes every raw newline a record boundary so find_safe_split cuts on \n, scan_records_parallel scans each slice, and find_key probes fields zero-copy](assets/zoomies_parsing.svg)

### 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`) returns
  `Split<(usize, usize)>` (byte ranges). It calls `find_safe_slot_end`
  (`znippy-zoomies/src/xml.rs:217`) to locate the last complete element (via
  `memmem::rfind` on 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 a `PbfSegment` carrying the encoded blobs
  plus a `left_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, and `flush_stub` (`:521`) re-parses the element that
  straddles a cut by concatenating `[prev_stub, left_stub]`. `finish` flushes 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`) maps
  `lbzip2::chunk::split_chunk` block 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

![multi-core capability matrix — rows are decode situations, columns are znippy-zoomies vs competitor tools; znippy-zoomies is the only column that is multi-core in every row: zip/jar entries (unzip is single-thread), gzip flush-marked (pigz parallelizes, igzip/libdeflate are single-stream), arbitrary markerless gzip (nobody else parallelizes — our unique win via speculative resync), concatenated gzip members (others decode serially), and bzip2 any stream (lbzip2 C matches us, pbzip2 only parallelizes its own output)](assets/zoomies_multicore.svg)

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; `lgz` does it with the per-core speculative resync from
  [§Where to cut](#where-to-cut-easy-split-vs-hard-split). 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 `lbzip2` is a genuine peer — it also finds block magic and
  decodes every block in parallel, so it ticks the same box. `pbzip2` only
  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`, the `Lgz` arm): 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](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`**

<!-- nornir:gen:start:benches name=ljar -->
**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 |
<!-- nornir:gen:end:benches -->

**Parallel ZIP decode — `lzip-parallel` vs `unzip -p`**

<!-- nornir:gen:start:benches name=lzip -->
**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 |
<!-- nornir:gen:end:benches -->

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

<!-- nornir:gen:start:benches name=lbzip2 -->
**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 |
<!-- nornir:gen:end:benches -->

**Parallel gzip decode — `lgz` vs `pigz -dc`**

<!-- nornir:gen:start:benches name=lgz -->
**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 |
<!-- nornir:gen:end:benches -->

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 |