Skip to content

[FEAT] [RACE DETECTOR] Compiled mode: static shared-memory race detection over TTGIR - #476

Merged
mark14wu merged 8 commits into
race-detector-z3-demofrom
race-detector-compiled-mode
Jul 6, 2026
Merged

mark14wu merged 8 commits into
race-detector-z3-demofrom
race-detector-compiled-mode

Conversation

@mark14wu

@mark14wu mark14wu commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a compiled-mode race detector: a static shared-memory race analysis over the TTGIR emitted by Triton's own compilation pipeline, selected via RaceDetector(compile=True) or the new triton-compiled-race-detector CLI.

  • Acquisition: TTGIR is captured from the real compilation warmup (post_warmup_callback receives the runtime's own CompiledKernel) — never a hand-built ASTSource, so the analyzed IR is exactly the launched specialization, including the pipeliner stages. Analysis results are cached per TTGIR SHA-256.
  • Analysis: encodes the cp.async pipeline's wait-coverage discipline as SMT queries. UNSAT across every (copy, load) pair, trip count and grid is a proof of race freedom for the specialization; SAT yields a RAW report with a concrete witness. Anything outside the modeled fragment degrades to an honest unsupported — never a silent ok.
  • Execution model: the client is STANDALONE (ClientManager rejects composing it with other clients) and WARMUP_ONLY — TritonTrace.run skips the interpreter entirely and executes the REAL kernel, so host scripts keep their true semantics (live outputs, asserts, autotuning). This is load-bearing: the interpreter's in-place tl.core.tensor dunder patches leak past snapshot/restore and break a later real compile in the same process.
  • Device functions under the CLI: the CLI wraps every @triton.jit function — including device fns — into a TritonTrace, which the real code generator rejects as a callee. Real-compile windows run with trace module globals unwound to the underlying JITFunction (_unwrapped_jit_globals) and restored afterwards.
  • CLI: triton-compiled-race-detector <script.py> prints a per-kernel verdict — race-free (proof) / RACE with rendered reports / UNSUPPORTED with the reason.

Stacked on #361 (race-detector-z3-demo); base set accordingly. Supersedes #421 (closed as stale).

Testing

  • tests/end_to_end/test_compiled_race_detector.py — 22 tests: golden-TTGIR proofs, pipeliner-bug mutations that must produce witnessed RAW reports, unsupported-degradation cases, and CUDA trace-level tests including two regressions: a second REAL compile after a traced launch, and a kernel calling a CLI-wrapped device function (exec'd into a synthetic module namespace to mirror the CLI's module-level globals; an in-function def would bind the callee as a closure freevar, which get_capture_scope() overlays on top of __globals__, outside the swap window's reach).
  • CLI smoke-tested on a script with a module-level device fn: per-kernel verdicts printed, kernel outputs verified live.

mark14wu added 8 commits June 11, 2026 13:51
…tion over TTGIR

Shared memory is invisible to the dynamic (interpreter-driven) mode — it
is introduced by TritonGPU compiler passes. This adds the compile-mode
counterpart from the design plan (race_detector_compiled_mode_plan.md):
a static analyzer over the kernel specialization's TTGIR that either
proves the cp.async pipeline race-free for ALL inputs, grids and trip
counts, or returns a witness, or honestly reports unsupported.

Components (triton_viz/clients/race_detector/compiled/):

- ttgir_reader: line parser over the closed v1 op vocabulary
  (local_alloc/local_load/local_store/memdesc_index/
  async_copy_global_to_local/async_commit_group/async_wait, scf.for
  iter_args/yield, the addi/cmpi/select rotation chains, loc aliases).
  Text instead of the pybind walk because attribute literals are opaque
  to the bindings and to_linear_layout SIGABRTs on shared encodings in
  the 3.6.0 wheel. Anything touching a memdesc outside the vocabulary
  (ttng/Hopper, barriers, nested loops) marks the kernel unsupported.

- hb: the happens-before model. TTGIR has NO CTA barriers (Membar
  inserts bar.sync during lowering), so ordering comes from commit-group
  counting (async_wait num=N leaves at most the N newest groups
  outstanding) and the multibuffer rotation, whose (base + k) mod S
  closed form is extracted by abstract interpretation and CHECKED by
  exhaustive simulation of the parsed select chain — never trusted.
  Model boundary documented in the module docstring: v1 checks the RAW
  direction (copy -> load) under the Membar-barrier drift assumption;
  generic-generic pairs are Membar-ordered by construction.

- layouts: blocked owner-coords and swizzled-shared XOR-offset closed
  forms (transcribed from the 3.6.x C++ sources), with the broadcast
  (tile > shape) case folded in as the bit-field modulo; differentially
  tested against an independent basis construction and, when available,
  triton's own LinearLayout.

- smt_encoder: per (copy, load) pair, a Z3 query over symbolic
  iterations and a symbolic trip count — no loop unrolling: does the
  load's slot match a copy whose commit group the guarding wait does
  not cover? SAT yields a witness (iterations, slot, byte offset via
  the layout closed forms) and an optional SMT-LIB2 artifact; UNSAT
  over all pairs is a proof. Pathological inputs (SSA cycles, non-TTGIR
  text) degrade to unsupported, never crash or fake a proof.

- client: acquires TTGIR through the runtime's own warmup
  (post_warmup_callback receives CompiledKernel.asm) so the analyzed IR
  carries the real divisibility specialization — a hand-built ASTSource
  silently disables the pipeliner. Analysis is cached per TTGIR hash;
  the interpreted grid run is skipped entirely (standalone client).

Verified: the stock compiler pipelines for num_stages 2/3/4 are all
proven race-free (the model extracts S/P/g/num per variant), the stock
num=2 wait is exactly tight (num=3 already races), and every mutation
class fires with a correct witness — weakened/deleted waits, shrunk
stage dims, rotation off-by-one, dropped commit groups (exactly one
report). Golden TTGIR dumps pin the printer format; dynamic-mode suites
are untouched (234 passed).
…True)

The compiled-mode backend was only reachable by importing
CompiledRaceDetector directly. Route it through the public RaceDetector
factory instead: RaceDetector(compile=True) dispatches __new__ to the
compiled backend (flag on), the default / compile=False stays the dynamic
SymbolicRaceDetector, and flag-off is NullRaceDetector either way. Extra
keywords flow to the chosen backend (e.g. collect_smtlib=True).

SymbolicRaceDetector.__init__ now tolerates the compile keyword: when
__new__ returns a RaceDetector-subclass instance, Python re-invokes
__init__ with the original kwargs (still carrying compile). The compiled
backend is not a RaceDetector subclass, so no such re-invocation occurs
for it.

Trace-level tests use the new factory form behind an enable-flag fixture;
the plan doc's mode-composition note matches the implemented switch.
… unsupported, never silent ok)

Review of the compiled-mode detector found several places where an
unmodeled construct yielded a confident "ok" instead of "unsupported" or
a report — the opposite of the tool's contract. This hardens each:

- async_wait operand tokens are now carried and used. The HB model gates
  wait coverage per allocation: a wait that names explicit tokens but does
  not await a load's allocation no longer "covers" that load by num count
  alone. Resolution follows the iter_arg rotation (init + yield) and
  wait-chaining; unresolvable tokens over-report (sound) rather than
  proving. Dropping a wait operand now reports the uncovered allocation's
  loads instead of reading as a proof. Stock pipeliner IR names tokens
  consistent with num, so the proof is unchanged.

- async_commit_group raises UnsupportedTTGIR on a true regex non-match
  (e.g. operand-style commit from an unmodeled printer); the bare
  no-token form still parses as a real empty group. Previously a
  non-match was silently swallowed as empty, corrupting commit-rank
  accounting.

- A nested/conditional region inside the pipelined loop (scf.if, or a
  "} else {" continuation) raises instead of mis-tracking the
  loop/epilogue boundary via naive brace counting.

- A local_alloc after a local_dealloc (buffer reuse / allocation
  aliasing, which v1 does not model) raises. A terminal dealloc (stock
  epilogue cleanup) stays a clean proof.

- The compiled detector is STANDALONE: it skips the interpreted run via
  pre_run_callback() == False, which is all()-combined and would suppress
  a co-registered client's capture. ClientManager.add_clients now rejects
  composing it with other clients, and the self-contradictory module
  docstring is corrected.

- Documentation: the "byte-level proof over all inputs/grids" wording is
  downgraded to what is actually solved — a wait-coverage proof for the
  whole-tile cp.async RAW pipeline under the model boundary; the
  byte_offset is a post-solve representative witness. Aliasing claims in
  the plan are marked deferred/unsupported.

Tests: new coverage for the dropped-wait-token report, malformed commit
group, conditional region in loop, alloc-after-dealloc, and the
standalone-composition rejection. Stock proof and all existing mutation
tests unchanged.
…t bounds, stage-dim, report API, cache key)

- Validate slots against stage geometry in build_pipeline_model: a
  ConstSlot must index an existing stage [0, stages) and a RotatingSlot
  must wrap at the stage count, for both copies and loads. Inconsistent
  geometry (e.g. a depth-1 buffer still indexed at slot 1) now fails
  closed as unsupported instead of being analyzed under a broken buffer
  model.

- Allocation.has_stage_dim distinguishes a staged buffer (viewed by
  ttg.memdesc_index) from an un-staged one, even at stage depth 1.
  buffer_dims / stage_bytes key off it instead of stages > 1, so a
  single-buffered memdesc<1x...> no longer keeps a phantom leading
  dimension in the witness layout computation.

- CompiledRaceReport.race_type reuses the dynamic detector's RaceType
  enum (RaceType.RAW) instead of a bare "RAW" string, so consumers can
  branch on race_type uniformly across both modes.

- Cache analysis results under a stable SHA-256 of the TTGIR text rather
  than Python's process-randomized, non-collision-resistant built-in
  hash() — the cache holds proof/witness verdicts, a soundness boundary.

- Document the standalone trade-off on the RaceDetector(compile=True)
  factory: the compiled backend cannot be composed with other clients.

Tests: rewrite the shrunk-stage mutation as a well-formed single-buffer
race (still reported) and add a separate inconsistent-geometry case that
must be unsupported; add a disabled-flag factory test; assert race_type
against RaceType.RAW. Stock proofs and all existing mutation tests
unchanged.
…group

ttg.async_commit_group always prints its !ttg.async.token result, and that
result token is what an async_wait names (commit_by_result in hb.py keys on
it). A commit group with no SSA result is therefore malformed and
unreachable by any wait, so the reader now fails closed (UnsupportedTTGIR)
instead of fabricating an empty token. Adds a unit test pinning the
semantics.
… CLI entry point

- CompiledRaceDetector is WARMUP_ONLY: TritonTrace.run skips the
  interpreter entirely (no language patching, no grid loop) and executes
  the REAL kernel after the warmup compile, so the host script keeps its
  true semantics (live outputs, asserts, autotuning). Load-bearing, not
  an optimization: the interpreter's in-place tl.core.tensor dunder
  patches leak past snapshot/restore and break a later real compile in
  the same process.
- Real-compile windows run with TritonTrace module globals unwound to
  the underlying JITFunction (_unwrapped_jit_globals) so device-function
  callees wrapped by the CLI resolve in the code generator.
- New triton-compiled-race-detector CLI entry point; prints a per-kernel
  verdict (race-free proof / RACE with reports / UNSUPPORTED reason).
- Tests: trace-level tests run on CUDA and assert live outputs; add
  regressions for second-real-compile survival and for a kernel calling
  a wrapped device fn (exec'd into a synthetic module namespace to
  mirror the CLI's module-level globals — an in-function def would bind
  the callee as a closure freevar, which get_capture_scope() overlays
  on top of __globals__, outside the swap window's reach).
…-detector-compiled-mode

# Conflicts:
#	triton_viz/wrapper.py
@mark14wu
mark14wu marked this pull request as ready for review July 6, 2026 22:49
@mark14wu
mark14wu merged commit 803be1f into race-detector-z3-demo Jul 6, 2026
@mark14wu
mark14wu deleted the race-detector-compiled-mode branch July 6, 2026 22:49
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.

1 participant