discombobulating

dataloaders  part 1

fafo exercise 1 : dumbest dataloader

If you load your samples in the Dataset on CPU and would like to push it during training to the GPU, you can speed up the host to device transfer by enabling pin_memory. This lets your DataLoader allocate the samples in page-locked memory, which speeds-up the transfer.

With this option to false, every time your code hits a line line for sample in dataloader:, it will create a brand new set of workers to do this loading and will kill them on exit. Meaning that if you have multiple dataloaders, the workers will be killed when you are done with one instantly. If you make them persist, these workers will stay around (with their state) waiting for another call into that dataloader. Setting this to True will improve performances when you call into the dataloader multiple times in a row (as creating the workers is expensive). But it also means that the dataloader will have some persistent state even when it is not used (which can use some RAM depending on your dataset).

*inject artifical I/O latency into getitem redo the num_workers sweep

Dataloader FAFO — Phase 1 Worklog

Date: 2026-07-03 Setup: Fedora, 8 logical CPU cores, CPU-only run (no GPU in this pass) Source video: test.mp4 — 1 min clip, 2054 frames extracted at native fps Dataset: naive Dataset — reads jpg via cv2.imread, resizes to 224x224, no other transforms


1. Baseline (num_workers=0)

total=6.00s | per_batch=60.04ms

Single-process loading: main process does read → decode → resize → tensor conversion, one sample at a time, no overlap with anything. This is the number every other optimization is measured against.


2. num_workers sweep — no artificial I/O delay

num_workers total (100 batches) per_batch
0 5.50s 55.03ms
2 3.62s 36.20ms
4 2.29s 22.87ms
8 2.27s 22.68ms
16 2.50s 25.05ms

Reading: Gains scale cleanly 0→4, then flatline at 4→8, then regress at 16.

Why: the machine has 8 logical cores. Once worker count approaches/exceeds that, workers start competing for CPU time instead of doing independent parallel work — context-switching overhead outweighs any benefit. PyTorch's own warning confirmed this directly: "suggested max number of workers in current system is 8."

Takeaway: __getitem__ here (imread + resize) is CPU-bound, not I/O-bound. In a CPU-bound regime, worker count has a hard ceiling around your core count — more workers past that point is pure waste, not "free" parallelism.


3. pin_memory on/off

pin_memory total per_batch
False 2.59s 25.90ms
True 2.79s 27.86ms

Reading: no benefit — actually marginally slower (noise-level difference, not meaningful on its own).

Why: pin_memory speeds up host→GPU transfer. No GPU was available in this run (torch.cuda.is_available() == False), so there's no transfer to speed up — PyTorch printed this directly: "pin_memory argument is set as true but no accelerator is found."

Takeaway: pin_memory=True is not a free "always on" setting — it's conditional on actually having a device to transfer to. Re-test once GPU driver issue is resolved.


4. persistent_workers on/off (3 epochs)

persistent_workers epoch 0 epoch 1 epoch 2 total
False 2.50s 2.04s 2.27s 6.81s
True 1.99s 1.98s 2.35s 6.32s

Reading: small win (~7%), not dramatic at this dataset size/complexity.

Why: persistent_workers=False respawns worker processes every epoch (interpreter startup, re-importing, re-initializing dataset state per worker). At 2054 frames and a trivial __init__, that respawn cost is small relative to total work per epoch. This optimization matters more as dataset setup cost grows (heavier __init__, larger metadata index, etc.) — not something to assume is dramatic by default.


5. num_workers sweep — WITH 10ms artificial I/O latency per sample

Simulates slow storage/network reads via time.sleep(0.01) inside __getitem__.

num_workers total (100 batches) per_batch
0 38.07s 380.66ms
2 19.32s 193.23ms
4 9.63s 96.33ms
8 5.06s 50.58ms
16 3.19s 31.92ms

Reading: No plateau. Scaling stays roughly monotonic well past 8 workers — 16 is still meaningfully faster than 8, unlike the no-latency sweep where 8→16 regressed.

Why: when each sample involves a wait (blocked on I/O), a worker spends most of its time doing nothing useful — CPU sits idle during the sleep. More workers means more samples can be "in flight" waiting simultaneously, and since they're not actually contending for CPU cycles (they're blocked, not computing), you can meaningfully oversubscribe past your core count.

Takeaway — this is the core lesson of Phase 1: There is no universal "correct" num_workers. It's a direct function of whether your per-sample cost is:

The two sweeps side-by-side are the proof — same dataset, same machine, only difference is where the per-sample bottleneck sits.

resources: