Skip to content

fix: unsafe non-blocking H2D transfers and MPS fallback timing - #21

Merged
Adithya-Thonse merged 6 commits into
TexasInstruments:mainfrom
musicalplatypus:pr/mps-eval-fixes
Aug 6, 2026
Merged

fix: unsafe non-blocking H2D transfers and MPS fallback timing#21
Adithya-Thonse merged 6 commits into
TexasInstruments:mainfrom
musicalplatypus:pr/mps-eval-fixes

Conversation

@musicalplatypus

Copy link
Copy Markdown
Contributor

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=True on 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=False on MPS/CPU), so an async H2D copy can race with reuse of the source buffer and corrupt the transferred tensor. Observed as AssertionError: min nan should be less than max nan inside a FakeQuantize observer — 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: every test_onnx.py script (one per task type — timeseries classification/regression/forecasting/anomaly-detection, image classification, audio classification) and timeseries_anomalydetection/train.py's get_reconstruction_errors_stats() build their own separate DataLoaders (hardcoding pin_memory=True, or gating it on GPU count rather than CUDA specifically) and use non_blocking=True unconditionally. A runtime warning confirms 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. These scripts are reachable from the default pipeline — mmcli train invokes them automatically as the post-training testing step, not gated behind a separate compile step.

Fixed by gating non_blocking on device.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/audio ai_modules training base classes) sets PYTORCH_ENABLE_MPS_FALLBACK=1 via os.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 during import torch — and torch is already imported (transitively, by the module _get_device() itself lives in) well before _get_device() ever runs. Confirmed via instrumentation that os.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 with NotImplementedError instead of falling back — the flag being present in os.environ at that point has no effect on an already-initialized backend.

Fixed by setting PYTORCH_ENABLE_MPS_FALLBACK unconditionally at the top of run_tinyml_modelmaker.py — the single shared entry point for all three ai_modules, which imports only stdlib at module level — ahead of import 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

  • non_blocking fix: reproduced the original crash via a full mmcli train run 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).
  • MPS fallback fix: reproduced the NotImplementedError with PYTORCH_ENABLE_MPS_FALLBACK unset and --training-device mps passed 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

t5fkg8d44d-beep and others added 3 commits July 30, 2026 15:36
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>
@musicalplatypus

Copy link
Copy Markdown
Contributor Author

CI failure investigated — pre-existing on upstream/main, unrelated to this PR.

Verified directly against upstream/main's own most recent CI run (same base commit, 331388a): https://github.com/TexasInstruments/tinyml-tensorlab/actions/runs/30376872256 — it already fails identically on macOS + Ubuntu with:

FAILED test_config_validation.py::...test_task_type_is_valid[google_speech_command/config_MSPM0.yaml] - unknown task_type='audio_classification'
FAILED test_config_validation.py::...test_model_name_exists_in_registry[...] - model 'DSCNN_NPU' not in registry

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 continue-on-error: true.)

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>
@musicalplatypus

Copy link
Copy Markdown
Contributor Author

Pushed an additional commit (072ce4f) fixing two more robustness bugs found in these same files during a broader codebase review:

  1. timeseries_anomalydetection/test_onnx.py: get_reconstruction_errors_stats() referenced args.gpu (singular), which the shared argparser never defines (only --gpus), crashing every invocation with AttributeError. Also fixed a related backwards rank-gated pin_memory in the same file's main(). Both now match the unconditional pin_memory=True used by every sibling test_onnx.py.
  2. audio_classification/test_onnx.py: never called shutdown_data_loaders() on its DataLoader, unlike all 5 sibling scripts, leaking worker processes/semaphores whenever --workers > 0. Wrapped in try/finally matching the sibling pattern.

Both come with regression tests verified to fail pre-fix and pass post-fix. See the commit message for full detail.

…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>
@musicalplatypus

Copy link
Copy Markdown
Contributor Author

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.

@Adithya-Thonse
Adithya-Thonse merged commit b6f1251 into TexasInstruments:main Aug 6, 2026
0 of 3 checks passed
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.

3 participants