fix: unsafe non-blocking H2D transfers and MPS fallback timing - #21
Conversation
Root cause: evaluate_classification/forecasting/regression/anomalydetection all moved input batches to device with .to(device, non_blocking=True), but create_data_loaders only pins source memory for CUDA (pin_memory=False on MPS/CPU). Async H2D transfer from non-pinned memory is only safety-guaranteed on CUDA (the driver makes it effectively synchronous); on MPS the async copy can read a CPU source buffer that gets reused before the Metal transfer completes, corrupting the tensor arriving on-device. This surfaced as NaN propagating into FakeQuantize observer running-min/max, tripping "AssertionError: min nan should be less than max nan" during QAT eval. Fix gates non_blocking on device.type == 'cuda' in all four evaluate_* functions, mirroring the existing use_pin_memory pattern in create_data_loaders. CUDA keeps its non_blocking+pin_memory optimization unchanged; MPS/CPU fall back to the same safe blocking transfer already used by every train_one_epoch_* function. Verified via full float-train -> 10-epoch QAT train+eval -> ONNX export -> ONNX runtime eval repro on the packaged mmcli macOS binary: 2 agent runs + 2 independent human runs, all EXIT_CODE:0, no NaN, no assertion failure.
The existing os.environ.setdefault('PYTORCH_ENABLE_MPS_FALLBACK', '1') calls in
_get_device() (timeseries_base.py, image_base.py, audio_base.py) only fire on the
auto-detect branch, and even there they run too late to have any effect: torch is
already imported (and its MPS backend already registered, which is where the flag
gets read) by the time _get_device() executes, since _get_device() lives in a module
that itself imports torch, and that module is loaded well before _get_device() is
called.
Verified empirically: with the flag unset externally and --training-device mps passed
explicitly, os.environ.setdefault() in _get_device() *did* set the variable in
os.environ, but the MPS fake_quantize op still hard-failed with NotImplementedError
instead of falling back to CPU -- confirming the flag has to be set before torch's
first import in the process, not merely before the first MPS-dispatched kernel.
run_tinyml_modelmaker.py is the single shared entry point for all three ai_modules
(timeseries/vision/audio) and does not import torch at module level -- only stdlib
modules -- making it the correct place to set this unconditionally, before
`import tinyml_modelmaker` (which pulls in torch transitively) ever runs. The
now-redundant setdefault() calls inside the three _get_device() methods are left in
place as harmless (if ineffective on their own) belbelt-and-suspenders.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ipts Same bug class as 825b493 (evaluate_classification/forecasting/regression/ anomalydetection in common/utils/utils.py): non_blocking=True H2D transfers are only safety-guaranteed on CUDA with pinned source memory, but every test_onnx.py script (one per task type) and timeseries_anomalydetection/train.py's get_reconstruction_errors_stats() hardcoded non_blocking=True regardless of device, with their own separate DataLoaders that request pin_memory=True unconditionally (or gated on GPU count, not specifically CUDA). Confirmed via an observed runtime warning that pin_memory is a silent no-op on MPS ("'pin_memory' argument is set as true but not supported on MPS now, then device pinned memory won't be used"), so the same race precondition -- non_blocking copy from unpinned source memory -- applies here too. These scripts are reachable from the default pipeline: mmcli train invokes them automatically as the post-training ONNX-runtime testing step (testing.enable=True by default), not just via a separate manual "compile" or "run" step. Fixed in all 8 affected files by gating non_blocking on device.type == 'cuda', identical to the pattern already applied and reviewed clean in 825b493. Note: unlike 825b493, no crash or NaN was forced on this path despite two full repro attempts (before and after the fix, identical params/seed, both completed cleanly at EXIT_CODE:0 with matching 30% accuracy). This is a preventive fix by pattern-match to an already-confirmed root cause, not an empirically-forced repro -- the race is inherently probabilistic, and this per-sample onnxruntime inference loop lacks the CPU-fallback-op scheduling perturbation that was necessary to expose the original bug reliably. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
CI failure investigated — pre-existing on Verified directly against This PR's CI run shows only that same subset — no new failures introduced. It's fixed in #19; should resolve automatically once that merges. (Windows failures don't block merging — the workflow marks that runner |
Two independent bugs:
1. timeseries_anomalydetection/test_onnx.py's get_reconstruction_errors_stats()
built its DataLoader with `pin_memory=True if args.gpu > 0 else False`, but
the shared argparser (common/test_onnx_base.py) only ever defines `--gpus`
(plural), never `--gpu`. Every call crashed immediately with:
AttributeError: 'Namespace' object has no attribute 'gpu'
before any model loading or data processing started -- this function runs
unconditionally at the top of main(), so every invocation of this script
was broken. The same file's main() had a related, non-crashing but still
wrong variant: `pin_memory=True if gpu > 0 else False`, gating pin_memory
on the DDP process rank (0 for single-process/CPU runs, 1+ for later
ranks) rather than on device type -- backwards from any sensible
pin_memory policy. Both are now unconditional `pin_memory=True`, matching
every one of the 5 sibling test_onnx.py scripts, none of which gate
pin_memory on gpu count or rank at all.
2. audio_classification/test_onnx.py never imported or called
shutdown_data_loaders() on its DataLoader, unlike every one of its five
siblings (image_classification, timeseries_classification,
timeseries_forecasting, timeseries_regression,
timeseries_anomalydetection), leaking DataLoader worker processes / POSIX
semaphores whenever --workers > 0. Wrapped the same body sibling scripts
wrap in try/finally, matching image_classification/test_onnx.py's
structure exactly.
Adds tests/test_onnx_robustness_bugs.py:
- Reproduces bug 1 by calling get_reconstruction_errors_stats() with an args
Namespace that only has `gpus` (matching the real parser), verified to
fail pre-fix with the exact AttributeError above.
- Reproduces bug 2 by forcing an exception during ONNX model loading inside
main() and asserting shutdown_data_loaders() still ran; verified to fail
pre-fix because the module didn't even have that attribute to patch.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Pushed an additional commit (072ce4f) fixing two more robustness bugs found in these same files during a broader codebase review:
Both come with regression tests verified to fail pre-fix and pass post-fix. See the commit message for full detail. |
9629e9c to
75cd37a
Compare
…on run An independent peer review of the "does the merge order between TexasInstruments#21 and TexasInstruments#22 matter" question -- specifically auditing whether their disjoint edits to utils.py and this file were semantically safe together -- found a real, independent bug already present in this PR, unrelated to any merge interaction. This PR's own earlier fix gated non_blocking transfers on `device.type == 'cuda'` in get_reconstruction_errors_stats() (matching the same fix applied to evaluate_classification et al. elsewhere). Before that, non_blocking was hardcoded True and the function never touched `.type`, so passing a plain device string worked fine. After the fix, `.type` is required -- but the sole call site (main(), calculating the anomaly-detection threshold right after export) passed `args.device`, the raw unconverted argparse string ('cuda' by default), instead of `device`, the torch.device already constructed by setup_training_environment() and in scope in the very same function. `'cuda'.type` raises AttributeError, so this crashed on every anomaly-detection training run -- CUDA included, not an edge case, the normal path right after training completes. Fixed by passing the existing local `device` instead of `args.device` at the call site. Adds tests/test_anomalydetection_train_device_crash.py: two tests characterize get_reconstruction_errors_stats()'s contract directly (works with a torch.device, crashes with a raw string -- exactly reproducing the pre-fix symptom). A third test drives the real main() (heavily mocked elsewhere, zero-iteration training loop) and asserts what it actually passes as the device argument at the real call site -- this is the one that genuinely exercises the fixed line, since the first two would pass identically regardless of whether the call site itself were fixed. Verified it fails pre-fix (main() passed the raw string 'cuda') and passes post-fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
An independent peer review (auditing whether this PR's changes and PR #22's changes to the same shared files -- `utils.py`, `timeseries_anomalydetection/train.py` -- were semantically safe to merge together, not just textually non-conflicting) found a real, independent bug already in this PR. Pushed a follow-up commit (f8ccbac): This PR's own earlier fix gated `non_blocking` transfers on `device.type == 'cuda'` in `get_reconstruction_errors_stats()`. Before that, `non_blocking` was hardcoded `True` and the function never touched `.type`, so a plain device string worked fine. After the fix, `.type` is required -- but the call site in `main()` passed `args.device` (the raw, unconverted argparse string, `'cuda'` by default) instead of `device` (the `torch.device` already constructed by `setup_training_environment()` and in scope in the same function). `'cuda'.type` raises `AttributeError`, crashing every anomaly-detection training run -- CUDA included -- right after export, while calculating the detection threshold. Not an edge case; the normal path. Fixed by passing the existing local `device` instead of `args.device`. Added a test that drives the real `main()` (heavily mocked elsewhere) and asserts what it actually passes at the call site -- verified to fail pre-fix (`main()` passed the raw string `'cuda'`) and pass post-fix. Two additional tests characterize `get_reconstruction_errors_stats()`'s contract directly. Re-verified the full #20→#21→#22→#23 merge sequence still produces zero conflicts with this commit included. |
Summary
Two related MPS-specific bugs found while validating training end-to-end on Apple Silicon.
non_blocking H2D transfer race on non-CUDA devices
non_blocking=Trueon a.to(device, ...)H2D transfer is only safety-guaranteed on CUDA with pinned source memory. On MPS,create_data_loaders()only pins for CUDA (pin_memory=Falseon MPS/CPU), so an async H2D copy can race with reuse of the source buffer and corrupt the transferred tensor. Observed asAssertionError: min nan should be less than max naninside aFakeQuantizeobserver — the eval-mode input tensor itself was already NaN before any quantization math ran, traced back to the async copy racing against its own CPU source buffer.Confirmed the same precondition applies more broadly than just the training loop's
evaluate_*functions: everytest_onnx.pyscript (one per task type — timeseries classification/regression/forecasting/anomaly-detection, image classification, audio classification) andtimeseries_anomalydetection/train.py'sget_reconstruction_errors_stats()build their own separate DataLoaders (hardcodingpin_memory=True, or gating it on GPU count rather than CUDA specifically) and usenon_blocking=Trueunconditionally. A runtime warning confirmspin_memoryis a silent no-op on MPS:'pin_memory' argument is set as true but not supported on MPS now, then device pinned memory won't be used. These scripts are reachable from the default pipeline —mmcli traininvokes them automatically as the post-training testing step, not gated behind a separate compile step.Fixed by gating
non_blockingondevice.type == 'cuda'everywhere it was previously unconditional:evaluate_classification/evaluate_forecasting/evaluate_regression/evaluate_anomalydetection(common/utils/utils.py) and all 8 files above.PYTORCH_ENABLE_MPS_FALLBACK set too late to take effect
_get_device()(in the timeseries/vision/audioai_modulestraining base classes) setsPYTORCH_ENABLE_MPS_FALLBACK=1viaos.environ.setdefault(...)when MPS is selected, so unsupported ops fall back to CPU instead of hard-failing. This env var is read once, by PyTorch, when its MPS backend registers duringimport torch— andtorchis already imported (transitively, by the module_get_device()itself lives in) well before_get_device()ever runs. Confirmed via instrumentation thatos.environ['PYTORCH_ENABLE_MPS_FALLBACK']really was set to'1'at that point in the call stack, yet an unsupported quantization op still hard-failed withNotImplementedErrorinstead of falling back — the flag being present inos.environat that point has no effect on an already-initialized backend.Fixed by setting
PYTORCH_ENABLE_MPS_FALLBACKunconditionally at the top ofrun_tinyml_modelmaker.py— the single shared entry point for all threeai_modules, which imports only stdlib at module level — ahead ofimport tinyml_modelmaker(which pulls in torch transitively). Harmless on CUDA/CPU since it only changes MPS dispatch behavior. The original_get_device()calls are left in place as harmless (if individually ineffective) redundancy.Verification
mmcli trainrun on Apple Silicon (quantized training + eval), confirmed it's resolved (clean run, both float and quantized ONNX exported, sane accuracy). For the 8 ONNX-runtime eval script call sites, ran the identical repro before and after the fix — both completed cleanly without crash or NaN either way, so this part is a preventive fix by pattern-match to the confirmed root cause, not something independently forced to fail-then-pass (the race is inherently probabilistic, and this code path lacks the CPU-fallback-op scheduling perturbation that made the original bug reproduce reliably).NotImplementedErrorwithPYTORCH_ENABLE_MPS_FALLBACKunset and--training-device mpspassed explicitly, confirmed clean (EXIT_CODE:0, correct fallback warning instead of a hard error, both ONNX artifacts produced) after the fix, in two separate runs.🤖 Generated with Claude Code