Skip to content

fix(training,calibration): resolve DataLoader workers everywhere, and guard it (#207) - #209

Merged
jayhesselberth merged 2 commits into
mainfrom
fix/207-val-loader-workers
Aug 24, 2026
Merged

fix(training,calibration): resolve DataLoader workers everywhere, and guard it (#207)#209
jayhesselberth merged 2 commits into
mainfrom
fix/207-val-loader-workers

Conversation

@jayhesselberth

@jayhesselberth jayhesselberth commented Aug 24, 2026

Copy link
Copy Markdown
Member

Fixes #207.

#206 fixed eval test and added resolve_dataloader_workers, whose docstring says:

Every caller that builds a loader goes through this function, so that guard lives in one place.

Nothing enforced that. Two callers didn't.

What was broken

site symptom
training.py val loader hardcoded num_workers=0, three lines below the call that resolves the train loader
calibration.py ×2 num_workers=num_workers with default 0 — on CUDA that is literally zero, not AUTO

Observed on an A30 with a 1,176,763-chunk val set:

fully fed for a while, then dropped to 30% for 5 minutes, now back

75 minutes of near-idle GPU per 15-epoch run, scaling with val size. Same root cause as #205: __getitem__ being cheap does not mean one process can saturate a GPU — collate, pin, host-to-device and the forward pass all serialize onto that core.

Why not just delete the zeros

The old val comment gave two reasons and they aged differently:

  • "__getitem__ is trivially fast, so workers add no benefit" — refuted by the measurement.
  • "avoids OOM-triggered segfaults on large multiclass datasets"real, but narrower than a blanket 0.

LeechDataset stacks per-chunk tensors into contiguous buffers precisely so a fork COW-shares them (the _try_stack note in dataset.py). Only the list fallback — inconsistent per-chunk shapes — makes each worker fault N PyObject headers into private copies and multiply peak RSS by (1 + workers).

So resolve_val_dataloader_workers scopes the exception to exactly that case, and it wins over an explicit --num-workers N, because OOM is not a throughput tradeoff. It lives beside its sibling in dataset.py rather than inline in train(), which restores the "one place" claim and makes it testable without standing up a training loop.

The guard, and why it looks like this

num_workers may no longer be a bare literal anywhere in the package. It must come from a resolver, from a local whose name says it carries a resolved count, or carry a call-site marker dataloader-workers: unresolved with a reason. Two markers exist: commands/benchmark.py (the worker count is the independent variable being benchmarked) and the legacy SignalCNN path (SignalDataset has no _signals_tensor, so the val guard would force 0 and change behaviour).

It took three attempts, and the first two failed their own mutation test:

  1. A file-scoped allow-list — worse than nothing. ("training.py", None) exempted the whole file, so reintroducing the The in-training validation loader bypasses resolve_dataloader_workers, starving the GPU once per epoch #207 bug passed.
  2. Inspecting DataLoader(...) call sites — also passed the The in-training validation loader bypasses resolve_dataloader_workers, starving the GPU once per epoch #207 reintroduction, because that bug lives in a val_loader_kwargs dict reaching the loader via **kwargs, and the enclosing function resolves a different loader. That is exactly the shape The in-training validation loader bypasses resolve_dataloader_workers, starving the GPU once per epoch #207 had.

So the check is on the value wherever it is bound, not on loader construction.

Tests

TestValLoaderWorkers (6) covers the resolver: stacked datasets get workers, val matches train, the list fallback stays serial, CPU stays serial, an explicit N is honoured when stacked and overridden when not.

The guard is mutation-tested against both real regressions:

mutant result
reintroduce the #207 literal in training.py fails, names training.py:1614
reintroduce the calibration.py literal fails, names calibration.py:202
remove the list-fallback guard 2 resolver tests fail
revert the resolver to always-zero 3 resolver tests fail

Full suite green; ruff format and check clean.

#207)

#206 fixed `eval test` by resolving its DataLoader workers, and added
`resolve_dataloader_workers` whose docstring says "Every caller that builds a
loader goes through this function, so that guard lives in one place". The
validation loader inside the training loop did not, and was hardcoded to 0
three lines below the call that resolves the train loader.

The cost is once per epoch. On a 1,176,763-chunk binary val set the GPU sat at
~30% for ~5 minutes at every epoch boundary and then recovered -- ~75 minutes
of near-idle accelerator across a 15-epoch run, and it scales with val size.
That is the same shape as #205: `__getitem__` being cheap does not mean one
process can saturate a GPU, because collate, pin, host-to-device and the
forward pass still serialize onto that core.

The memory half of the old comment is real, but narrower than a blanket 0.
`LeechDataset` stacks per-chunk tensors into contiguous buffers *precisely* so
a fork COW-shares them (see the note at dataset.py `_try_stack`); only the list
fallback, taken when per-chunk shapes are inconsistent, makes each worker fault
N PyObject headers into private copies and multiply peak RSS. So the exception
is now scoped to exactly that case instead of penalising every run to protect
it -- and it wins even over an explicit `--num-workers N`, because OOM is not a
throughput tradeoff.

The logic lives in `resolve_val_dataloader_workers` beside its sibling rather
than inline in `train()`, which keeps the "one place" claim true and makes it
testable without standing up a training loop.

Six tests, and both halves are mutation-checked: removing the list-fallback
guard fails two of them, and reverting to the old always-zero fails three.

Other call sites that still bypass the resolver, not touched here:
`calibration.py` 193/543 (runs on CUDA, most likely to matter next),
`commands/benchmark.py` 63, and the legacy `SignalCNN` path at training.py
261/265. `gridsearch.py` is fine -- its pool workers are daemonic, where the
resolver returns 0 anyway.
…le class

Both `calibration.py` loaders passed `num_workers=num_workers` with a default
of 0, so on CUDA they got literally zero rather than AUTO -- the same starvation
as #205 and #207, in the third place. Both now go through
`resolve_val_dataloader_workers`: they feed validation datasets, so the
list-fallback exception applies to them as well.

The more useful half is the guard. `num_workers` may no longer be a bare
literal anywhere in the package; it must come from a resolver, from a local
whose name says it carries a resolved count, or carry a call-site marker
`dataloader-workers: unresolved` with a reason. Two such markers exist:
`commands/benchmark.py` (the worker count is the independent variable being
benchmarked) and the legacy SignalCNN path in `training.py` (SignalDataset is
not a LeechDataset and has no `_signals_tensor`, so the val guard would force 0
and change behaviour -- converting it needs its own measurement).

The guard took three attempts, and the first two are why it is written this
way:

  1. A file-scoped allow-list. Worse than nothing: `("training.py", None)`
     exempted the whole file, so reintroducing the #207 bug PASSED.
  2. Inspecting `DataLoader(...)` call sites. Also passed the #207
     reintroduction, because that bug lives in a `val_loader_kwargs` dict that
     reaches the loader via `**kwargs`, and the enclosing function resolves a
     DIFFERENT loader -- which is precisely the shape #207 had.

So the check is on the VALUE wherever it is bound, not on loader construction.
Mutation-tested against both real regressions: reintroducing the #207 literal
fails it naming training.py, and reintroducing the calibration.py literal fails
it naming calibration.py.
@jayhesselberth jayhesselberth changed the title fix(training): route the validation loader through the worker resolver (#207) fix(training,calibration): resolve DataLoader workers everywhere, and guard it (#207) Aug 24, 2026
@jayhesselberth
jayhesselberth merged commit 2313ba1 into main Aug 24, 2026
3 checks passed
@jayhesselberth
jayhesselberth deleted the fix/207-val-loader-workers branch August 24, 2026 14:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The in-training validation loader bypasses resolve_dataloader_workers, starving the GPU once per epoch

1 participant