Skip to content

feat(autotuner): reach both persistence backends through autotune() - #1

Open
aleozlx wants to merge 22 commits into
YangXu1990uiuc:yanxu/autotune-cache-v2-mvpfrom
aleozlx:feat/autotune-unified-api
Open

feat(autotuner): reach both persistence backends through autotune()#1
aleozlx wants to merge 22 commits into
YangXu1990uiuc:yanxu/autotune-cache-v2-mvpfrom
aleozlx:feat/autotune-unified-api

Conversation

@aleozlx

@aleozlx aleozlx commented Aug 4, 2026

Copy link
Copy Markdown

📌 Description

Targets yanxu/autotune-cache-v2-mvp, not main — a proposal for flashinfer-ai#3861.

Reach v2 through the existing autotune() instead of a second public context manager. An explicit
v2_opt_in argument selects the implementation, and autotune() dispatches to autotune_v2()
rather than absorbing it:

autotune(True)                                          # legacy: memory only
autotune(True, cache="cfg.json")                        # legacy: JSON file cache
autotune(True, v2_opt_in=True)                          # v2 + managed store
autotune(False, v2_opt_in=True)                         # v2 replay, no profiling
autotune(True, v2_opt_in=True, persistent_cache=False)  # v2, no disk
if v2_opt_in:
    _reject_v1_only_args(cache)
    with autotune_v2(...):          # v2's implementation, unchanged, in autotune_cache.py
        yield
    return
_reject_v2_only_args(cache_root, persistent_cache, measure)
# ---- v1 implementation ----

Not the "subtle behaviour change" flashinfer-ai#3920 warned about. v2_opt_in defaults to False, so absent
the argument behaviour is byte-identical. The v2 branch hands off the whole context and returns, so
the v1 body never runs for it — the two implementations never share a function scope, and
interleaving is not merely prevented but unrepresentable. No recursion: autotune_v2() calls
autotune() without the opt-in for the shared machinery (bucketing, skip_ops, tuning-mode
refcount).

Branch selection is positive. if not v2_opt_in: asserts "everything that is not v2", an
unbounded set that would silently absorb a third implementation; each branch now names its own and
rejects only the arguments the other owns.

The RFC's two objections are answered rather than accepted:

Objection Answer
cache=<file> would be silently redefined as a root dir It isn't. cache keeps its exact v1 meaning; the store uses a separate cache_root, and combining them raises.
v1 is block-scoped, v2 attaches for the process Attach happens only inside autotune_v2(), reached only via the opt-in. autotune(True) and autotune(True, cache=...) keep block scope.

Why one entry point. A shared front door is a structural brake on divergence, and it changes
what happens to future edits: with two entry points a change to shared machinery must be plumbed
twice and drifts by omission, and a v1-only fix (a ported TRT-LLM change, say) is invisible to v2
unless someone remembers to mirror it. With one signature both land in front of the same reviewer —
divergence is paid per-change as a review question rather than later, all at once, as a migration.

Why v2_opt_in and not a behaviour name. A version-named flag announces its own expiry and
retires along a path a function name cannot offer: flip the default → ignore the argument internally
once no caller passes it → delete it, with callers working at every step, each step independently
verifiable and revertible. autotune_v2() cannot be "ignored" — an import either resolves or raises
— so removing it is one breaking edit for every caller. The durable knobs (persistent_cache,
cache_root, measure) keep behaviour names because they outlive v1.

⚠️ Critical assumption, flagged not resolved. The graduation plan assumes v1 is eventually
removed. That is not decided — no owner, no date. It requires the frameworks to stay migrated,
new TRT-LLM work to land against the runner contract rather than v1's cache, and a major version
to spend. If any fails, the honest outcome is indefinite coexistence with a preferred default, and
§5 should say so instead of carrying a removal plan that never runs. This PR does not depend on
that assumption
: one entry point is worth having under either outcome — permanent coexistence is
exactly when two front doors drift most.

User-facing docs, the gap flagged on flashinfer-ai#3861. docs/autotuning.rst described the legacy
implementation only, and two sections were wrong once v2 exists: the Config Caching note warned
that concurrent writes "may result in lost updates" — precisely what v2's atomic per-entry publish
fixes — and Config Lookup Priority omitted the managed store. Both corrected, plus an
Autotuner v2 (Experimental) section and API-reference entries for v2_opt_in, persistent_cache,
cache_root, measure, MeasurementPolicy, autotune_v2_reload and autotune_v2 — three of
which are exported from the top-level package and had no user-facing documentation anywhere.

Design doc, updated in the same change: §2.1 describes dispatch (it previously claimed v2 was
"deliberately disjoint from autotune()"); §4 explains why the two share an entry point; §5 opens
with the assumption callout; §5.1 carries the option-space and future-edits comparison; §5.4 records
the docs as written rather than owed; the Scope line narrows to the v2 branch of autotuner.py.

🔍 Related Issues

RFC flashinfer-ai#3920 · MVP flashinfer-ai#3861 · design-doc policy flashinfer-ai#4334

🚀 Pull Request Checklist

Thank you for contributing to FlashInfer! Before we review your pull request, please make sure the following items are complete.

✅ Pre-commit Checks

  • I have installed pre-commit by running pip install pre-commit (or used your preferred method).
  • I have installed the hooks with pre-commit install.
  • I have run the hooks manually with pre-commit run --all-files and fixed any reported issues.

If you are unsure about how to set up pre-commit, see the pre-commit documentation.

🧪 Tests

  • Tests added — tests/autotuner/test_autotune_unified_api.py, 12 GPU-free cases covering
    the dispatcher, which is the new code: argument ownership and its error messages, the nesting
    guard, rollback on a rejected argument, validation ordering, one smoke test that dispatch reaches
    v2, and the alias's argument translation. Deliberately thin on v2 behaviour — that is unchanged
    and already covered by test_autotune_cache_v2.py; six cases written against the earlier
    merged-body design were deleted once dispatch made the boundary structural.
  • All tests passing — NOT VERIFIED. No torch/pytest in this environment, so the new tests
    and the existing tests/autotuner/ suite are unrun. Needs CI or a GPU box.

Reviewer Notes

  1. measure= now requires v2_opt_in=True (behaviour change from an earlier revision of this
    PR). It is a v2 concept and the dispatcher has no path that could honour it on the v1 branch.
    Say the word if you would rather it stayed permissive.
  2. persistent_cache without v2_opt_in=True raises — deliberate, but it is a new error for an
    argument name autotune_v2() already accepts.
  3. Is v1 still worth keeping? It is largely a TRT-LLM integration. If the argument for keeping it
    is absorbing upstream changes, that weakens once those changes must satisfy the runner contract
    anyway. Worth checking against a real recent TRT-LLM port — it decides whether §5's timeline is
    conservative or simply never.

YangXu1990uiuc and others added 20 commits July 22, 2026 03:18
…totuner package

Port of the autotune-cache-v2 branch onto current main (104 commits,
including the autotuner.py -> flashinfer/autotuner/ package split):

- search_cache branch 2.5 (managed store) rewritten in the per-runner-key
  style of flashinfer-ai#4004: hits validate runner_class_name, ProfilingCacheKey.file_key
  replaces the hand-rolled projection helper.
- choose_one publishes via cache_key.file_key / .runner_class_name.
- The CUPTI measurement route now lives INSIDE flashinfer-ai#3187's cardinality-invariant
  try block, so a rank using timer='cupti' still reaches the cross-rank
  all-reduce on success and on failure; the events_no_delay delay-kernel
  skip composes with the CC globaltimer backend.
- Review fixes: utf-8 encodings on store I/O (gemini), attach/policy-push
  moved after successful context entry so a failed autotune() entry leaves
  no side effects (coderabbit), TypeError guard for path-like
  persistent_cache (coderabbit), unused unpack (coderabbit).
- New fix found while dogfooding the port on SM100: re-attaching a
  different store (e.g. new measurement policy) now clears the decode memo
  so store A's winners are never served under store B's identity;
  regression test added.

226 autotuner tests green (24 v2 + upstream incl. flashinfer-ai#3187 AST guards);
GPU-validated on SM100: tune -> publish -> fresh-process serve from store.

AI-assisted (Claude Code).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase-separated per-candidate oracle measurement manufactured phantom
regret under thermal drift (58% on an ES board whose SM clock settles
1965->~660MHz within seconds).  The oracle now measures all candidates
back-to-back per round with a rotating start offset, records the SM
clock per round (pynvml), and scores against two explicit deployment
oracles — cupti (host-excluded span) and execution_mode='eager'
(host-included) — instead of relying on delay-budget overflow for the
eager measurand.

AI-assisted (Claude Code).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…re identity

Review hardening (external design review of 8338400):

- P1: partition the in-memory winner cache by measurement identity
  (policy manifest fields).  Winners tuned under one policy no longer
  short-circuit tuning under another in the same process; the on-disk
  isolation contract now holds in memory too.  Default flows stay on
  the plain profiling_cache (v1 byte-identical; save_configs sees
  exactly what it used to).
- P1: context-scoped store targeting via a per-thread store stack.
  Lookups/publishes inside autotune_v2 target THAT context's store;
  persistent_cache=False now truly forbids disk for the context even
  when an ambient store is attached; nested contexts with different
  identities never publish into each other's manifest.  The ambient
  store still serves context-free lookups after exit.
- P2: CUPTI partial-init failure now disables whatever activity kinds
  were enabled (and flush failure cannot skip disable / mask the
  original error).
- P2: rotating input batches are only allocated on the event path,
  halving transient tuning memory under cold_l2 with the cupti timer.
- harness: add the v2_eager selection policy (completes the
  policy x deployment matrix) and point the methodology reference at
  RFC flashinfer-ai#3920.

3 new regression tests (policy-switch reprofile, persistent_cache=False
disk-freeze, nested-context publish isolation); 229 GPU-free tests green;
GPU-validated: same-process policy sweep now yields 3 env dirs / 12
entries (was 1 dir with silent in-memory reuse).

AI-assisted (Claude Code).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sion guard, tactic revalidation

Closes the three gaps an issue-sweep review identified as prerequisites
for vLLM/SGLang deleting their own cache logic (persistence makes a bad
or divergent winner long-lived, so these cannot wait until after
migration):

- autotune_v2_reload(): rank-consistency finalize step.  Call on every
  rank after a post-tuning barrier: drops in-process winners and store
  memos so all homogeneous ranks re-read the store's canonical entries
  (the flashinfer-ai#3186 divergence class; composes with flashinfer-ai#3187's in-session
  all-reduce).
- Regression guard: inside autotune_v2 contexts, (runners[0], -1)
  always races as a candidate, so a tuned-and-persisted selection can
  structurally never lose to the default path (the slower-than-default
  class of flashinfer-ai#3537/flashinfer-ai#3622/flashinfer-ai#3409).  Plain autotune() selection stays
  byte-identical (guard scoped to v2).
- Runner-contract revalidation: on-disk hits consult an optional
  runner hook validate_tactic(inputs, tactic); a rejected tactic is a
  loud once-logged cache miss (retune or fallback), never a blind
  replay (the flashinfer-ai#3566 plan-rejected-at-execute class).

Plus contract regression tests: runner-list reorder replay, extras
collision (flashinfer-ai#3363/vllm#43119 class), guard win/persist/replay,
v1-unchanged, reload convergence.  235 GPU-free tests green;
GPU-revalidated (3-policy sweep + serve).

AI-assisted (Claude Code).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…in-memory hits

Round-3 review fixes:

- Winner partitions now key on the active/ambient STORE identity
  (root, env_hash) when a store is targeted — policy fields only for
  store-less policy tuning, legacy profiling_cache only for pure v1
  flows.  Bare serving after a policy context exits therefore serves
  the ambient identity's winner (never a stale legacy-memory winner
  tuned under a different identity), and switching cache_root
  repopulates the new root instead of silently reusing the old one.
  Corollary: winners tuned in a v1 context nested inside a v2 context
  belong to the v2 identity (they were measured under its policy) and
  are no longer written into the v1 JSON file; test updated to the
  corrected semantics.
- validate_tactic revalidation now also covers in-memory winners, so a
  runtime shape the runner rejects becomes a clean fallback in the
  same serving process (the no-restart flashinfer-ai#3566 case); test added.
- The default-candidate guard comment now states its honest scope:
  no-regression AT THE PROBE inputs; unrepresentative probes
  (flashinfer-ai#3622/flashinfer-ai#3537 class) are op-level work it does not cover.

Framework patch drafts pass an explicit MeasurementPolicy so the
deployment-mode choice (flashinfer-ai#3719) is visible at the migration call site.

238 GPU-free tests green (3 new); GPU-revalidated.

AI-assisted (Claude Code).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…th clock guard

- Op registry + multi-op capture: a composite API (MoE) that tunes several
  internal ops per call is swept per-op; input builders lifted from the
  corresponding tests so probes are valid (quant/scale layouts, routing).
- --max-candidates subsampling for large sets (MoE ~324), logged as PARTIAL.
- Validated on production B200: bmm_fp8 reproduces the deployment-match
  matrix; mm_fp4 is a clean negative control (<=2%); cutlass MoE tactic
  selection is ~0% at balanced routing on stable clocks.
- PERF_VALIDATION_GUIDE: added the clock caveat — an ES SM100 board threw
  43-96% phantom MoE regret by collapsing 1965->120 MHz mid-sweep; check
  min/max SM-clock ratio before trusting a row.

AI-assisted (Claude Code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…le_tuning bool

At a serving call site 'enable_tuning=False' read like 'discard the tuning
results', when it actually means 'replay tuned winners, skip profiling'.
Replace the negated boolean with a positive mode:

  autotune_v2(mode='tune')     # warmup: profile misses + publish (default)
  autotune_v2(mode='replay')   # serving: replay winners, no profiling

persistent_cache stays as the orthogonal disk toggle. Invalid mode raises
a clear ValueError. v2 is unmerged/opt-in so no back-compat shim is needed.

AI-assisted (Claude Code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ocess ambient

The ambient store (what bare, context-free serving calls read) is a
process-wide default set at warmup.  Entering a NESTED autotune_v2
context used to also rebind that ambient as a side effect, so a scoped
execute-time override (e.g. an eager-region context inside a
graph-serving process) leaked past its 'with' block and changed what
later bare calls selected -- a scope guard that fails to restore what
it touched.

Now only a TOP-LEVEL context (store stack empty on entry: warmup or an
explicit re-attach) sets the ambient; a nested context resolves + pushes
its store for the region and pops on exit, leaving the ambient default
intact.  Sequential top-level re-attach stays last-wins.

Splits _attach_managed_cache into _resolve_managed_store(set_ambient=).
Two regression tests: nested override does not clobber ambient;
sequential top-level is last-wins.  241 GPU-free tests green; tune/serve
revalidated on SM100.

AI-assisted (Claude Code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…o on switch

codex round-4 P1(runtime)+P2: store registry keyed by (root, manifest) reuses
store objects across identity switches; re-attach no longer clears the decode
memo (keyed by (root,env,file_key), so identities coexist and post-hydrate
lookups stay pure-memory; alternating graph/eager no longer re-reads disk).
clear_cache clears every store's memo. replay+persistent_cache=False documented
as memory-only replay. Regression test A->B->A serves from memory after disk
delete. Open (codex P1 design): explicit scope= vs nesting-inferred lifetime.
241 GPU-free tests green.

AI-assisted (Claude Code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t record

codex round-5 (converged): the store stack existed only to support nested /
scoped-override autotune_v2, which no real consumer does -- verified across
vLLM (single warmup context before capture, bare serving) and SGLang (one
context per warmed-once forward; speculative path sequential per-capture,
guarded by tuned_phases).  The stack was implicit state for a phantom use
case; scope=/top-level-vs-nested inference was its fragile surface.

Replace the per-thread store stack + measure stack with a single-slot
per-thread record (_V2Local: active/store/measure).  A persistent context
always attaches its store as the process ambient (last-wins); bare serving
reads the ambient; a context's own lookups read its slot (concurrent threads
in different identities stay isolated).  autotune_v2 no longer nests: v2-in-v2
fails fast (nesting a plain v1 autotune() is still fine).  No scope= param.

Kept (the real isolation): store registry, identity-partitioned winner cache,
identity-keyed decode memo, validate_tactic on hits.

241 GPU-free tests green; tune/serve revalidated on SM100.

AI-assisted (Claude Code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… flow

Reproduces the exact consumer sequence -- warmup context over a shape grid ->
autotune_v2_reload() -> bare (context-free) serving -- against a real bmm_fp8
op, short of a full model server.  Asserts warmup profiles + publishes, and
bare serving hits the store with ZERO re-profiling.

Runs BOTH measurement policies, including execution_mode='cuda_graph'
(per-candidate CUDA-graph capture profiling), which had no prior on-GPU
coverage.  Both pass on SM100: eager 7 entries, cuda_graph 7 entries (separate
env dir), bare serve 0 profile calls / 4 store hits each.  vLLM patch comment
updated: cuda_graph is the mode-sensitive-correct policy and is now
validated-working for bmm_fp8; flip once the full op suite's capture-safety is
confirmed.

NOTE: this validates the API SEQUENCE + cuda_graph profiling, NOT a full
vLLM/SGLang server e2e (no real model / scheduler / multi-rank NCCL) -- that
remains the open validation gap.

AI-assisted (Claude Code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… capture

codex round-6 (graph-compat audit): the autotuner synchronizes and, in graph
modes, captures its own private CUDA graph -- both illegal inside an outer
stream capture.  A tuning context accidentally left open around a framework's
model-capture would otherwise surface a cryptic CUDA error.  Guard the
profiling entry with torch.cuda.is_current_stream_capturing() and raise a
clear 'tune before capture, not inside it' message instead.

vLLM/SGLang tune-before-capture so this is defensive, but it turns a confusing
nested-capture failure into an actionable one.  242 GPU-free tests green;
GPU integration sequence (eager + cuda_graph) still clean.

AI-assisted (Claude Code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Consolidates the v2 design into docs/design_docs/, following the
structure of cute_dsl_kernel_cache.md: motivation, store layout,
environment identity, concurrency/crash safety, MeasurementPolicy,
runner contract, distributed story, alternatives, limitations.
Specifics are checked against flashinfer/autotune_cache.py on this
branch (manifest = _collect_metadata() + cache_schema + policy fields,
sha256[:16] env hash / [:24] op hash, {key, runner, tactic} entries).

Two sections go beyond restating RFC flashinfer-ai#3920:

- Relationship to the CuTe-DSL kernel cache (flashinfer-ai#3874): why the two
  caches cannot share a payload format -- opposite locking contracts
  (single-flight vs last-valid-write-wins, the latter required because
  ranks tune inside collectives), reproducible artifacts vs
  measurements -- and which mechanics should be shared anyway:
  env-record naming (meta.json vs manifest.json), one atomic-write /
  invalid-is-a-miss helper, one cache-clearing story.

- Graduation plan: autotune_v2 is a transitional name. At graduation
  autotune() becomes the v2 implementation, autotune_v2 becomes a
  deprecated alias, and the v1 spellings are retained as forwarding
  shims with cache=<path> honored as placement only. Names the four
  gates hidden behind "deprecate v1 afterwards" (framework release,
  validate_tactic adoption, execution_mode default, regret <= v1 on
  >=2 arches) and the major-bump constraint on removal, so the version
  number does not become permanent public API surface.

Also records why a separate entry point is needed: not the on-disk
format (autotune caches are already per-version disposable --
flashinfer_version is stamped by _collect_metadata() and hard-rejected
on mismatch, so no v2 process can encounter a live v1 file) but the
call-site signature (cache=<file> vs a placement-only root directory)
and the context-scoped vs process-attach lifetime change.

Flags that docs/autotuning.rst still documents v1 only and must be
updated in the change that swaps the implementation.

AI-assisted: drafted with Claude Code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cope

The title named only managed persistence, which under-sells the doc:
deployment-matched measurement and the runner contract are co-equal
parts of the v2 proposal in RFC flashinfer-ai#3920, not sub-topics of persistence.
Retitled to name all three, using the RFC's own phrasing. The
graduation plan stays a section (§5) rather than a title element --
it is the doc's most contested part but not one of its design pillars.

Also adds a "**Scope**:" header line naming the paths the doc governs,
so an agent or contributor editing flashinfer/autotune_cache.py or
flashinfer/autotuner/ can discover the doc without a central index.

AI-assisted: drafted with Claude Code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… scope

A design doc is only read if the code points at it. Adds "Design doc:"
pointers following the csrc/fused_moe/monomoe/ convention, deliberately
only where the code is v2:

- flashinfer/autotune_cache.py -- module docstring; the whole module is v2.
- autotuner.py _V2Local -- §2.1 (attach semantics, why v2 does not nest),
  with an explicit note that the doc governs the autotune_v2 state and
  hook sites in this file ONLY.
- autotuner.py _managed_cache field -- §2.1 (why attach is process-lifetime
  rather than context-scoped).
- autotuner.py managed-store lookup -- §2.4 (invalid entry is a miss, never
  an error; hits memoized per store identity).
- autotuner.py measurement-policy application -- §2.5 (policy is part of the
  store's environment identity).

No pointer at module level in autotuner.py: that file is overwhelmingly
v1, and a module-level citation would falsely claim the whole autotuner
is governed by a doc that describes only v2.

For the same reason the doc's Scope line is narrowed. It previously read
"flashinfer/autotuner/", which claimed the v1 API too. It now names
autotune_cache.py plus the v2 hook sites, and states explicitly that
autotune() / save_configs() / load_configs() are NOT covered -- that code
predates this doc and changing it creates no obligation to update it.
§5 is where the two converge, and the scope line widens the day v1 is
folded into v2.

AI-assisted: drafted with Claude Code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ships v2 under the existing top-level API instead of a second context
manager. `autotune()` gains an explicit `managed_cache` argument that
selects the persistence backend; `autotune_v2()` becomes a thin alias
that only spells the arguments differently.

    autotune(True)                        # legacy: memory only
    autotune(True, cache="cfg.json")      # legacy: JSON file cache
    autotune(True, managed_cache=True)    # managed store
    autotune(False, managed_cache=True)   # replay, no profiling
    autotune(True, managed_cache=False)   # forbid disk for this context

`managed_cache` defaults to None, so absent the argument behaviour is
byte-identical to before it existed. This is not the "subtle behaviour
change" a merged API risks: the backend is selected by an explicit
argument, and resolved ONCE on context entry before any state is
touched. Within a context the two cannot interleave -- the legacy file
path runs iff managed_cache is None, and the managed store is consulted
iff a context record was pushed. Mixing is prevented by construction,
which is the property the separate-function design was reaching for.

The two objections to merging are answered rather than accepted:

- Call-site signature: `cache=<path>` is NOT redefined. It keeps its
  exact v1 meaning; the store is placed with a distinct `cache_root`,
  and combining the two raises instead of guessing.
- Lifetime: process-lifetime attach happens only on the branch a caller
  explicitly opted into. `autotune(True)` and `autotune(True, cache=...)`
  keep block-scoped semantics.

Beyond naming, one front door is a structural brake on divergence. Two
context managers are free to drift -- separate arguments, lifetimes, and
mental models -- until "migrate to v2" means relearning the API rather
than passing a flag. One signature makes each divergence explicit and
reviewable where it is introduced, and makes eventual v1 removal a
deleted branch rather than a caller migration.

Dependency inverted: autotune_v2() previously delegated to autotune()
and then attached; now every behaviour lives in autotune() and the alias
just forwards. `_attach_managed_store()` builds the manifest (v1 env
metadata + non-default MeasurementPolicy fields) on the managed branch.

Design doc updated in the same change: §4 previously argued FOR a
separate entry point and now explains why the two share one; §5.1
records the merge as done and keeps the remaining graduation steps.

Tests: tests/autotuner/test_autotune_unified_api.py, 18 GPU-free cases
covering argument validation and rollback, backend isolation in both
directions, attach-survives-exit, replay, measure-without-persistence,
and alias/unified equivalence. NOT RUN LOCALLY -- no torch/pytest in
this environment; they need CI or a GPU box.

AI-assisted: drafted with Claude Code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Renames the selector from managed_cache to v2_opt_in, and moves the
"no disk this context" case onto its own persistent_cache argument.

The earlier tri-state (managed_cache None/True/False) overloaded one
name: False meant "use v2 machinery but forbid disk", which is not
"opt out of v2". Splitting the two makes each argument mean one thing
and mirrors the alias's existing shape.

The name is deliberately a version rather than a behaviour, because a
version-named flag announces its own expiry and retires along a path a
behaviour name (or a separate function) cannot offer:

  flip the default -> ignore the argument internally once no caller
  passes it -> delete it

with callers working at every step, each step independently verifiable
and revertible. A separate autotune_v2() function cannot be "ignored" --
importing it either resolves or raises -- so its removal is one
breaking edit for every caller. A behaviour name like managed_cache=
would read as permanent and invite the cargo-culting that keeps a
transitional switch alive forever. The durable knobs (persistent_cache,
cache_root, measure) keep behaviour names because they outlive v1.

Design doc updated in the same change:

- §2.1 no longer claims v2 is "a standalone context manager,
  deliberately disjoint from autotune()" -- that became false when the
  entry points merged.
- §5.1 gains the option-space comparison as a table, including the
  effect on FUTURE EDITS: with two entry points a change to shared
  machinery must be plumbed twice and drifts by omission, and a
  v1-only fix (e.g. a ported TRT-LLM change) is invisible to v2 unless
  someone remembers to mirror it. With one signature both land in view
  of the same reviewer. Divergence cost is paid per-change as a review
  question instead of later, all at once, as a migration.
- §5 opens with an explicit CRITICAL ASSUMPTION callout: the entire
  graduation plan assumes v1 is eventually removed, which is not
  decided, has no owner and no date. Lists what must become true
  (frameworks stay migrated; TRT-LLM work lands against the runner
  contract rather than v1's cache; a major version is available), names
  the honest alternative outcome (indefinite coexistence with a
  preferred default), and separates the unconditional part -- one entry
  point -- from the conditional part -- removal.
- Scope line now says the v2 *branch* of autotuner.py rather than the
  whole file; fixed a stale §4 cross-reference that meant §3.

Tests renamed to match. Still NOT RUN LOCALLY -- no torch/pytest here.

AI-assisted: drafted with Claude Code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Folding v2's logic INTO autotune() made one function hold both
implementations' state, which is exactly where a later edit can wire up
a subtle cross-path behaviour without anyone noticing. Restructured so
the entry point only chooses:

    if v2_opt_in:
        _reject_v1_only_args(cache)
        with autotune_v2(...):   # v2's implementation, unchanged, in
            yield                # autotune_cache.py
        return
    _reject_v2_only_args(cache_root, persistent_cache, measure)
    # ---- v1 implementation ----

v2's body moves back to autotune_v2() where it was. The v1 body no
longer contains a single line of v2 state. Interleaving is not merely
prevented, it is unrepresentable: the two never share a function scope.
No recursion -- autotune_v2 calls autotune() without v2_opt_in for the
shared machinery (bucketing, skip_ops, tuning-mode refcount).

Also switches validation to positive selection. `if not v2_opt_in:` was
an unbounded condition: it means "everything that is not v2" and would
silently absorb a third implementation the day one exists. Each branch
is now keyed on the implementation it names and rejects only the
arguments the OTHER one owns, via two small named helpers
(_reject_v1_only_args / _reject_v2_only_args), so adding an
implementation means adding a case rather than widening an existing
one. The earlier dict-and-loop version of this validation was dropped:
it was compact but not scannable, and unscannable validation is the
same hazard in a different place.

Behaviour change: measure= now requires v2_opt_in=True. It is a v2
concept and the legacy implementation has no measurement policy; under
the dispatcher there is no longer a path that could honour it on the v1
branch. Previously it was accepted without the opt-in.

Design doc §2.1 and §5.1 updated to describe dispatch rather than a
merged body.

Tests updated: measure-without-opt-in now asserts the raise, and a new
case covers a policy under v2 with persistent_cache=False.

Still NOT RUN LOCALLY -- no torch/pytest in this environment.

AI-assisted: drafted with Claude Code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six of the eighteen cases were policing a boundary that separate
function bodies now make unrepresentable, or duplicating coverage that
already exists in test_autotune_cache_v2.py:

  test_default_is_byte_identical_legacy  -> test_legacy_v1_path_does_not_create_v2_dirs
  test_v2_writes_no_v1_file              -> test_v2_disk_entries_cannot_leak_into_v1_file
  test_persistent_cache_false_forbids_disk -> test_persist_false_disables_disk
  test_attach_survives_context_exit      -> test_serving_after_context_exit_reuses_entries
  test_replay_mode_does_not_profile      -> test_hydrate_only_context_enables_reuse
  test_measure_policy_without_persistence -> test_persistent_false_context_never_touches_disk

They were written when v2's logic lived inside autotune() and a stray
edit really could have made one path observe the other's state. Once
the entry point became a dispatcher, v2's behaviour is reached through
the same autotune_v2() the existing suite already exercises, so the
duplicates only added runtime and a second place to update.

The alias/dispatch equivalence parametrisation drops from three cases to
two: dispatch now *calls* autotune_v2(), so equivalence is by
construction and the test only needs to pin the argument mapping.

What remains (12) tests the dispatcher, which is the actually-new code:
argument ownership and its error messages, the nesting guard, rollback
on rejected arguments, validation ordering, one smoke test that dispatch
reaches v2 at all, and the alias's argument translation.

AI-assisted: drafted with Claude Code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/autotuning.rst described the legacy implementation exclusively, and
two of its sections were not merely incomplete but wrong once v2 exists:

- The Config Caching note warns that multi-process use is "best-effort:
  concurrent writes to a shared cache file may result in lost updates
  from race conditions" -- exactly what v2's per-entry atomic publish
  fixes. A reader hitting that warning had no way to learn the fix
  shipped in the same release. Now qualified as describing the legacy
  file cache, pointing at v2.
- Config Lookup Priority omitted the managed store, which is a real tier
  in the resolution order. Added, and the list renumbered.

Adds an "Autotuner v2 -- Managed Persistence (Experimental)" section:
opt-in example, warmup/serving and hydrate-only flows, a legacy-vs-v2
comparison table (cache identity, unit of write, concurrent ranks,
crash mid-tuning, wrong environment), cache location and layout, the
non-persistence of entries across versions, measurement policy, and the
distributed story including autotune_v2_reload().

Adds API-reference entries for v2_opt_in, persistent_cache, cache_root
and measure on autotune(), plus MeasurementPolicy, autotune_v2_reload
and autotune_v2 -- three symbols exported from the top-level package
that had no user-facing documentation anywhere.

The v2 section leads with the transitional nature of v2_opt_in and
points at the design doc for what is not yet decided about v1's future,
so a reader does not mistake an opt-in flag for a stable configuration
surface.

Design doc §5.4 updated from "documentation debt" to what now exists,
keeping the two obligations that belong to later graduation steps.

AI-assisted: drafted with Claude Code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aleozlx

aleozlx commented Aug 4, 2026

Copy link
Copy Markdown
Author

Investigation: how vLLM and SGLang call the autotuner today

New in this comment

  • vLLM has a second autotune call site (flashinfer_sparse_mla_warmup.py) that framework_patches/vllm_autotune_v2.patch does not migrate. Because the draft's header says it deletes flashinfer_autotune_cache.py entirely, and that file's second caller imports two functions from it, applying the draft as written is an ImportError at import time — not a subtle regression. Analysis of why it was missed below.
  • SGLang can only detect "am I inside autotune?" by grepping the Python stack (pynccl_allocator.py:393 matches _flashinfer_autotune in traceback.format_stack()). No public predicate exists; it breaks on rename.
  • vLLM omits skip_ops from its cache key; SGLang includes it. Changing VLLM_FLASHINFER_AUTOTUNE_SKIP_OPS replays tactics tuned under a different skip policy — the vllm#43119 class in a different field. Free fix under the managed store.
  • Effort and sequencing estimate for us sending both migration PRs: diffs are ~half a day and a few hours; §5.2 gate 1 unpacks into four sequential steps, the first two ours and blocking (completeness check, export()/install()).
  • Wording tension in [RFC] Autotuner v2: FlashInfer-managed persistent cache and deployment-matched measurement flashinfer-ai/flashinfer#3920 Evidence 1: the vLLM flow it describes (rank 0 tunes → broadcast → load_configs) executes only at world_size == 1 at HEAD, where the broadcast is a no-op.

Corrections to the first revision of this comment

Re-verified, unchanged: every claim in flashinfer-ai#3920 Evidence 1 still holds at HEAD 2026-08-04, including the zero-external-use feature list.


Read against vllm-project/vllm@0b1c151 and sgl-project/sglang@5e6c37f (both HEAD, 2026-08-04), to check the migration surface this PR implies and re-verify the RFC survey at a newer HEAD.

Most of this was already established in flashinfer-ai#3920 and in framework_patches/vllm_autotune_v2.patch. Attribution is inline below so the genuinely new items are separable.

Correction to the first revision of this comment. It presented vLLM's multi-rank cache disable as a headline finding:
"This is the lasting damage from vllm#43119 and it is still in effect at HEAD — the RFC cites it, but it is worth stating that it is current, not historical."
That overclaimed. flashinfer-ai#3920's Exhibit A already says "still disabled on the general path today", and framework_patches/vllm_autotune_v2.patch removes the exact use_persistent_cache lines and calls out the win in its header. Re-verification added currency, nothing more. Also struck: "two projects, two hash functions ... all of it is FlashInfer policy that neither should have had to invent" — that is flashinfer-ai#3920's thesis, restated here as if new.

Confirmed, already documented

Finding Already in
vLLM runs use_persistent_cache = False whenever world_size > 1 — every multi-GPU deployment re-tunes in memory each start flashinfer-ai#3920 Exhibit A ("still disabled on the general path today"); framework_patches/vllm_autotune_v2.patch removes those exact lines
SGLang writes one file per rank (rank_tp{n}_pp{n}_dp{n}.json) and tunes on every rank flashinfer-ai#3920 Evidence 1, SGLang bullet
Zero external use of tuning_buckets, round_up, tune_mode=False, direct save_configs, FLASHINFER_AUTOTUNER_LOAD_FROM_FILE flashinfer-ai#3920 Evidence 1 — still true at this HEAD. (round_up greps 220× in vLLM; all unrelated arithmetic.)
vLLM reinvents hashing / atomic write / enable knob flashinfer-ai#3920 Evidence 1
export()/install() must land before framework migration flashinfer-ai#3920 roadmap ordering (item 4 before item 6)
vllm#43119 as the key-completeness exhibit flashinfer-ai#3920 Exhibit A

The only thing re-verification adds is currency: all of it still holds three weeks later.

Where the code lives (for the migration PRs)

vLLMwarmup/kernel_warmup.py (general warmup), warmup/flashinfer_sparse_mla_warmup.py (a second pass), warmup/flashinfer_autotune_cache.py (hash + path + atomic write), config/kernel.py + config/vllm.py (enable_flashinfer_autotune), envs.py (VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR, ..._SKIP_OPS), utils/flashinfer.py (re-export).

SGLangsrt/model_executor/runner/flashinfer_autotune.py (all of it), runner/base_runner.py (call site), srt/server_args.py (--disable-flashinfer-autotune, --flashinfer-autotune-skip-ops), srt/environ.py (SGLANG_FLASHINFER_AUTOTUNE_CACHE).

Full used surface across both projects: autotune(True, cache=<path>, skip_ops=<set>) and AutoTuner.get().load_configs(<path>). Nothing else.

New — not found in flashinfer-ai#3920 or the flashinfer-ai#3861 thread

1. vLLM has a second autotune call site, and the migration draft misses it.

kernel_warmup.py:145 calls flashinfer_sparse_mla_decode_autotune_warmup(worker) before flashinfer_autotune(...) at line 168. Both resolve the same resolve_flashinfer_autotune_file(runner) path and both open autotune(True, cache=str(cache_path)). They coexist only because v1's save_configs does read/merge/write against the on-disk file.

framework_patches/vllm_autotune_v2.patch migrates kernel_warmup.py only — zero occurrences of sparse_mla. That turns into a concrete breakage rather than an omission, because the draft's header states it deletes:

flashinfer_autotune_cache.py entirely, ~56 lines

and flashinfer_sparse_mla_warmup.py:10-12 imports two functions from that module:

from vllm.model_executor.warmup.flashinfer_autotune_cache import (
    resolve_flashinfer_autotune_file,
    write_flashinfer_autotune_cache,
)

So applying the draft as written is an ImportError at module import, not a subtle regression — it fails loudly, which is the good version of this problem. The fix is to migrate both sites or keep the helper.

Why it was missed — worth recording, because the obvious explanations do not hold:

  • Not because it postdates the survey. The file was created 2026-06-22 (vllm#43477, "Enable DeepSeek V4 and GLM-5.1 on SM120"); the draft is dated 2026-07-24 and the RFC survey says "HEAD, 2026-07".
  • Not a narrow grep. flashinfer_autotune(, with .*autotune(, resolve_flashinfer_autotune_file and load_configs all match both files. Only the very narrow fi_utils.autotune matches just kernel_warmup.py — the two sites import the same symbol differently (import vllm.utils.flashinfer as fi_utils vs from vllm.utils.flashinfer import autotune as flashinfer_autotune).
  • Most likely: the survey characterised a usage pattern, and the draft migrated the canonical instance of it. The sparse-MLA site follows the identical pattern — one-shot warmup, cache=<path>, leader broadcast, load_configs — so it changes no conclusion in [RFC] Autotuner v2: FlashInfer-managed persistent cache and deployment-matched measurement flashinfer-ai/flashinfer#3920. It is a missed instance, not a missed pattern, and it only becomes a defect because the draft claims to delete a module a second caller depends on.

The finding also illustrates §2.2's filename-as-identity argument concretely: two independent tuning passes were never really distinct cache identities, and only v1's read/merge/write hid it.

2. SGLang can only detect "am I inside autotune?" by sniffing the Python stack.

srt/distributed/device_communicators/pynccl_allocator.py:393:

traces = traceback.format_stack()
# Skip autotune stack traces
if any("_flashinfer_autotune" in trace for trace in traces):
    return

A symmetric-memory debug check suppresses warnings during tuning by matching a function name in the stack — it breaks the day that function is renamed. There is no supported predicate; is_tuning_mode is not public. Cheap addition to v2's observability surface.

3. vLLM omits skip_ops from its cache key; SGLang includes it.

SGLang appends "skip_ops=" + ",".join(sorted(skip_ops)) to its key material. vLLM's key is aot_compile_hash_factors(vllm_config) only, so changing VLLM_FLASHINFER_AUTOTUNE_SKIP_OPS reuses tactics tuned under a different skip policy — the same key-incompleteness class as vllm#43119, in a different field. The patch draft passes skip_ops through as a kwarg but does not touch keying. Under the managed store this disappears because we own the key, which is a concrete "you get a bug fix for free" line for the vLLM PR.

4. A wording tension in flashinfer-ai#3920's Evidence 1.

The vLLM bullet reads "rank 0 runs autotune(tune_mode=True, cache=<path>), broadcasts the raw JSON file bytes, every rank atomically rewrites the file and calls load_configs(path)." At HEAD that path executes only when world_size == 1, where the broadcast is a no-op. Exhibit A corrects it two bullets later, but Evidence 1 is the bullet most likely to be quoted in isolation, and read alone it implies multi-rank works that way. Suggest a half-sentence fix in the RFC.

Divergence between the two consumers

vLLM SGLang
Who tunes rank 0 (single-GPU only); all ranks uncached (multi-GPU) every rank, always
Cache file one shared file per config hash one file per rank
Hash inputs aot_compile_hash_factors(vllm_config) model path, dtype, quant, moe backend, tp/pp/dp/ep, hf config class, skip_ops
Distribution broadcast_object of raw bytes + load_configs none
Disable knob enable_flashinfer_autotune --disable-flashinfer-autotune
Reuse knob SGLANG_FLASHINFER_AUTOTUNE_CACHE=0 → timestamped runs/ dir

Effort estimate: us sending the migration PRs

Framing: neither project will prioritise migrating someone else's caching layer, and the patch draft in flashinfer-ai#3861 already implies we send both. So this is our cost plus two external review queues.

Diffs are small, mostly deletion

vLLM ≈ 150 lines removed / 30 added — flashinfer_autotune_cache.py deletes entirely (except mapping VLLM_FLASHINFER_AUTOTUNE_CACHE_DIRcache_root); flashinfer_autotune() goes ~70 → ~15 lines; flashinfer_sparse_mla_warmup.py sheds ~40 lines of broadcast/barrier/load_configs (once finding 1 is folded in).

SGLang ≈ 60 removed / 10 added — flashinfer_autotune_cache_path() (~45 lines) deletes, per-rank filenames go, SGLANG_FLASHINFER_AUTOTUNE_CACHE=0 maps to persistent_cache=False. Their all-ranks pattern is already what the store is designed for.

Coding: ~half a day (vLLM), a few hours (SGLang). That is not the cost.

What actually costs

export()/install() is unimplemented, and vLLM's broadcast does not need a shared filesystem. flashinfer-ai#3920 already orders item 4 before item 6; the point here is that it is a hard prerequisite, not a nice-to-have — without it we migrate vLLM's single-node case and they keep broadcast for multi-node, retaining most of what the migration was meant to delete.

vLLM's multi-GPU path is a behaviour change, not a refactor. Persistence is currently off there, so migrating turns caching on in the configuration everyone runs. Needs a rank-consistency and perf gate, not a green test suite.

The hard sell is key completeness, and it is the one thing still unguarded. vLLM disabled this because a key omitted a field. Re-enabling requires showing the class is structurally fixed — contract rule 5 plus the debug-mode completeness check, which flashinfer-ai#3920 lists as "proposed", not in flashinfer-ai#3861. The thing that got the feature disabled is the thing not yet mechanically guarded. Build the check before opening the vLLM PR, or the review stalls on exactly the point that killed it last time.

Pre-empt one conceptual objection in the PR body. vLLM's cache identity is model-level; ours contains no model identity at all, separation coming from the op key. Correct, but it looks wrong at a glance and is better answered in the description than in a review round.

Estimate

Coding Validation Wall-clock to land
SGLang ~4h light — already tunes every rank 1–3 weeks
vLLM ~1d heavy — multi-GPU perf + rank consistency 4–8 weeks, after export/install

Implication for §5.2 gate 1

"Frameworks migrated and released" unpacks into four sequential steps, the first two ours and blocking:

  1. debug-mode key-completeness check (proposed only)
  2. export() / install() (roadmap item 4, unimplemented)
  3. SGLang PR → review → release
  4. vLLM PR → review → release

Realistically multi-quarter. That does not change what this PR does — one entry point is right either way — but it should calibrate how the graduation timeline is written, and it reinforces the §5 assumption callout: worth making coexistence good rather than treating removal as imminent.

Line numbers read at HEAD 2026-08-04; both projects move fast.

aleozlx and others added 2 commits August 4, 2026 03:19
§3 compared the autotune store to the CuTe-DSL kernel disk cache (flashinfer-ai#3874):
why the two cannot share a payload format, and which mechanics they
should share. It answered a question that came up in review, but in the
doc it reads as a digression into a different subsystem -- a reader
arriving at "Autotuner v2" has no reason to care about JitSpec's locking
contract, and the section invited more confusion than it resolved.

Deleted, keeping the one part that actually explains an autotuner design
decision: §2.4's "no locks" bullet now says why single-flight is right
for the kernel cache and wrong here -- compiling twice wastes CPU,
whereas ranks tune inside collectives, so a cross-rank lock would
serialize warmup or deadlock it. That is the sentence a reader needs at
the point they wonder why publishes are unsynchronised.

The cross-cutting cleanup §3 proposed (one atomic-write /
invalid-is-a-miss helper, one name for the environment record, one
cache-clearing story) is real but belongs in an issue against the JIT
layer, not in this doc.

Sections 4-7 renumbered to 3-6; cross-references updated. Code comments
cite §2.1/§2.4/§2.5 only, so they are unaffected.

AI-assisted: drafted with Claude Code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…o feat/autotune-unified-api

# Conflicts:
#	docs/design_docs/autotuner_v2.md
@YangXu1990uiuc
YangXu1990uiuc force-pushed the yanxu/autotune-cache-v2-mvp branch from c701529 to c2f48b7 Compare August 20, 2026 18:31
YangXu1990uiuc pushed a commit that referenced this pull request Aug 20, 2026
…nfer-ai#4219)

## Issue
flashinfer-ai#4214
Addresses GDN-C1 / GDN-C2 / GDN-C3 / GDN-H1 from the GDN CuTe-DSL cache
audit (PR #1 of the suggested sequence).

## Summary

- Documented FP16 `q/k/v/a/b` on the BF16-state and FP32-state MTP
decode paths were silently reinterpreted as BF16 because the kernels
hard-code `cutlass.BFloat16` fragments. Convert those operands to BF16
at the kernel boundary (and stage non-BF16 caller `output=` on MTP).
- Add polymorphic operand dtypes (`A_log`, `dt_bias`, slot indices) to
the compile-cache identities so mixed-dtype sequences no longer collide.
- Stop returning a cached per-batch default `output` buffer from
BF16-state paths when `output=None`.
- Apply the same FP16 conversion on the WY output-only kernel; convert
slot indices to int32 when needed.
- Follow-up: non-BF16 MTP `output=` staging uses `output.to(bfloat16)`
(not `empty_like`) so negative-index padding rows keep
caller-initialized values.
- Assert documented `dt_bias` (bf16/fp32) and `initial_state_indices`
(int32/int64) dtypes at the public API.

## Test plan

- [x] New regressions in `tests/gdn/test_decode_delta_rule.py` (8
parametrizations): FP16 conversion, dtype / `dt_bias` interleaving,
default-output non-aliasing, non-BF16 `output=`, padding-slot
preservation, WY FP16
- [x] Those regressions fail on unmodified `main` (most on main) and
pass on this branch
- [x] Full `tests/gdn/test_decode_delta_rule.py`: **838 passed** on H100
NVL (`CUDA_VISIBLE_DEVICES=1`, `-x -vv`, ~38 min)
- [ ] GPU CI: `@flashinfer-bot run`

## Review

Independent re-review at `024e7c4f`: **approve-with-nits** (padding
critical fixed). Follow-up commits add comment trim + API dtype asserts.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
  * Improved validation for tensor data types, shapes, and index values.
* Fixed mixed-precision decode workflows, including proper BF16
conversion and preservation of requested output types.
* Prevented stale output reuse and preserved padding in partially filled
output buffers.
* Improved compilation behavior when switching between supported input
data types.

* **Tests**
* Added coverage for FP16/BF16 conversion, output handling, cache
isolation, index validation, and mixed-precision correctness.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
@YangXu1990uiuc
YangXu1990uiuc force-pushed the yanxu/autotune-cache-v2-mvp branch from f50a940 to f7760f2 Compare August 28, 2026 17:31
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.

2 participants