Gunnar

nordisk/znippy-zoomies

.nornir/gatling-design.md

# Gatling — design

> **Class:** `design` · **Function:** `gatling` (the no-barrier engine).
> Strict: this file states *only the shape we ship*. The *why* (the rayon ban, the
> "1-ends cook" bug, the road here) lives in [`gatling-reasoning.md`](gatling-reasoning.md);
> the how-to is [`gatling-guide.md`](gatling-guide.md); the plain-words intro is
> [`gatling-for-idiots.md`](gatling-for-idiots.md).

![gatling variants](assets/gatling_variants.svg)

Source (leaf `gatling` crate, re-exported by `znippy-zoomies`): `gatling/src/gatling.rs`
(streaming), `gatling/src/gatling_forkjoin.rs` (fixed set), `gatling/src/gatling_io.rs`
(async I/O), `gatling/src/gatling_ordered.rs` (ordered item sink),
`gatling/src/parwrite.rs` (parallel writers).

## The shape every variant shares

One idea in several bodies:

1. **No barrier.** There is never a "submit all, then await all" stall. The instant a
   worker finishes a unit it claims the next — it does not wait for a slow sibling at a
   chunk boundary. An N-core box stays N-busy even when units cost wildly different
   amounts.
2. **Self-dispatch via one atomic.** A single shared `AtomicUsize` hands out the next
   index with `fetch_add` — no central scheduler, no per-unit channel send, the cheapest
   1→N hand-off.
3. **`std::thread::scope` substrate.** The sync engines are pure scoped threads; the
   async sibling is tokio tasks. Scoped threads let worker closures borrow caller stack
   data directly — no `'static`/`Arc` ceremony.
4. **Zero-copy, zero-contention output.** Each unit owns a **disjoint** slice of the
   output (a pixel row, an index slot, a byte range in a slot ring). Workers write in
   place — never sharing a cache line, never locking.

The reader only reads; the collector only stitches in order; **all** heavy work is in
the workers in the middle.

## Two families

### A. Streaming — work *arrives* a chunk at a time (`gatling.rs`)

`Reader → split → N workers → collector → sink`. The caller plugs in a **codec** (how
to split + transform) and a **sink** (how to consume); the engine carries no format
knowledge.

- **Byte mode** — `run` + `Codec`/`Sink`: workers emit raw bytes; the collector
  concatenates and cuts at a `Sink::safe_end` boundary (e.g. end of an XML element).
- **Typed mode** — `run_typed` + `TypedCodec`/`TypedSink`: `split` guarantees logical
  boundaries so workers emit finished records; the collector forwards each in order, no
  reassembly.

Tuned by `Config { chunk_size, carry_headroom, ring_slots, initial_carry, slot_fill }`.
`ring_slots` is the steady-state width; `SlotFill::Incremental` (default) faults only
the bytes read, `SlotFill::Big` pays the full slot fault once for planet-scale inputs.

### B. Fork-join — the work set is *known upfront* (`gatling_forkjoin.rs`)

No reader, no carry: a fixed `0..n` range (or a row grid) and N workers draining a
shared counter. The family you reach for when you already hold a `Vec`/grid/file list.

### C. Async I/O sibling — latency-bound fan-out (`gatling_io.rs`)

For I/O-bound work (PUT/GET/fsync) a blocked thread is wasted. `gatling::io` keeps every
invariant (no barrier, bounded N-in-flight, ordered output) but swaps the substrate to
≤ `max_in_flight` tokio futures. Payloads are `bytes::Bytes`, moved in and out, never
deep-copied.

### D. Parallel-writer — the writer itself fans out (`parwrite.rs`)

The families above (and the codecs built on them) funnel decoded output through **one**
collector + **one** writer thread — the last un-parallelised stage. `parwrite` removes
it: the same no-barrier, atomic-cursor, `std::thread::scope` pool, but **each worker
writes its own output** — no collector, no writer thread on the critical path. It is safe
without any ordering because the output units are provably **disjoint**:

- **Independent files** — `extract_entries_unordered`: each worker `pread`s its entry's
  compressed bytes from the shared input (thread-safe positional reads, no shared file
  offset), decodes, and writes its **own** output file (ZIP/JAR members are independent).
- **Independent regions of one stream** — `write_segments_positional` (decode then
  `pwrite`) / `write_slices_positional` (already-decoded slices, `pwrite`): the caller
  pre-computes each segment's offset (prefix-sum of known output sizes), pre-sizes the
  file, and each worker `pwrite`s its region at its offset. Positional writes to disjoint
  regions never conflict, so exact stream byte-order holds with **no** ordered collector.

Each worker reuses its scratch buffers across the units it claims (zero-alloc hot loop),
never copies input into the pool, and is rayon-free. This is the family a codec reaches
for when output sizes are known up front (a central directory, or a prior decode pass) and
ordering can be dropped — see the writer story in
[`gatling-reasoning.md`](gatling-reasoning.md).

![the writer lift — one ordered writer vs parallel offset-pwrite](assets/writer_pipeline.svg)

## The variants

| Variant | Shape | Use when |
|---|---|---|
| `gatling::run` | streaming, byte mode, reassembled | bytes arrive a chunk at a time, output reassembled at boundaries (bz2/gz → XML) |
| `gatling::run_typed` | streaming, typed mode, in-order records | workers produce finished records, `split` cuts logical boundaries |
| `gatling::io::run` / `run_ordered` | async, latency-bound | I/O fan-out over ready payloads; `run` = fastest-first, `run_ordered` = submission order |
| `gatling::ordered::run_ordered_sink` | item-oriented, ordered | caller yields `(Label, Input)`, N workers map, sink emits in producer order through a bounded reorder buffer |
| `gatling_forkjoin::gatling_for_each` | fixed `0..n` → `Vec<T>`, index order | a fixed set of independent CPU units, results back |
| `gatling_forkjoin::gatling_run` | fixed `0..n`, side-effects only | each worker writes into per-`i` state the caller owns |
| `gatling_forkjoin::gatling_map_owned` | owned `Vec<T>` consumed → `Vec<R>`, input order | per-item payload owned and possibly `!Sync` |
| `gatling_forkjoin::gatling_for_each_balanced` | `0..n`, **LPT + batched** → `Vec<T>` | unit costs heavily skewed — schedule heaviest-first, claim in batches |
| `gatling_forkjoin::gatling_map_balanced` | owned `&[T]`, **LPT + batched**, `f(i, &item)` → `Vec<R>` | skewed work you map by reference then group-by-key |
| `gatling_forkjoin::gatling_scanlines` | 2D `rows × stride` buffer | each row independent, writes a disjoint slice (rendering) |
| `parwrite::extract_entries_unordered` | N workers `pread` + decode + **write own file** | independent archive entries (ZIP/JAR members) → separate output files, no ordering |
| `parwrite::write_segments_positional` | N workers decode → `pwrite` at offset | independent regions of one pre-sized stream, offsets known (prefix-sum), exact byte order |
| `parwrite::write_slices_positional` | N workers `pwrite` ready slices at offsets | segments already decoded (prior pass), zero-copy parallel `pwrite` into one stream |

### The balanced bodies (skew killers)

`gatling_for_each_balanced(n, n_workers, batch, weight, f)` adds two refinements while
keeping `0..n` output order: a **heaviest-first (LPT)** schedule from a caller-supplied
`weight(i)` (e.g. file byte size), and **batched self-dispatch** (claim `batch` indices
per atomic `fetch_add`; `batch == 0` ⇒ `1`). `gatling_map_balanced(items, n_workers,
batch, size_of, f)` is its sibling for an owned `Vec` of heterogeneous tags: `size_of(&
item)` and `f(i, &item)` both read one borrow (no external re-index), results land in
disjoint output slots in original order, ready to group-by-key. First real consumer:
nornir's member-scan parsing every `.rs` across all cores scheduled by byte size.

## Whereabouts map — where every piece lives

Every engine variant, codec, and the law test, with a `file:line` you can jump to. The
engine lives in the leaf **`gatling`** crate; the codecs are workspace members; all are
re-exported / path-depended by the root `znippy-zoomies` crate.

| Thing | Crate | file:line |
|---|---|---|
| streaming byte engine `run` | gatling | `gatling/src/gatling.rs:328` |
| streaming typed engine `run_typed` | gatling | `gatling/src/gatling.rs:622` |
| `Codec` / `Sink` traits | gatling | `gatling/src/gatling.rs:68` / `:83` |
| `TypedCodec` / `TypedSink` traits | gatling | `gatling/src/gatling.rs:99` / `:127` |
| `Config` (chunk_size, ring_slots, slot_fill…) | gatling | `gatling/src/gatling.rs:156` |
| `gatling_for_each` | gatling | `gatling/src/gatling_forkjoin.rs:60` |
| `gatling_run` | gatling | `gatling/src/gatling_forkjoin.rs:147` |
| `gatling_for_each_balanced` (LPT + batch) | gatling | `gatling/src/gatling_forkjoin.rs:209` |
| `gatling_map_balanced` (item-ref LPT) | gatling | `gatling/src/gatling_forkjoin.rs:318` |
| `gatling_map_owned` (consumes `Vec<T>`) | gatling | `gatling/src/gatling_forkjoin.rs:409` |
| `gatling_scanlines` (2D rows) | gatling | `gatling/src/gatling_forkjoin.rs:490` |
| async `io::run` / `io::run_ordered` | gatling | `gatling/src/gatling_io.rs:95` / `:147` |
| `ordered::run_ordered_sink` / `OrderedSink` | gatling | `gatling/src/gatling_ordered.rs:113` / `:77` |
| `chunk_revolver` slot pool (`ChunkRevolver`, `get_chunk_slice`) | gatling | `gatling/src/revolver/{mod,revolver,ring}.rs` |
| `chunk_revolver` coalescing mode (`with_coalescing`, `Filler`, `PackedSlice`, `Returner`) | gatling | `gatling/src/revolver/coalesce.rs:255,351,66,143` |
| `parwrite::extract_entries_unordered` (independent files) | gatling | `gatling/src/parwrite.rs:85` |
| `parwrite::write_segments_positional` (decode → pwrite) | gatling | `gatling/src/parwrite.rs:191` |
| `parwrite::write_slices_positional` (ready slices → pwrite) | gatling | `gatling/src/parwrite.rs:263` |
| `parwrite::EntryJob` / `WriteStats` | gatling | `gatling/src/parwrite.rs:57` / `:47` |
| lgz entry `decompress_tar_gz_filter`; split | lgz | `lgz/src/lib.rs:63`; `chunk.rs:29`; `deflate_scan.rs:92` |
| lbzip2 block scan; split | lbzip2 | `lbzip2/src/block_scan.rs:85` (all-blocks), `:221` (split); `chunk.rs:35` |
| ljar entry `decompress_jar`; gatling fan; split | ljar | `ljar/src/lib.rs:65`; `reader.rs:122`; `deflate_scan.rs:16` |
| lzip-parallel entry `decompress_zip`; gatling fan; split | lzip-parallel | `lzip-parallel/src/lib.rs:58`; `reader.rs:172`; `deflate_scan.rs:16` |
| linflate DEFLATE core `inflate_segment*` / `inflate_to_vec` | linflate | `linflate/src/lib.rs:81,93,108,206` |
| linflate flush-boundary SIMD scanner (`FlushBoundary`, `find_next_flush`, `find_all_flushes`) | linflate | `linflate/src/deflate_scan.rs:25,57,201` |
| the law test (`whole_workspace_is_rayon_free`, `workspace_src_roots`) | root | `tests/rayon_free_law.rs:127,52` |

`parwrite` (the writer-lift) is landed on `master` alongside every other variant.

## Gatling-adjacent primitives (same crate, same no-rayon law)

- **`chunk_revolver`** — the zero-allocation slot pool behind the streaming engine
  ([`chunk_revolver-design.md`](chunk_revolver-design.md)).
- **`psort`** — parallel sample sort over 16-byte AoS records keyed by a leading `i64`.
- **`stree`/`stree32`** — the AVX2 static search tree ([`stree-design.md`](stree-design.md)).
- **`xml`/`vtd`/`json`** — zero-copy parallel record scanners; all three parallelize
  through `gatling_forkjoin::gatling_for_each`.