Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

End-to-end speed sprint: video training, LLM token fast path, honest re-measurement
of the ResNet e2e story on fresh hardware states, and platform/CI gaps.
of the ResNet e2e story on fresh hardware states, platform/CI gaps — and the
TBL-RAW pre-processed pipeline (decode once, mmap-serve every epoch).

### Added
- **TBL-RAW pre-processed pipeline** — the new efficiency frontier for
many-epoch training, portably (FFCV's idea without the .beton lock-in):
- `SampleFormat.RAW_U8` in TBL v2 (decoded RGB uint8 HWC training samples);
uncompressed uniform payloads are contiguous, so
`turboloader.tbl.open_raw_view` maps a whole file as one zero-copy
`(N, H, W, 3)` array.
- `preprocess_to_tbl(tar, tbl, image_size=N)` — one-time parallel
decode+resize through the C++ fast path (9,469 Imagenette images in 6 s
on an M4 Max), exact uint8 recovery from the [0,1] floats.
- `TblRawImageLoader` / `DataLoader('data.tbl')` — serves batches from the
mmap through ONE fused parallel SIMD pass (`normalize_u8_gather`: index
gather + u8 HWC → normalized f32 CHW, GIL released). Output is
**bit-identical** to `DataLoader(tar, ImageNetNormalize())` (tested with
`array_equal`). Full loader contract: (seed, epoch) determinism, resume,
drop_last, pinned ring, `meta['indices']`, serve-time `hflip_prob` (the
one aug this path supports — crop/color must be baked; `train_aug` on a
.tbl raises with guidance).
- Measured (M4 Max, Imagenette 160px, per-stage SUBPROCESSES — in-process
stage order measurably contaminated results — at both consumption levels):
raw serve **531k img/s produce / 89k np.sum-consumed**, prefetch mode
**98.6k consumed** (production overlaps the consumer), vs on-the-fly
32k/31k and `cache_decoded=True` 62k/60k with **8.8 GB** peak RSS vs
TBL-RAW's ~1 GB of evictable file-backed pages. e2e ResNet-18 on the 3090:
**TBL-RAW 3.64s epochs — the fastest input pipeline measured on this
benchmark** (TAR 3.76s, PyTorch 3.92s, pure-GPU floor 3.39s; hflip-only
recipe caveat documented). Background prefetch was ADDED because the first
e2e run was honestly SLOWER than TAR (4.51s — synchronous serve sat on the
training thread); the fix is documented alongside the failure. Other
honest numbers: LZ4 on decoded photos = 1.06x → RAW defaults
`compression=False`; the .tbl is larger than the source TAR (727 vs
263 MB) — you trade disk for decode.
- GPU-resident ingestion: `MetalResidentLoader('data.tbl')` (upload = one
memcpy into unified memory) and `CudaResidentLoader.from_tbl(...)`
(chunked upload through the mmap) — the decode-all pass disappears.
- `normalize_u8_batch` / `normalize_u8_gather` exported as standalone ops;
`benchmarks/benchmark_tbl_raw.py`; e2e benchmark `--tbl` flag; 24 tests.
- **`VideoDatasetLoader`** (CUDA): labeled clip batches from a DIRECTORY of videos —
ImageFolder-style `root/class_x/*.mp4` discovery, N PyAV decoder threads with
per-thread container caches and pts-derived seeking (counting fallback for
Expand Down
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ flowchart LR

- **Fast on CPU**: ~55k img/s on-the-fly (2.0× `tf.data`, 2.7× PyTorch DataLoader); trains a real ResNet-18 **1.05–1.17× faster end-to-end** (run-dependent), ~9% above the pure-GPU floor
- **Fast on GPU**: beats **NVIDIA DALI** on-the-fly (+12%, RTX 3090) and **FFCV** on pre-processed data (1.6–3.5×); ~757k img/s resident on Apple unified memory
- **Pre-processed pipeline (TBL-RAW)**: decode once, mmap-serve every epoch — **531k img/s raw serve on CPU**, beats the float32 RAM cache at every consumption level on ~1/9th the peak RSS, bit-identical batches, any hardware; **fastest e2e input pipeline we've measured** (3.64s epochs vs 3.76 TAR / 3.92 PyTorch, floor 3.39)
- **Video**: hardware decode to training batches — **3.9× the best industry standard** on Apple Silicon; CUDA `VideoDatasetLoader` trains a real video classifier **1.16× faster** than the PyTorch+PyAV recipe (first e2e video benchmark)
- **Train-ready**: fused `train_aug` (torchvision-parity RandomResizedCrop+flip), `state_dict()` mid-epoch resume, pinned-memory rings, DDP sharding
- **Also tokens & arrays**: memory-mapped `TokenDataLoader` (**1.9× nanoGPT `get_batch` to-device**, zero-alloc pinned ring, `device='cuda'` overlapped H2D), `ArrayDataLoader`, and `MapDataLoader` for any `__getitem__` dataset
Expand All @@ -48,9 +49,11 @@ flowchart TD
S --> ARR["📊 Arrays / tabular"]
S --> ANY["🐍 Anything with<br/>__getitem__"]

IMG --> Q1{"Fits in GPU / unified<br/>memory, many epochs?"}
Q1 -- yes --> RES["CudaResidentLoader · NVIDIA<br/>MetalResidentLoader · Apple"]
Q1 -- no --> Q2{"Where to decode?"}
IMG --> Q0{"Many epochs,<br/>resize+flip recipe OK?"}
Q0 -- "no (full random aug)" --> Q2{"Where to decode?"}
Q0 -- yes --> Q1{"Fits in GPU /<br/>unified memory?"}
Q1 -- yes --> RES["CudaResidentLoader · NVIDIA<br/>MetalResidentLoader · Apple<br/>(both ingest .tbl)"]
Q1 -- no --> TBL["preprocess_to_tbl once →<br/>DataLoader('data.tbl') · mmap"]
Q2 -- "CPU fast path (default)" --> DL["DataLoader(output_format='pytorch',<br/>image_size=N)"]
Q2 -- "NVIDIA GPU" --> CIL["CudaImageLoader(decode='nvimgcodec',<br/>return_indices=True)"]
VID --> QV{"Training on a labeled<br/>video dataset?"}
Expand All @@ -71,7 +74,8 @@ flowchart TD
| A TAR of JPEGs, training on any hardware | **`DataLoader(..., output_format='pytorch', image_size=N)`** | The default fast path — auto-fused C++ decode+resize+normalize. Start here. |
| The same, need per-sample dicts (inspection, irregular data) | `DataLoader(...)` (default `output_format='dict'`) | Several times slower; not for training loops. |
| Labels | derive from `meta['indices']` / `sample['filename']` | Samples carry **no** `label` key; align an external label array by index. |
| A dataset that fits in GPU/unified memory, many epochs | `CudaResidentLoader` (NVIDIA) / `MetalResidentLoader` (Apple) | Decode once, ~280k / 433–757k img/s per epoch. `return_indices=True` for labels. |
| A dataset that fits in GPU/unified memory, many epochs | `CudaResidentLoader` (NVIDIA) / `MetalResidentLoader` (Apple) | Decode once, ~280k / 433–757k img/s per epoch. `return_indices=True` for labels. Both ingest `.tbl`. |
| Many epochs, fixed resize(+hflip) recipe, any hardware | `preprocess_to_tbl` once → `DataLoader('data.tbl')` | mmap serve, zero decode, ~zero owned RAM; bit-identical to the TAR pipeline. No random crop — bake it or use the TAR path. |
| A pre-processed dataset larger than VRAM (NVIDIA) | `CudaStreamLoader` | Fully-C++ streaming, ~140k img/s. |
| On-the-fly GPU decode (NVIDIA) | `CudaImageLoader(decode='nvimgcodec', return_indices=True)` | Beats DALI; batches complete OUT of order — align labels via the returned indices. |
| On-the-fly GPU transforms (Apple) | `MetalImageLoader` (alias of `GpuImageLoader`) | Metal decode+transforms. |
Expand Down Expand Up @@ -145,6 +149,7 @@ caveats (and the corrections we published) in [docs/benchmarks](docs/benchmarks/
| Pre-processed, fits in VRAM | **~280k img/s** | FFCV ~80k (**3.5×**) | RTX 3090 |
| Pre-processed, streaming > VRAM | **~140k img/s** | FFCV ~85k (**1.6×**) | RTX 3090 |
| Pre-processed, unified memory | **433–757k img/s** | numpy resident ~3.7k | M4 Max |
| Pre-processed, CPU mmap (TBL-RAW, any hardware) | **531k img/s** raw serve (99k np.sum-consumed w/ prefetch) | float32 RAM cache 62k (60k) at ~9× the peak RSS | M4 Max |
| Video → training batches | **2,556 f/s (3.9×)** | OpenCV 657 · PyAV 535 · torchcodec 173 | M4 Max |
| End-to-end ResNet-18 training | **1.05–1.17×** vs PyTorch recipe | ~9% above the pure-GPU floor | RTX 3090 |
| End-to-end VIDEO training (r3d_18) | **1.16×** vs PyTorch+PyAV recipe | both decode-bound (honest) | RTX 3090 |
Expand Down
34 changes: 34 additions & 0 deletions benchmarks/E2E_TRAINING_RESULTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,37 @@ GPT-2 shape 32x1024, uint16 memmap corpus, delivered TO DEVICE, median of 5):
**1.9× the standard idiom** — one `seq_len+1` gather feeds both x and y (get_batch
gathers twice), zero steady-state allocations. `device=` adds ready-on-GPU tensors
with no lifetime rules at parity throughput.

## TBL-RAW pre-processed pipeline — the fastest input path measured here

Same ResNet-18/Imagenette-160 benchmark, fed from a pre-processed RAW `.tbl`
(`preprocess_to_tbl` once: ~7s on the 3090 box; serve = mmap + fused SIMD
normalize + background prefetch + pinned ring). Recipe caveat as with the
resident-loader section: random hflip only — RandomResizedCrop cannot apply to
pre-resized samples, so this is a lighter recipe than the augmented rows.

| Input pipeline | median epoch | end-to-end img/s |
|---|---:|---:|
| pure-CUDA floor | 3.39 s | — |
| **TBL-RAW** (`DataLoader('....tbl')`, hflip-only) | **3.64 s** | ~2,570 |
| TurboLoader TAR (full train_aug) | 3.76 s | ~2,490 |
| PyTorch DataLoader (full aug) | 3.92 s | ~2,385 |

**7.4% above the pure-GPU floor** — the closest any input pipeline has come on
this box. Engineering honesty log: the FIRST e2e run measured TBL-RAW at
4.51 s — SLOWER than TAR — because serving ran synchronously on the training
thread while the TAR pipeline produces in background C++ threads. The fix
(stop-aware background prefetch; SIMD ops release the GIL so production
overlaps the step) is what turned 0.87x into the fastest pipeline, and the
loader now defaults to it.

Loader-only on the same box (per-stage subprocesses; WSL2, modest CPU memory
bandwidth — the M4 Max numbers in docs/benchmarks/index.md are ~10x higher):
TBL-RAW sync 48.6k img/s produce / 26.1k np.sum-consumed vs on-the-fly
21.9k/21.6k and float32 cache 31.9k/27.6k (cache peak RSS 8.8 GB vs TBL's
1.1 GB of evictable file pages). Honest nuance: under the np.sum consumer — an
adversarial pure-CPU reader that HOLDS the GIL — the float32 cache edges
TBL-RAW on this bandwidth-poor box (27.6k vs 26.1k), yet in the real training
loop above TBL-RAW is the fastest pipeline: a GPU step releases the GIL, so
the serve work overlaps it. Pick by workload; both numbers are printed by the
same script.
60 changes: 60 additions & 0 deletions benchmarks/benchmark_e2e_training.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,43 @@ def bench_turboloader_prefetcher(tar_path, labels_path, epochs, batch_size, size
return times


def bench_turboloader_tbl(tbl_path, labels_path, epochs, batch_size, device):
"""Pre-processed TBL-RAW pipeline: mmap, zero decode. Recipe note: hflip only
(RandomResizedCrop cannot be applied to pre-resized samples) — the same
lighter-recipe caveat as the CudaResidentLoader comparison."""
import torch

import turboloader as tl

labels = np.load(labels_path)
loader = tl.TblRawImageLoader(
tbl_path,
batch_size=batch_size,
shuffle=True,
seed=0,
drop_last=True,
pin_memory=(device == "cuda"),
hflip_prob=0.5,
)
_model, step = make_model_and_step(device)
times = []
for ep in range(epochs):
loader.set_epoch(ep)
dev_sync(device)
t0 = time.perf_counter()
n, last = 0, 0.0
for x, meta in loader:
xb = (x if hasattr(x, "to") else torch.from_numpy(x)).to(device, non_blocking=True)
yb = torch.from_numpy(labels[np.asarray(meta["indices"])]).to(device, non_blocking=True)
last = step(xb, yb)
n += xb.shape[0]
dev_sync(device)
dt = time.perf_counter() - t0
times.append(dt)
print(f" [tbl-raw] epoch {ep}: {dt:.2f}s ({n / dt:.0f} img/s) loss {last:.3f}")
return times


def main():
ap = argparse.ArgumentParser()
ap.add_argument("--imagenette-dir", required=True)
Expand All @@ -208,6 +245,11 @@ def main():
action="store_true",
help="also measure the pure-GPU floor (same steps, resident batch)",
)
ap.add_argument(
"--tbl",
action="store_true",
help="also run the TBL-RAW pre-processed pipeline (hflip-only recipe)",
)
args = ap.parse_args()

import torch
Expand Down Expand Up @@ -252,6 +294,18 @@ def main():
f"{time.perf_counter() - t0:.2f}s"
)

t_tbl = None
if args.tbl:
import turboloader as _tl

tbl_path = os.path.join(args.imagenette_dir, f"imagenette_e2e_{args.size}.tbl")
if not os.path.exists(tbl_path):
t0 = time.perf_counter()
_tl.preprocess_to_tbl(tar_path, tbl_path, image_size=args.size)
print(f"preprocess_to_tbl (one-time): {time.perf_counter() - t0:.1f}s")
print("== TurboLoader TBL-RAW (mmap, zero decode; hflip-only recipe) ==")
t_tbl = bench_turboloader_tbl(tbl_path, labels_path, args.epochs, args.batch_size, device)

print("== TurboLoader (train_aug + pin_memory + prefetch) ==")
t_tl = bench_turboloader(tar_path, labels_path, args.epochs, args.batch_size, args.size, device)
t_pf = None
Expand All @@ -278,6 +332,12 @@ def main():
f"\nwith CudaPrefetcher: {med(s_pf):.2f}s | "
f"speedup vs pytorch {med(s_pt) / med(s_pf):.2f}x"
)
if t_tbl is not None:
s_tbl = t_tbl[1:] or t_tbl
line += (
f"\nTBL-RAW (hflip-only recipe): {med(s_tbl):.2f}s | "
f"speedup vs pytorch {med(s_pt) / med(s_tbl):.2f}x"
)
print(line)


Expand Down
Loading
Loading