Skip to content

fix: allow weights_only=False when loading --cache-dataset torch cache - #31

Merged
Adithya-Thonse merged 3 commits into
TexasInstruments:mainfrom
musicalplatypus:pr/cache-dataset-weights-only-crash
Aug 5, 2026
Merged

fix: allow weights_only=False when loading --cache-dataset torch cache#31
Adithya-Thonse merged 3 commits into
TexasInstruments:mainfrom
musicalplatypus:pr/cache-dataset-weights-only-crash

Conversation

@musicalplatypus

Copy link
Copy Markdown
Contributor

Problem

load_data() in tinyml-tinyverse/tinyml_tinyverse/common/utils/utils.py has two cache-hit branches (training dataset and validation/test dataset) that call torch.load(cache_path) with no weights_only argument. The cached object is a (dataset, datadir) tuple where dataset is one of this project's own Dataset subclasses.

PyTorch 2.6+ changed torch.load()'s default to weights_only=True, which only allowlists a small set of known-safe types. Any run that reuses a --cache-dataset cache written by an earlier run now crashes with:

_pickle.UnpicklingError: Weights only load failed...
WeightsUnpickler error: Unsupported global: GLOBAL <DatasetClass>
was not an allowed global by default...

i.e. the --cache-dataset speedup flag is currently broken on PyTorch 2.6+.

Fix

Pass weights_only=False explicitly at both call sites. This is not a security fix — cache_path is derived from a hash of datadir and is a purely local cache this same process wrote moments earlier; it is never fetched from a URL or otherwise externally supplied. This is a different situation from genuinely untrusted/externally-sourced checkpoint loads elsewhere in the codebase, which should keep the safe default or use torch.serialization.safe_globals([...]).

Testing

Added tinyml-tinyverse/tests/test_cache_dataset_weights_only.py, which reproduces the crash end-to-end through load_data() itself (writes a fake cached dataset via torch.save, then calls load_data() against it), not just a direct torch.load call.

  • Verified the test fails on pre-fix code with the exact UnpicklingError above (checked via git stash).
  • Verified the test passes with the fix.

Merge safety

This touches the same file (utils.py) as three other currently-open PRs (pr/mps-eval-fixes, pr/compile-hardening, pr/hardware-defaults). Verified via a disposable worktree that this branch merges cleanly on top of all three with no conflicts (git merge --no-ff of all three followed by this branch resolves via auto-merge, clean working tree).

🤖 Generated with Claude Code

t5fkg8d44d-beep and others added 2 commits August 4, 2026 18:39
Both cache-hit branches in load_data() (training and validation/test
data) called torch.load(cache_path) with no weights_only argument. The
cached object is a (dataset, datadir) tuple where dataset is one of
this project's own Dataset subclasses -- not a type PyTorch 2.6+'s new
weights_only=True default allowlists. Any run that reused a
--cache-dataset cache written by an earlier run failed with:

  _pickle.UnpicklingError: Weights only load failed...
  WeightsUnpickler error: Unsupported global: GLOBAL <DatasetClass>
  was not an allowed global by default...

This is not a security fix: cache_path is derived from a hash of
datadir and is a purely local cache this same process wrote moments
earlier, never fetched from a URL or otherwise externally supplied.
Explicit weights_only=False is the correct, safe choice here, unlike
genuinely untrusted/external checkpoint loads elsewhere in the
codebase which should keep the safe default or use safe_globals().

Adds a regression test that reproduces the crash end-to-end through
load_data() itself (not just a direct torch.load call), verified to
fail pre-fix with the exact UnpicklingError above and pass post-fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
An independent peer review of this PR's weights_only fix caught a deeper,
more serious pre-existing bug it had unblocked: _get_cache_path(datadir)
computed the SAME cache path for the training dataset and the validation
dataset (both derived only from datadir), and only the training branch
ever wrote its cache -- the validation write-back was dead code,
commented out with "TODO: Add utils and uncomment the if block".

Consequence, verified by reproduction: on the very first run with
--cache-dataset enabled, load_data() writes the training dataset to
cache_path, then immediately re-derives the identical cache_path for
validation and finds it already exists (since it was just written) --
so dataset_test silently becomes a torch.load() of the TRAINING dataset.
Not a crash: --cache-dataset appeared to work, but every validation
metric from that point on was computed against training data instead of
held-out data, at every run using this now-crash-free flag.

Fixed by:
- Giving _get_cache_path a `tag` parameter ('train'/'val') folded into
  the hash, so the two datasets get distinct cache paths.
- Enabling the validation cache write-back, which was dead code -- without
  it, fixing only the path collision would mean the validation dataset
  simply never got cached (safe, but silently defeats the point of
  --cache-dataset for validation).

Rewrites tests/test_cache_dataset_weights_only.py: the previous test
patched _get_cache_path with a single fixed return_value for both the
training and validation calls, which (ironically) meant it would have
passed even under this exact bug -- both calls resolving to the same
path is precisely the defect. New tests patch _get_cache_path with a
tag-aware side_effect and assert the training and validation datasets
come back distinct, and that a missing validation cache actually gets
written. Verified all three fail on the pre-this-commit code with the
exact symptom (dataset_test.tag == 'cached-train' instead of the
expected validation value) and pass post-fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@musicalplatypus

Copy link
Copy Markdown
Contributor Author

An independent peer review of this PR's weights_only fix caught a deeper, pre-existing bug it had unblocked (previously masked by the crash). Pushed a follow-up commit (de8ec33):

_get_cache_path(datadir) computed the same cache path for the training and validation datasets (both derived only from datadir), and only the training branch ever wrote its cache -- the validation write-back was dead code, commented out with # TODO: Add utils and uncomment the if block.

Consequence, confirmed by reproduction: on the very first run with --cache-dataset enabled, load_data() writes the training dataset to cache_path, then re-derives the identical path for validation and finds it already exists -- so dataset_test silently becomes a torch.load() of the training dataset. Not a crash: --cache-dataset looked like it worked, but every validation metric from that point on was computed against training data, not held-out data.

Fixed by giving _get_cache_path a tag parameter ('train'/'val') folded into the hash, and by enabling the validation cache write-back that was dead code.

Also rewrote the test file: the previous test patched _get_cache_path with a single fixed return value for both train and val calls, which (ironically) would have passed under this exact bug. New tests use a tag-aware mock and assert train/val come back distinct -- verified to fail on the pre-fix code with the exact symptom and pass post-fix. Re-verified this still merges cleanly alongside PRs #21/#22/#23 (all touch utils.py).

Two major CodeRabbit findings on this PR's cache handling, both
extending pre-existing gaps the earlier reviews had also noted:

1. _get_cache_path keyed only on tag+datadir, but the cached object is
   the fully PREPARED dataset -- the code's own comment warns "Attention,
   as the transforms are also cached!". A run changing transforms,
   loader, frame size, etc. against the same datadir silently reused a
   dataset prepared under the OLD configuration. Now a documented tuple
   of dataset-shaping args (_CACHE_KEY_ARGS: loader, annotation,
   transform, and framing settings) is folded into the hash via
   getattr-with-None-default reprs -- stable for equivalent configs,
   different for any change. Deliberately not exhaustive (an exotic
   loader could consume an arg not listed; extend as needed) and biased
   toward over-invalidation, which costs a cache miss, never wrong data.
   Side effect: existing caches are invalidated once more (key changes);
   they were already invalidated by the tag change in the previous
   commit, so no additional migration impact.

2. Cache writes used a plain torch.save straight to the final path --
   another rank (or a later run, after a mid-save kill) could see the
   file exist and read it partially written. New _save_cache_atomically
   helper writes to a .tmp sibling and publishes via os.replace (atomic
   on POSIX within a filesystem); both write sites now use it, wrapped
   in is_main_process() to preserve save_on_master's rank-0-only
   semantics.

New tests pin both: config change produces a different key (same config
produces the same key), and the atomic helper leaves no .tmp file and a
loadable cache. Both verified to fail pre-fix and pass post-fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Adithya-Thonse
Adithya-Thonse merged commit 8e76878 into TexasInstruments:main Aug 5, 2026
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