Skip to content

Keep Inductor's compile cache under ~/.cache so restarts start warm - #819

Merged
dghosef merged 25 commits into
mainfrom
cold-start/persist-compile-cache
Oct 28, 2026
Merged

dghosef merged 25 commits into
mainfrom
cold-start/persist-compile-cache

Conversation

@dghosef

@dghosef dghosef commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

What this does

This keeps Inductor's compile cache under ~/.cache, so a restart in a new container, after a reboot, or after /tmp is cleaned starts warm.

By default torch puts TORCHINDUCTOR_CACHE_DIR at /tmp/torchinductor_<user>. A process restart on the same host already reuses it, but the directory is lost with the container, on reboot, and when /tmp is cleaned. Then every capture bucket is compiled again and its GEMMs are autotuned again.

How much that costs: at the default -O1, Qwen3.5-0.8B on H200 takes about 320 s to start on an empty cache, and 1,294 s at -O2 (#817). With this PR, a restart that loses /tmp takes 96–108 s instead (measured below).

The weights (~/.cache/huggingface) and FlashInfer's JIT builds (~/.cache/flashinfer) already live under ~/.cache. Inductor's cache was the one piece that a ~/.cache volume didn't keep.

How it works

  1. mstar/engine/__init__.py sets TORCHINDUCTOR_CACHE_DIR to <user cache dir>/mstar/torchinductor when it isn't already set, and TRITON_CACHE_DIR to <TORCHINDUCTOR_CACHE_DIR>/triton, so exporting the Inductor directory alone moves both. An exported value always wins.
  2. The user cache dir comes from one stdlib-only helper, mstar/utils/cache_home.py::user_cache_dir(): $XDG_CACHE_HOME if it is an absolute path, else ~/.cache. It replaces three hand-written XDG lookups (engine, omnilingual_asr, vjepa2). A relative XDG_CACHE_HOME is ignored, and with no usable home both variables stay unset, so torch's /tmp default applies and a compile never fails because of an unwritable cache directory.
  3. It's set in mstar.engine because importing torch._dynamo writes torch's own /tmp default into os.environ (torch/_dynamo/package.py calls cache_dir() at module level), so our default has to be set before that first import. mstar.engine is where every entry point first imports it. Platform.process_env_defaults runs too late.
  4. The conductor logs the resolved directories once at startup.
  5. There's no new mstar knob. These are torch's and Triton's own variables.

Docs

  • docs/serving.rst, "Restarts and the compile cache": what to persist, what the cache key covers (torch/Triton versions and, at -O1, the source hash with compute capability and SM count, so keep one cache per GPU model; ptxas and the driver aren't in the key, so delete the directory or set TORCH_COMPILE_CACHE_KEY_TAG after changing them), that the default grows in home and nothing prunes it, and to export a node-local path when home is on NFS or a quota.
  • docs/environment_variables.rst: rows for both variables and their defaults.
  • XPU docker run (docs/installation.rst) and the Dynamo compose worker use a named mstar-cache volume.
  • .claude/skills/benchmarking/SKILL.md: a cross-process A/B or restart sampling uses a fresh, empty cache per boot, since a shared cache pins one arm's autotune picks.

How it was tested

What Result
ruff check . clean
CPU Core on 3783c8890 (merged with main f417b902d) vs main 8073 passed, 936 skipped, 2 failed; main 8067 passed with the same 2 failures (test_dump_stacks_retries_with_sudo_only_when_asked, a box-local py-spy install). None fail only on this PR.
Behaviour script, fresh processes on a Linux CPU box 9/9: default under $HOME/.cache/mstar/torchinductor with Triton under it; a second process hits the FX cache (1 hit, 0 misses); exported TORCHINDUCTOR_CACHE_DIR, TRITON_CACHE_DIR and XDG_CACHE_HOME each win; an empty export counts as unset; a relative XDG is ignored; a read-only HOME (user nobody) falls back to /tmp/torchinductor_nobody and the compile works; 8 concurrent compiles on one empty cache all exit 0 and a 9th process hits.
GPU, 1 x H200, Qwen3.5-0.8B and Z-Image, no cache variables exported Every boot logged Compile caches: Inductor ~/.cache/mstar/torchinductor, Triton .../torchinductor/triton and left nothing in /tmp/torchinductor_*; main wrote to /tmp/torchinductor_root. No tracebacks, every request OK.

Restart timing, Qwen3.5-0.8B at -O1 on 1 x H200 (PR 3783c8890 vs main f417b902d). "Restart" wipes /tmp/torchinductor_* and keeps $HOME, which is what a new container loses:

Boot to /health cold restart
this PR 388 s, 535 s 108 s, 96 s
main 547 s, 343 s 343 s, 331 s
  • On main a restart costs a full cold start (its cache was in /tmp); with this PR it reused the 326 MB cache and wrote no new files.
  • Cold boots land in two clusters (about 345–390 s and 535–550 s) on both arms, so the PR doesn't change cold-start time.
  • A restart reproduces the greedy outputs of the boot that wrote the cache (4/4 prompts). Two fresh -O1 compiles can differ on both arms (PR 3/4, main 1/4 identical), which is pre-existing.
  • c=1 decode is within the boot-to-boot spread on both arms (PR 641–672, main 637–670 tok/s), and first-request latency is the same (0.16–0.19 s).

Not run: a restart that keeps /tmp (same container), several servers sharing one cache, and Z-Image restarts.

Not in this PR

Written with AI assistance (Claude Code); I've reviewed the change and the numbers above.

🤖 Generated with Claude Code

torch defaults TORCHINDUCTOR_CACHE_DIR to /tmp/torchinductor_<user>, which
is lost with the container, on reboot and when /tmp is cleaned, so most
restarts paid a full cold start (compiling and autotuning every capture
bucket). mstar.engine now defaults it to ~/.cache/mstar/torchinductor
(XDG-aware) before anything imports torch._dynamo, which writes torch's
own default into the environment. An exported value wins, and a home that
can't be written keeps torch's default.

Docs: what to persist for warm restarts (serving.rst), the variable's row
(environment_variables.rst), the XPU docker example mounts the cache, and
the benchmarking skill says to use an empty cache dir for cold starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dghosef
dghosef marked this pull request as ready for review October 22, 2026 03:19
@dghosef
dghosef requested a review from vasilevklart October 22, 2026 03:19
Comment thread docs/environment_variables.rst Resolved
@mstar-review

mstar-review Bot commented Oct 22, 2026

Copy link
Copy Markdown

Automated review

🟡 1 P2 on 8def7f7, light review

Description

  • The startup figures (1,312 s cold, 94 s warm) come from two separate earlier runs, and neither used the new cache directory. A warm restart from ~/.cache/mstar/torchinductor was never timed, yet docs/serving.rst states "about 1.5 minutes with warm ones" as a measured result for this setup. Timing one warm restart would back that sentence.
  • The full CPU Core suite was not run; only test/modular/test_torch_config.py was.
What the review checked
  • mstar/__init__.py is lazy and imports no torch, so mstar.engine is the first module to set the variable. mstar.cli.main reaches mstar.api_server.entrypoint without importing torch first.
  • mstar.conductor.conductor imports plain torch before mstar.engine. That is safe only because import torch loads torch._dynamo lazily, and the new subprocess test checks that this import order is safe.
  • mstar/platforms/compile.py imports torch._inductor.config inside a function, not at module import, so it runs after the default is set.
  • An exported TORCHINDUCTOR_CACHE_DIR returns early, so the user's value wins. When home can't be written, mkdir hits OSError or os.access fails, and the variable is left unset, so torch falls back to its /tmp default.
  • Spawned workers inherit the parent's environment, so the parent's choice carries over to them.
  • :ref:serving:Restarts and the compile cache`` resolves because docs/conf.py enables `autosectionlabel` with `autosectionlabel_prefix_document = True`.
  • The new test runs in a subprocess with its own XDG_CACHE_HOME, so it doesn't pollute the test runner's environment or the user's real cache.
  • Invariant 9: the variable has a row in docs/environment_variables.rst that names its default and the function that sets it.

Advisory, not a merge gate. Rules: AGENTS.md. Reply to discuss.

dghosef and others added 2 commits October 21, 2026 20:22
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dghosef dghosef changed the title [Engine] Keep Inductor's compile cache under ~/.cache so restarts start warm Keep Inductor's compile cache under ~/.cache so restarts start warm Oct 22, 2026
@vasilevklart vasilevklart self-assigned this Oct 23, 2026
dghosef and others added 14 commits October 27, 2026 00:17
Conflicts:
- .claude/skills/benchmarking/SKILL.md: main's -O1/-O2 compile-level wording for trap 1, with this branch's persistent-cache sentence replacing the old "unverified" one.
- docs/environment_variables.rst: kept both rows (main's MSTAR_COMPILE_LEVEL, this branch's TORCHINDUCTOR_CACHE_DIR).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ache

The 22-minute figure was -O2's; the default is -O1 (320 s, the page's own
-O flag row). A process restart on one host already reused torch's /tmp
cache, so the gain is for a new container or a reboot. (R1.2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CacheBase.get_system keys on the torch and Triton builds and, on CUDA and
ROCm only, the GPU name and CUDA/HIP version; it doesn't cover ptxas or the
driver, and on XPU it is empty. Now that the cache outlives a reboot, say
how to break it (delete it, or torch's TORCH_COMPILE_CACHE_KEY_TAG), and
keep the per-GPU-model advice to the backends that need it. (R1.4, R1.10)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The default moves compile I/O from node-local /tmp to home, and nothing
prunes it. Say it is safe to delete, and when to export a node-local path
instead (quota-limited or network homes, a volume shared across hosts).
(R1.5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The image runs as root, so bind-mounting $HOME/.cache/mstar leaves
root-owned entries a later native run can't write (or, if Docker creates
the bind source, a root-owned directory the native run silently skips).
A named volume keeps the container's cache to itself. (R1.7)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The worker runs as root, so its default cache is /root/.cache/mstar, which
was lost whenever compose recreated the container. (R1.9)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s torch's default

mstar.engine's default resolved Path.home() outside its try, so a process
with no HOME and no passwd entry failed `import mstar.engine` with
RuntimeError, and a relative XDG_CACHE_HOME (or one with a literal ~) was
used as is, against the XDG spec. The lookup was also the fourth
hand-written copy of the cache root, each with different semantics.

user_cache_dir() (mstar/utils/cache_home.py, standard library only, since
mstar.engine calls it before importing torch) replaces all four, the
engine catches RuntimeError too, and the conductor logs the cache
directory it resolved once at startup. (R1.6, R1.12)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Where a Triton kernel landed depended on what compiled first: Inductor's
first kernel in a process writes TRITON_CACHE_DIR=<inductor>/triton/<dev>
into the environment (CachingAutotuner.__init__), so raw Triton kernels
compiled before it (Qwen3.5's GDN/FLA kernels) went to ~/.triton/cache,
which the docs said ~/.cache held. Default TRITON_CACHE_DIR beside
Inductor's cache, with the same exported-wins and unwritable-keeps-the-
default rule, as vLLM does; triton_cache_dir() then sends Inductor's
kernels there too. The docs list FlashInfer's own variable
(FLASHINFER_WORKSPACE_BASE). (R1.3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…restarts

A restart that finds a warm cache reloads the previous process's tuning
(autotune_cache, and -O2's GEMM picks baked into the cached code), so
"sample several processes" with one persisted cache per arm draws one pick
N times, and one arm's lucky pick survives every restart. Say to share one
cache across both arms so unchanged kernels keep their pick, and to give
each boot fresh empty Inductor and Triton directories to resample or to
measure a cold start (FlashInfer stays warm). Same-host restarts always
reused /tmp. The env var row's cold-start recipe names both directories.
(R1.1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…orm env default

`python -m mstar.integrations.dynamo` is a third process that can import
torch._dynamo first; add it to the import-order test. And note in the
docstring why the default can't live in Platform.process_env_defaults,
which runs after torch._dynamo is imported. (R1.8)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
benchmark/eval is shipped to eval hosts on its own (evalfw, PYTHONPATH
set to that copy), so it can't import mstar; the import broke
test_one_model_runs_both_arms_on_a_remote_slot_and_reports. The three
mstar copies still share user_cache_dir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dghosef and others added 8 commits October 27, 2026 19:23
monkeypatch.delenv(raising=False) records nothing for an absent variable,
so the values _default_compile_caches writes outlived the test in its
xdist worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With a sibling ~/.cache/mstar/triton, an exported TORCHINDUCTOR_CACHE_DIR
(a node-local path, or an empty one for a cold start) no longer brought
Triton's kernels with it, as torch's <inductor>/triton/<dev> did. Derive
Triton's default from the resolved Inductor directory instead, so one
variable moves everything again; an exported TRITON_CACHE_DIR still wins,
and the no-home fallback leaves both unset. (R2.1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e (R2.2)

The shared-cache advice was unmeasured, and a kernel the branch changes is
still one draw per arm under it. Lead with what Qwen3-TTS showed (per-arm
warm caches: fake -5.8% at c=1; fresh caches per boot: overlap), keep the
shared cache only as a way to hold unchanged kernels fixed, and drop the
unverified claim that same-host restarts always reused /tmp, which sat
oddly beside the Whisper restart spread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…GPU model (R2.3)

CacheBase.get_system's GPU name covers the FX graph cache and -O2's GEMM
picks; -O1's coordinate-descent .best_config files are keyed on the kernel
source, which embeds DeviceProperties (compute capability, SM count) but
not the name, so an H100 and an H200 sharing a cache reuse each other's
picks. On XPU get_system is empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… (R2.4)

Dynamo tracing and graph capture still run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd the device (R3.1)

FxGraphHashDetails and the -O1 .best_config key always hash torch_key();
only get_system() (Triton build, device, CUDA/HIP version) is empty off
CUDA and ROCm, and on ROCm its device is the gfx arch, not the card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…R3.2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dghosef
dghosef merged commit 4ba0b60 into main Oct 28, 2026
10 checks passed
@dghosef
dghosef deleted the cold-start/persist-compile-cache branch October 28, 2026 04:43
dghosef added a commit that referenced this pull request Oct 28, 2026
Main since 73c6c17: #883 mstar_rust in the Linux wheels, #901 output
spans (BatchedModelOutput.output_spans, NodeSubmodule.spanned_outputs, the
worker's crop), #904 DiT seed noise drawn at ingest (on_request_added,
NoiseStager deleted, to_device_nonblocking as one memcpy into pinned memory),
#903 API-server torch pool from the CPU quota and pooled image encodes,
#924 robotics harness, #819 Inductor cache under ~/.cache, #881
CommGroup.agree / JointGroups.agree (agree_across_ranks and
flagged_on_any_rank deleted), #724 CUDA IPC KV in one 2D memcpy a page,
#387 GLM-5.2 (MLA, fp8 MoE with NoAuxTcRouter, the DSA resource), #921
vjepa2 AC crop, #922 Cosmos3 contiguous positions, #923 dense attention
falls back when fa3-fwd has no kernel for the GPU.

Conflicts:
- mstar/utils/h2d.py, to_device_nonblocking: the stack's signature
  (data: Tensor | Sequence, device, dtype=None) and DeviceCapability
  pin_memory gate, with main's tensor path (#904): a CPU tensor is written
  into a fresh pinned buffer with one memcpy rather than pin_memory()'s
  copy_ over the intra-op pool. Every CPU tensor now goes through a fresh
  buffer, so the stack's "an already-pinned source is read in place"
  caveat (R3.3) no longer applies and is dropped from the docstring.
  test_accelerator_graph_backend's pinning test checks the pinned
  torch.empty and its non-blocking copy instead of pin_memory().
- mstar/model/components/diffusion/denoise_loop.py: imports, both
  (main's copy, the stack's functools).

Main code on a superseded path, ported here:
- GLM-5.2 (glm52/submodules.py): its own check_stop / check_stop_batched
  (four arguments, step_walk, its own row-map loop) / _stops ->
  token_stop = TokenStop(decode_loop, new_token, eos_token_ids, sampler)
  with a _token_stops override for its two differences (a stop token at
  prefill ends the reply at the first decode step; max_tokens counts the
  prefill's token), and a check_stop override for that closing step,
  whose token postprocess drops. test_glm52_model's helper and
  test_model_kv_layouts' GLM cap test build the real submodule (over a stub
  language model) instead of object.__new__, which would leave token_stop
  unset.
- PinnedStager.to_device (#387), a second "host values -> new device
  tensor" helper beside to_device_nonblocking: deleted with its test
  (test_pinned_stager_to_device.py); its callers, GLM-5.2's prefill
  seq_lens and the DSA resource's page tables, call
  to_device_nonblocking(list, device, dtype=torch.long).
  test_dsa_resource spies on that instead.

The other files both sides touched (engine.py, accelerator_graph_runner.py,
kv/manager.py, attn/base.py, ragged/base.py, communication.py,
model/base.py, submodule_base.py, entrypoint.py, conductor.py, moe.py,
image_io.py, vjepa2_model.py, docs, tests) auto-merged into disjoint
hunks: per file the merged delta equals main's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants