Skip to content

[FEAT] [RACE DETECTOR] Z3-based race detector - #361

Draft
mark14wu wants to merge 271 commits into
mainfrom
race-detector-z3-demo
Draft

mark14wu wants to merge 271 commits into
mainfrom
race-detector-z3-demo

Conversation

@mark14wu

Copy link
Copy Markdown
Collaborator

Summary

This PR adds a solver-only Z3 happens-before demo for CAS-based synchronization in the race detector.

It intentionally keeps the scope narrow:

  • extends AccessEventRecord with minimal solver metadata
  • replaces the hb_solver.py placeholder with a small event-graph HB solver
  • adds synthetic unit tests for CAS release/acquire race vs no-race behavior

Why

This isolates one question for review: whether the HB model can express CAS release/acquire synchronization correctly.

It does not mix that modeling work with real Triton atomic capture, concrete atomic execution, or race-detector integration.

What Changed

  • Added minimal solver-facing metadata to AccessEventRecord with backward-compatible defaults
  • Implemented a solver-only HB model with:
    • scalar event lowering
    • program-order edges
    • minimal synthetic CAS read-from relation
    • synchronizes-with edges
    • transitive happens-before closure
    • race queries over unordered conflicting accesses
  • Added two synthetic tests:
    • unconditional load after acquire CAS is reported as racy
    • load guarded on acquire-CAS success is not reported as racy

Explicit Non-Goals

This PR does not:

  • hook into real tl.atomic_cas capture
  • change triton_viz/clients/race_detector/race_detector.py
  • change SymbolicRaceDetector.finalize() behavior
  • modify the Triton interpreter path
  • implement a full coherence/read-from model

Validation

  • uv run pytest tests/unit/test_race_detector_hb_solver.py -q
  • uv run pytest tests/unit/test_race_detector.py -q
  • uv run pytest tests/end_to_end/test_race_detector.py -q

Notes

The CAS read-from relation in hb_solver.py is intentionally a PR-A synthetic relation for the solver-only demo. It is not presented as a full memory-model implementation.

@github-actions

github-actions Bot commented Apr 22, 2026 •

Copy link
Copy Markdown

Performance Benchmark

Benchmark main (min) PR (min) Change Samples
gemm 0.096s 0.102s +6.6% ⚠️ 20 / 20
gemm_oob 0.108s 0.114s +5.9% ⚠️ 20 / 20
indirect_load 0.019s 0.020s +3.4% 20 / 20
nested_loop 0.201s 0.206s +2.5% 20 / 20
block_pointer_loop_advance 0.113s 0.115s +1.6% 20 / 20
liger_jsd 0.133s 0.146s +9.4% ⚠️ 20 / 20
flaggems_layernorm 0.349s 0.356s +2.0% 20 / 20
swiglu 0.161s 0.176s +8.9% ⚠️ 20 / 20
cross_entropy 0.915s 0.936s +2.3% 20 / 20
fused_linear_jsd 0.202s 0.223s +10.3% ⚠️ 20 / 20
Total 2.296s 2.392s +4.2% N/A

Threshold: >5% regression flagged with ⚠️
Iterations: 1 warmup + 20 measured
Samples are shown as main / PR; long pytest benchmarks may use fewer samples.

@mark14wu mark14wu changed the title [race-detector] Add solver-only Z3 HB model for CAS-based synchronization [RACE-DETECTOR] Add solver-only Z3 HB model for CAS-based synchronization Apr 22, 2026
@mark14wu mark14wu changed the title [RACE-DETECTOR] Add solver-only Z3 HB model for CAS-based synchronization [FEAT] [RACE DETECTOR] Add solver-only Z3 HB model for CAS-based synchronization Apr 22, 2026
@mark14wu mark14wu changed the title [FEAT] [RACE DETECTOR] Add solver-only Z3 HB model for CAS-based synchronization [FEAT] [RACE DETECTOR] Z3-based race detector May 13, 2026
mark14wu added 14 commits June 10, 2026 20:29
… wrapping it symbolically

tl.static_range is compile-time unrolled: every iteration executes with a
concrete index, and host-side consumers depend on that — indexing a
pointer tuple (peer_ptrs[i]) needs a real __index__, which raised
'ValueError: cannot coerce ArithRef to int' under the symbolic iterator
(the long-standing test_tuple_pointer_item_selection failure). Wrapping
it also mismodeled the unrolled semantics.

_wrap_range now returns None for the tl_static_range spelling, so the
loop runs as a plain Python loop (the loop hooks already skip
non-RangeWrapper iterables) and each unrolled iteration records with its
concrete index. Side benefits verified: per-iteration concrete OOB checks
under the sanitizer, and atomic CAS/RMW inside tl.static_range is now
supported (unrolled iterations are not 'inside a loop'). tl.range and
plain range keep the symbolic iterator machinery.

Add a race-detector e2e test for an unrolled static_range cross-block
race.
…TTIR

Adds Sanitizer(compile=True), the torch-style dual-mode counterpart to the
eager interpreter-driven sanitizer. It analyzes the kernel's TTIR (acquired
through the real compilation warmup) once per specialization and instantiates
the out-of-bounds check per launch with concrete tensor metadata and scalar
argument values — proving in-boundedness for ALL inputs consistent with
those scalars and the grid, with no interpreted execution.

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

- ttir_reader: parses TTIR into an AccessGraph. Each tt.load/tt.store
  pointer is traced through tt.addptr back to a base pointer ARGUMENT; the
  access becomes an element-offset expression (a lazy term tree) over
  program ids, arange lanes, and the loop induction variable, with scalar
  args left as Param leaves for per-launch substitution. A make_range
  reused for a 2D tile's row and column (triton does this) is split into
  independent (ssa, dim) variables via expand_dims, so the footprint does
  not collapse to the diagonal. Indirect/gather addressing, block pointers,
  and nested loops raise UnsupportedTTIR so the eager mode can take over —
  never a silent wrong verdict.

- oob: per access, a Z3 query over the free variables with scalar args as
  constants — OOB iff SAT(mask AND (offset < 0 OR offset >= numel)) for the
  base tensor's element count. UNSAT over all accesses is a proof; SAT
  yields a witness with the byte violation address. The valid element range
  is the closed interval [0, numel-1], matching eager's inclusive bounds.

- client: CompiledSanitizer(Client). Warmup captures asm['ttir'];
  arg_callback collects per-tensor numel/elem_size/data_ptr (contiguous
  only) and scalar values, distinguishing constexpr from runtime int args;
  grid_callback takes the concretized 3-tuple. finalize runs the check and
  emits OutOfBoundsRecordZ3 with the TTIR source location, honoring
  abort_on_error / records like eager. Analysis is cached per TTIR hash;
  per-launch metadata is reset after finalize (arg_callback precedes
  grid_callback).

Factory: Sanitizer.__new__ dispatches compile=True to CompiledSanitizer
(a plain Client, not a Sanitizer subclass, so Python does not re-invoke
__init__ on the returned object).

Verified by an adversarial differential against the eager sanitizer over 15
affine kernels (masked/unmasked, ragged tails, wrong strides, 2D tiles,
broken masks, negative offsets, boundary-exact accesses): every verdict
matches, no false negatives or positives in the supported class; per-tensor
numel, constexpr-vs-runtime scalars, i64 extsi offsets, and inclusive
[0,numel-1] parity confirmed. The adversarial pass caught a nested-loop
false-negative (the guard keyed on a flag set only at loop close) — fixed
to reject nested loops; the same fix corrected the scf.for regex to
recognize accumulator-free store loops, now analyzed rather than skipped.

Add reader/oob unit tests and trace-level e2e tests; extend the golden
generator with the tile2d and gather kernels. Dynamic-mode suites
untouched.
…oop lower/step

Two P2 review findings on the compiled sanitizer:

1. Respect ENABLE_SANITIZER=0 in compiled mode. CompiledSanitizer is a
   plain Client (not a Sanitizer subclass), so trace's _is_sanitizer_client
   did not match it and the flag-off escape hatch never fired — an explicit
   Sanitizer(compile=True) under ENABLE_SANITIZER=0 still warmed up,
   analyzed, and could abort on OOB. The factory now collapses
   compile=True to NullSanitizer when the flag is off, exactly like the
   eager path, so trace() leaves the kernel untraced.

2. Model the loop lower bound and step. The OOB query bounded the loop
   induction value as [0, upper), ignoring the parsed lower/step, so for
   range(1, n) or range(0, n, 2) it checked iterations that never run and
   could false-flag valid launches. The loop free variable is now the
   0-based ITERATION INDEX: the induction value at iteration iter is
   lower + iter*step and a loop-carried pointer sits at offset0 + iter*delta,
   with iter constrained by lower + iter*step < upper. This is sound for any
   positive-step affine loop and unchanged for the common range(0, n) /
   range(0, n, BLOCK) cases (matmul: lower=0, step=1). Descending
   (non-positive step) loops are marked unsupported rather than mis-modeled.

Add unit tests for the lower/step iteration model (non-zero lower with no
false positive, step-2 skipping unrun iterations while still catching a
real even-iteration OOB, descending loop unsupported) and an e2e test that
ENABLE_SANITIZER=0 turns Sanitizer(compile=True) into NullSanitizer.
…-launch TTIR, honest unsupported docs

Three correctness/clarity findings from review:

1. Missing tensor metadata is now unsupported, not a silent skip. When a
   base pointer has no registered TensorMeta, check_access used to return
   None, which check_graph treated as 'this access has no OOB' — so an
   unchecked load/store could slip through and the launch still report
   last_status='ok' with empty records, a false proof. A static proof is
   only valid once EVERY access is checked, so this now raises
   UnsupportedTTIR (the client surfaces it as last_status='unsupported').

2. _pending_ttir no longer leaks across launches. It is the current
   launch's captured TTIR input; the persistent state is the parsed-graph
   cache (keyed by TTIR hash). It is now cleared at launch teardown
   (finalize's finally) and at warmup start, so a later launch whose warmup
   yields no TTIR falls to 'unsupported' instead of re-analyzing a previous
   kernel's graph against the current launch's metadata (wrong locs, or a
   wrong-graph false verdict).

3. Docs corrected: unsupported constructs (indirect/gather, block pointers,
   non-contiguous tensors, nested loops) are REPORTED as unsupported with
   empty records — not a silent wrong verdict, but also NOT an automatic
   eager fallback. v1 does not interpret unsupported kernels; the docstrings
   and package doc now say to run the eager Sanitizer() on them instead of
   claiming 'the eager mode takes over'.

Add regression tests: missing-metadata -> unsupported (not ok); stale TTIR
does not leak when a later warmup yields no TTIR; unsupported is
report-only with no auto eager fallback.
The language snapshot only recorded attributes that already existed
(`if hasattr`), but Triton's interpreter ADDS some attributes that have no
native counterpart (tensor.__bool__ / __index__; tl.core.tensor defines
neither). After restore those stayed installed process-wide, so a later
REAL compilation (the compiled sanitizer's warmup) resolved tensor truth
tests through the interpreter's _get_bool and crashed with
"'triton._C.libtriton.ir.value' object has no attribute 'data'" on any
re-launch of a kernel using tl.load/tl.store with a mask.

Snapshot now schedules absent attributes for removal (mark_removed), and
restore deletes them only if present, staying idempotent when the same
class is reachable via several language targets (tl.tensor is
tl.core.tensor). Root cause for 58/184 TritonBench_G_v1 files failing
under Sanitizer(compile=True) on their second launch.
…ation

Real warmup compilation walks the kernel AST to hash and inline referenced
device functions. When a kernel calls a @triton.jit helper that is also
wrapped in trace() (the CLI / wrap-every-jit pattern), the reference
resolves to a TritonTrace, which Triton's dependency walker rejects with
"Unsupported function referenced". Only compiled-mode clients trigger this
warmup; eager traces never compile and were immune.

During warmup, temporarily swap every TritonTrace reachable from the
kernel's globals for its underlying JITFunction, then restore. Same-module
helpers share the kernel's module dict; helpers imported from another
module are reached as `mod.helper`, so the unwrap also descends one level
into module objects in those globals (torch._inductor-style kernels).
Unblocked 22/184 TritonBench_G_v1 files under Sanitizer(compile=True).
Mirror of triton-sanitizer, wrapping every @triton.jit / @triton.autotune
kernel with Sanitizer(compile=True): instead of interpreting the kernel,
it warms up the real compilation to capture TTIR and checks out-of-bounds
statically, aborting with a report on a SAT witness. Unsupported
constructs (data-dependent addressing, block pointers, nested loops) are
reported as unsupported rather than aborting.

triton.heuristics is deliberately left unpatched, like the existing CLI
wrappers: a @triton.heuristics @triton.jit kernel then keeps its real
Heuristics around the traced inner jit, whose warmup path is the one the
trace runner drives correctly.
…t TypeError

The factory's __new__ popped `compile` from a local kwargs copy and then
manually invoked the target's __init__ — but when the returned object is a
Sanitizer SUBCLASS (SymbolicSanitizer / NullSanitizer), Python re-invokes
its __init__ automatically with the ORIGINAL call kwargs, compile=
included. SymbolicSanitizer.__init__ accepted only abort_on_error, so the
legitimate spelling Sanitizer(compile=False) raised TypeError; the manual
call also double-initialized the instance.

Drop the manual __init__ for subclass paths (the automatic one suffices)
and let SymbolicSanitizer.__init__ swallow the stray factory-only kwarg.
Regression test pins compile=False -> SymbolicSanitizer with the abort
flag honored.
…reader

A tt.load/tt.store syntax variant the regexes do not match, or an
unmodeled side-effecting memory op (tt.atomic_*), previously fell through
to the generic handling: a store has no SSA result so it was silently
dropped, and an atomic's access went unchecked while its result became a
harmless-looking DataDep. Either way check_graph could then prove "ok"
without having checked a real access — an unsound proof.

Guard after the load/store matches: any remaining tt.load / tt.store /
tt.atomic_* line raises UnsupportedTTIR, so the launch is reported
unsupported instead. Tests pin both shapes (an attribute-dict store
variant and tt.atomic_rmw).

Also scrub leftover "dynamic-mode fallback" wording from UnsupportedTTIR
and check_graph docstrings: v1 reports unsupported and never auto-falls
back to interpreted checking.
@Jokeren

Jokeren commented Jun 20, 2026

Copy link
Copy Markdown
Member

Closing as stale during PR cleanup.

@Jokeren Jokeren closed this Jun 20, 2026
@Jokeren
Jokeren deleted the race-detector-z3-demo branch June 20, 2026 01:24
@Jokeren
Jokeren restored the race-detector-z3-demo branch June 20, 2026 13:32
@Jokeren Jokeren reopened this Jun 20, 2026
Two invariants make the branch sound without finalize-on-error routing:

- last_status is pessimistically "aborted" from construction and re-armed
  in arg_callback/grid_callback; only finalize() upgrades it. A launch that
  dies anywhere (mid-kernel, in the user's grid lambda, during arg
  conversion) can no longer be read as a clean "ok", with or without a
  harness-level finalize-on-error guard.
- SymbolicClient.grid_callback unconditionally reclaims the class-level
  scalar-concretize observer slots at launch start: any observer still
  installed there belongs to a launch that died before finalize() could
  uninstall it, so the next symbolic client launch self-heals instead of
  dispatching truthiness to a dead detector's hook.

Regression tests cover the mid-kernel crash, the pre-grid-callback crash
after a healthy launch, and the stale-observer leak into a following
Sanitizer launch.
mark14wu added 5 commits July 5, 2026 22:48
…istic status

The pessimistic-verdict change (last_status stays "aborted" from
grid_callback until finalize() produces a verdict) missed this unit
test, which calls _handle_access_check directly and asserted "ok" —
a state that direct-call path can no longer reach, since the capture
is never sealed and finalize() never runs. Assert the launch-start
"aborted" instead: a healthy check must neither degrade it to
"unsupported" (the block-ptr lowering regression this test guards)
nor prematurely upgrade it to "ok". The tile-footprint record
assertions — the substance of the test — are unchanged.
… truncated division

Extend the compiled-mode OOB checker's TTIR coverage and fix a signed-division
soundness bug:

- Model arith.remsi/minsi/maxsi as %/min/max in the reader and lower them to
  Z3 (remainder carries the dividend's sign; min/max via If).
- Fix arith.divsi lowering: it truncates toward zero, but Z3's Int `/` is
  Euclidean (floor for a positive divisor); divide magnitudes and re-apply the
  sign so negative dividends match hardware. Apply the same to remsi.
- Track scf.if regions with a region stack (replacing the single in_loop flag),
  handling `} else` and nested braces. Accesses inside an scf.if are marked
  guarded: checked as unconditional (UNSAT stays a sound proof), but a SAT hit
  becomes `unsupported` rather than a witness, since the branch condition is not
  modeled and the model may sit in an untaken branch. An unguarded SAT witness
  still takes precedence.
- Fail closed on other control flow (scf.while, cf.*) instead of flat-scanning
  it as if unconditional.
- Accept multi-result SSA operands (`%acc#N`) so stores of a for-loop result
  value parse instead of failing closed.
# Conflicts:
#	tests/golden/ttgir/generate_golden.py
#	triton_viz/core/frontend/base.py
#	triton_viz/core/frontend/triton.py
#	triton_viz/core/trace.py
#	triton_viz/wrapper.py
Rename race_detector_compiled_mode_plan.md to race_detector_static_hybrid_plan.md
and reorganize into three parts:

- Part I (new): the conceptual skeleton — one solver, one claim ladder
  (T0 all-symbolic / T1 params-concrete), five terminal states, front-end
  reachable regions, the per-term tier selector, and the three hybrid
  information channels (concrete injection, witness replay, differential
  cross-check).
- Part II: the shipped TTGIR shared-memory track, carried over near-verbatim
  with milestone status (M0-M3 landed, M4/M5 outstanding).
- Part III (new): the planned TTIR global-memory track — design decisions
  (TTIR, solver reuse, T1 as primary target), verified in-tree asset
  inventory, steps S1-S5 with exit criteria, timeline and risks.

Update the two code docstrings that referenced the old filename.
mark14wu and others added 30 commits September 7, 2026 16:25
Combine immutable precheck cache admission, strict runtime dependency transport, and fail-closed enumeration cloning. Preserve existing grid and input-content claim scopes while preventing stale or changed execution state from yielding a clean result.
Discover constant arithmetic terms only from original conjuncts and independently certify every equality before substitution. Retain the equalities for exact SAT models, preserve all grid and content premises, and leave query budgets and fallback policy unchanged.

Bound optional certification work, retain the original formula on failed certificates, and cache only equivalent rewrites of complete immutable queries. Add scope, zero-divisor, conditional-mask, context, and failure-path regressions.
Substitute both concrete program-instance tuples into every assertion before each existing exhaustive fallback check. Retain the original PID equalities in SAT models so reported witnesses still satisfy the complete original query.

Preserve all cases, constraints, feasibility handling, timeout budgets, fallback ordering and proof extent. No new decision cache is introduced.

Validation: 220 focused solver, enumeration, query-cache, compiled-global and div/rem tests pass. A matched classic-MM intra-instance query diagnostic keeps all 32 cases and reduces 39.50 seconds to 0.071 seconds; the 992 cross-instance cases remain about 2.8 seconds. These are extracted-query measurements, not full-kernel timings.
Replace a free integer array's sole distinct read with one shared scalar
only when the complete query has no other use of that array. Bind each
array to the corresponding constant array so native models still satisfy
the original assertions, including nested reads through other arrays.

Use simplify, solve-eqs, and general SMT with model conversion for matching
queries. Preserve caller timeout settings and return to the original path
when eligibility or preparation fails. No query scope, captured contents,
grid, or memory-model premise is changed.

Add 34 focused checks for native witnesses, projected satisfiability,
masking, repeated and nested reads, scalar fallback pins, resource-limit
unknown, contexts, rejected array uses, and preparation exceptions.
Derive optional facts only from original top-level constant array equations after exhaustively checking a contiguous integer interval and distinct values. Preserve all original assumptions, guard every fact by the known interval, and bound optional pair generation without restricting any query.

Validate fifteen unit cases covering absent or conditional cells, duplicate values, out-of-domain reads, independent arrays and contexts, quantified reads, partial byte overlap, and bounded lemma growth. Existing copy-by-destination query diagnostics retain the any-grid SAT result and accelerate the original launch-scoped UNSAT query.
Use exact single-read array elimination only after all original query premises have been normalized. Preserve original solver budgets, immutable cache keys, and native model reconstruction through PID enumeration. Strengthen only the existing UNSAT precheck with guarded facts entailed by snapshot cells already asserted in that query. Add integration regressions for original launch bounds, broader-grid collisions, repeated targets, and solver reset behavior.
Simplify again after constant-array equation elimination and select the
general SMT backend's arithmetic solver 2 for this isolated-array path.
The full KDA diagnostic exposed unstable arithmetic-solver-6 behavior even
though the original complete query and array eligibility were unchanged.

Keep native model conversion, original query limits, and general SMT
support. Do not assume QF_LIA or add any grid, input, or domain premise.
Add nonlinear SAT/UNSAT and uninterpreted-function witness checks.

Validation: 37 focused tests pass, including resource-limit unknown and
preparation fallback. Three bounded replays of the actual captured query
returned SAT with valid original models using arithmetic solver 2; full
kernel adoption remains subject to the integrated measurement.
…alidation

Retain certified divisor folding, complete PID substitution, and guarded snapshot lemmas in production. Complete KDA profiles at both array strategy revisions did not improve the static-stage runtime, despite fast isolated queries. Remove the production array factory hooks and retain its exact transformation and model regressions under evaluation/solver_prototypes for reproducible investigation. Preserve original proof domains, query budgets, and all fallback cases.
Add an optional solver-local cache for immutable AST applicability facts. Repeated queries without variable integer divisors reuse negative subtree results while every positive case retains query-local certification, original premises, and the existing certificate budget.

Retain AST and context references to prevent identifier reuse, and conservatively defer unsupported syntax to the original normalization path. Add regression coverage for shared DAG traversal, changing guards, independent contexts, deep expressions, unsupported syntax, and certificate budgets.

Validation: 18 guarded-division unit tests passed; git diff --check passed.
Reuse immutable AST summaries, original table certificates, and guarded range and injectivity expressions across changing conflict pairs. Revalidate certificates when asserted cells change, and preserve quantifier, context, launch, and substitution boundaries.

Keep uncached standalone extraction and the existing pure-Select rewrite cache. Add production-precheck scope regressions and counters proving common cells are parsed and certified once. Validation: 92 focused unit tests passed.
Pass immutable guarded-divisor and snapshot summaries through the production query paths and reset their lifetime on each race-finding invocation. Keep current full premises, model ordering and fallback budgets unchanged.

Add integration regressions for changed guards, mutated table cells, revoked launch pins and cache isolation. The shared-core and compiled/dynamic end-to-end selection passes 337 tests with 7 skips.
Summarize literal cell equations over plain array symbols as leaves while preserving all traversal through complex array expressions. Reuse cached read summaries to defer certificates when no eligible read is present, without changing guards, premises, or lemma results.

Reduce repeated z3py wrapper calls during cell recognition. Cover deferred certification, changing read sets, conditional equations, and nested reads in complex array terms; 90 focused helper and production precheck tests pass.
Try the existing relaxation of original necessary conditions before deriving optional snapshot facts on the simplify-first path. Only UNSAT discharges an access pair; inconclusive or failed attempts retain the original guarded query and its independent 500 ms budget. Reuse a normally completed check when no lemmas were generated.

Keep mixed-radix processing, caller fallback budgets and proof scope unchanged. Add regression coverage for unknown results, optional exceptions and unchanged guarded checks. Validation: 100 focused unit tests and all applicable pre-commit hooks pass.
Let evaluation borrow a unique parsed graph only when its exact source, ladder level, multipath mode, identity and mutable graph state still match the original parse. Keep binding retention opt-in and independent of production verdict computation.

Preserve the original parse fallback and True/False/None gate results after failures, ambiguous captures or graph changes. Keep diagnostic gate work outside static.time_s. Add parser-count, lifecycle, mutation, timing-boundary and real static-analysis equivalence coverage; 126 related unit tests pass.
Reuse the pure-Select path's existing normalized conditions across the weak and guarded attempts while keeping original conditions as snapshot certificate inputs. Return directly on literal false relaxations without constructing QF_LIA solvers. Retain the weak-first policy already present at 12fe429, including exception fallback, empty-lemma reuse and independent 500 ms budgets; this change adds no SMT attempts.

Preserve snapshot gates, unsupported-input exception boundaries, general radix processing and independent feasibility. Add source-premise, normalization-count, solver-count, custom-context and fallback regressions. The relevant selection including existing lazy-precheck tests passes 149 tests, and all repository checks pass. No kernel performance claim is made.
Gate full read discovery on shallow top-level cell candidates and defer fact assembly when no current symbolic read applies. Certify each relevant array using its complete current original facts, preserving standalone lemma order, context isolation, and mutation behavior.

Reuse each immutable AST child tuple across recognition, read discovery, and fact assembly. Preserve the literal-cell shortcut from f5a8466 and leave the existing solver checks and budgets unchanged. Add structural operation-count and boundary regressions; 149 focused unit tests and all applicable pre-commit hooks pass.
Reuse successful TTIR parsing for gate metadata, avoid irrelevant snapshot certification and repeated AST child enumeration, and reuse normalization with literal-false precheck exits. Preserve the independently landed weak-first policy at 12fe429. Each change retains the original query semantics and carries targeted correctness and operation-count tests.
Accept the exact triton root module alongside its descendants so next_power_of_2 and cdiv retain their checked ConstexprFunction identities. Preserve callable source, code, defaults, closures, parent-child equality and fail-closed handling.

Add six regression instances for serialization and rejected replacements. All 73 related tests pass; both affected FLA launches pass real READY admission and complete L2 with proved@enum. Record the source-bound checks while leaving the active frozen rerun unchanged.
Balance the 70 labeled TritonRaceBench cases at 35 racy and 35 race-free without modifying historical cases. Add independent output and registration checks and record the correctness arguments and analysis limitations. Existing pinned paper measurements remain unchanged.
Distinguish atomic-address refusals caused by the symbolic grid's counter
wrap bound, then rebuild the complete static query at the supplied launch
extent without relaxing counting guards. Bind existing grid expressions,
check every relevant pair and feasibility, and retain launch-only proof
provenance and the original analysis conditions.

Add 16 regressions covering vector batch tickets, real overflow and other
counter guards, cross-instance and duplicate-lane races, NumPrograms
binding, vacuity, unknown feasibility, and content qualification.

Validation: 146 focused solver/frontend tests passed; the 16 new tests
passed again after formatting; all applicable pre-commit hooks passed.
The unchanged trb013_batch_ticket_no case now returns proved@T1-launch
through the native complete-system harness at grid 4.
Admit the exact ordinary-CAS fragment through the existing conditional
write, reads-from and synchronization machinery. Preserve operand
observations in copy-local renaming and explicitly lower boolean values
to zero or one.

Refuse loaded operands, arithmetic, unmodeled observations and width
changes rather than inventing CAS success or publication values. Keep
generic reader behavior and the existing awaited-CAS admission unchanged;
cast checks only gate ordinary CAS and add no scan for non-CAS kernels.

Cover role-specific release/acquire CAS, CAS unlock, failed-CAS nonwriting,
failure-acquire, semantic/scope mutations, operand dependence, overflow
and cast-chain refusal. Preserve all benchmark kernels and labels.
Separate operational control conditions from execution-domain assumptions
when deriving atomic value dependencies. Retain legacy interpreter masks
and loop premises, and preserve addresses, RMW operands and CAS conditions.

Record preceding await candidates and all observations used by their exit
conditions. Add same-copy control edges only under the await, target and
source activities, so exclusive branches do not create false cycles while
real relaxed wait/publication cycles remain forbidden.

Keep all termination premises and the independent feasibility obligation.
Add 21 regression cases covering independent/sequential waits, branch joins,
expected-value dependencies, domain predicates and genuine RF/value cycles.

Validation: 255 focused tests pass; all applicable pre-commit checks pass.
The definitive pinned manifest rejected every non-rehearsal roster whose
size differed from the 1242 rows frozen before cf099aa added the seven
race-free TritonRaceBench repair variants. The committed corpus now
enumerates 1249 rows over the same 17 corpora, so the frozen count moves
to 1249; rehearsal manifests, row identities, sidecar checks and every
other admission rule are unchanged. PINNED_RESUME.md states the new count.

Validation: the pinned run, resume and state unit tests pass.
Reuse whole-storage and identical-view digests only for ordinary physical tensor views within a single identity calculation. Hash continuous byte buffers directly, preserving layout, aliases, reinterpretation, mutation detection and exceptional-view behavior. Add compatibility and work-count tests; the 67 focused dynamic transport and runtime-dependency tests pass.
Add an explicit preload launcher with fresh row workers and fresh
analysis children. Bind each completed row to real child wait receipts,
verify the service domain before acceptance, and retain fully charged
session setup, shutdown, interruption and recovery records.

Freeze launcher/environment/source identities and validate original
receipt hashes again at publication. Add prepare-only creation so a run
can be frozen without dispatching a service. Keep the ordinary launcher
as the default and leave private mmap disabled.

Validation: 80 mocked launcher/controller/resume unit tests and the
normal pre-commit checks pass. Real integrated lifecycle checks and
full L2 execution await the user's explicit start order.
The TritonRaceBench cuTile track covered 62 of the Triton roster's rows;
the seven race-free repair rows added at cf099aa had no twin. Port them
with the corpus conventions: same row name, ground-truth label, pattern,
grid and argument contents, atomics one to one with sem/scope written as
MemoryOrder/MemoryScope, captured on the same cuda.tile 1.5.0, torch and
sm_89 as the original rows. The 62 pre-existing captures are unchanged
byte for byte, and no pre-existing verdict moves.

Six of the seven Triton kernels order the payload against the
synchronizing atomic with tl.debug_barrier; batch_ticket_queue needs no
such order. cuda.tile has no fence, and its token pass does not order
everything unconditionally: same-array accesses chain directly, and a
RELEASE or ACQ_REL atomic receives join_tokens of the preceding memory
operations while a RELAXED or ACQUIRE-only atomic receives none. Every
twin rests on the second edge or needs no order at all, and
trb026_fenced_tile_handoff_no is the corpus's first row whose label
rests on a cross-allocation token edge. The module docstring records the
rule; the previous wording ("supplies the order unconditionally") was
wrong and is corrected.

trb026_reread_unfenced_yes and trb026_guarded_no_producer_fence_yes stay
Triton-only: they are racy BECAUSE a fence is absent, and their cuTile
port is textually identical to an already-registered row carrying the
opposite label. The track therefore covers 69 of 71 rows and the frozen
definitive roster moves from 1,249 to 1,256 configurations; runs pinned
before this commit keep their own manifests.

check_tritonracebench_cutile_twins.py validates, independently of any
detector verdict, the catalog and twin pairing, the token chains each
label rests on (with a negative control on two racy rows that must have
no releasing atomic), and ten launches per new row against the outputs
the Triton twin is specified to produce. At L2 three rows prove and four
abstain on reader-fragment boundaries that already bound pre-existing
rows: indirect-address for the ticket queue, cas-value for the three
with an ordinary non-spin CAS. No new row reports a race.

Validation: 391 focused pinned/cuTile unit tests pass; the twin checker
passes with --gpu; all applicable pre-commit hooks pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWiTd5LAf1x9kgU5Uf66N7
cuda.tile writes a bitonic partner index as an integer xor and a radix key
as a shift, straight into an address. The reader refused those rows with
indirect-address while their Triton twins, which reach the same addresses
through floordiv/mod, were decided; the corpus even carries the control,
ctb_top_k_selection___bitonic_step_kernel, a bitonic step written
arithmetically that proved all along.

_fold_bitwise rewrites an integer bitwise operation into the existing
affine fragment when the second operand is a known integer of a shape
whose identity is exact: a shift by a known count, a mask of 2^k - 1, and
a xor with a single bit. Bin("//") and Bin("%") truncate toward zero, so
floor division and floor modulo are written out rather than assumed, which
makes every identity exact for negative operands too. Anything else keeps
its DataDep and the refusal; nothing widens a footprint.

A shift count or mask is usually a scalar argument known only from the
captured launch, so parse_cutile_ir now takes those values and marks a
graph that consumed one param_pinned. t0_linearity_gate refuses such a
graph, so the ANY-params claim is never made from a rewrite that holds for
one launch's parameters; a lowering from a literal pins nothing.

Also fixes a soundness hole found by the new tests: raw_binary_bitwise
bound and_/or_ to a boolean conjunction regardless of result type, so an
integer and_ in an address became a modeled boolean term instead of an
abstention. The branch is now guarded on a boolean result.

Measured: every row of both cuTile corpora rerun at L2 (130 rows). Exactly
two verdicts change, both abstain to race-free (proved@T1): the two
bitonic_sort steps. No other verdict, no proof rung, and no Triton-track
row moves. tilebench_cutile goes from 47 proofs and 14 abstentions to 49
and 12. Record: evaluation/CUTILE_BITWISE_ADDRESSING.md.

Validation: 2041 unit tests pass; all applicable pre-commit hooks pass; the
lowering identities were checked against Python's operators over 192,240
values including negative and large ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWiTd5LAf1x9kgU5Uf66N7
cuda.tile has no `for` construct in its final IR for a python `while`. A
kernel written as `while m_tile < n_tiles: ...; m_tile += 1` lowers to the
while-form `loop` construct, with the counter as a loop-carried value, the
test as the first body operation, and the exit as a `break` in that test's
else arm. The reader refused every while-form loop carrying a non-token
value, because a carried value in general means a data-dependent trip
count. The counter shape is not that: it has an ordinary range trip count,
and the Triton twins of the same operators, which spell it `for m_tile in
range(...)`, were decided all along.

_counted_while_shape matches that shape on the IR text, before any state is
touched, and hands _lift_counted_loop the bounds range(init, bound, K).
That function is the former body of _handle_for, now shared, so a counted
while and its `for` twin build the same AccessGraph: one LoopInfo slot, the
counter bound to LoopVar, other carried values bound to DataDep, the same
zero-trip rule, and the same _serial_loop_boundary obligation on the body.

Every clause of the match is a soundness obligation. The test must be the
first body operation (no do-while); exactly one integer scalar slot is the
counter; the bound names no carried parameter, and is read on the first
body line so SSA dominance puts its definition outside the loop; `then` is
a bare yield and `else` a break whose operands repeat the carried
parameters in order; no other break targets this loop and the body's only
terminator is its trailing continue; the counter advances by a positive
integer constant at the top level of the body. Single exit matters most,
because the zero-trip rule deletes in-loop accesses rather than widening
them, so a loop that can also leave early would lose real accesses. A break
inside a nested `if` region still exits the enclosing loop, so _nesting_scan
treats only a nested loop, for, or combiner do block as shielding one.

Anything that fails a clause falls through unchanged, first to the AWAIT
shape and then to the byte-identical control-flow refusal. Stream-K's
first_wave_kernel is the live example: its outer iterator advances by min,
which is the data-dependent walk the refusal exists for.

Both cuTile corpora were rerun at L2 (130 rows). Exactly four verdicts
changed, all abstain to race-free (proved@T1): the three
linear_self_attention kernels and streamk_matmul full_tiles. All 69
tritonracebench_cutile rows are unchanged in verdict and reason, so the
await shape and the planted races are untouched. tilebench_cutile goes from
49 proofs and 12 abstentions to 53 and 8. A parse-level sweep of both
corpora in both modes (260 outcomes) shows nine differences: those four
rows plus block_sparse_attention, whose refusal advances from control-flow
to the loaded tile index that Route 2 owns.

Two unit tests carry the negative control: a counted while writing tile
bid*N + i, token-chained, proves race free, and the same kernel with the
block id dropped still reports the race. Six more pin the refusals and the
inherited cross-iteration obligation. Record in
evaluation/CUTILE_COUNTED_WHILE_LOOP.md. The pinned evaluation numbers are
unchanged; a cuTile population including these proofs needs a new pinned
run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWiTd5LAf1x9kgU5Uf66N7
Route 2 gave the Triton track a source of values for addresses that depend
on a loaded value (evaluation/ROUTE2_SNAPSHOT_SELECT.md): an integer load
with a modeled mask binds a Loaded term, the encoder turns it into a Select
over the tensor's pre-launch contents, and the proof is content-qualified.
The cuTile track had neither half. Its reader bound DataDep for every
loaded value, and its captures predate the value-capture rule, so all 166
tensor descriptors were value-free. Six real-operator rows and five
benchmark rows abstained for that reason while their Triton twins decided.

_loaded_binding mirrors the TTIR reader's loaded_binding clause for clause:
bound only under multipath (L2), so L0 and L1 keep DataDep and every
refusal message is byte-identical; a float pointee or a DROPPED mask keeps
DataDep, because only a modeled mask can keep a masked-off lane apart from
the snapshot value. cuTile has no `other` operand, so it comes from the
source: a partition view's padding_mode=ZERO supplies Const(0),
UNDETERMINED and NEG_INF leave the lane unspecified, and load_pointer takes
its padding_value when that is modelable. Both cuTile captures now record
the address snapshot under the Triton track's own bound
(ADDRESS_SNAPSHOT_MAX_ELEMENTS), and _cutile_bindings passes it into
GlobalTensor gated on L2 exactly as CompiledRaceDetector gates the capture.

Both corpora were re-captured on the pinned TileBench checkout with
cuda.tile 1.5.0, all 45 operators and all 69 benchmark rows, zero failures.
The merge adds ONLY the new value fields and refuses on anything else: 61
rows and 183 fields for tilebench_cutile, 69 rows and 170 for
tritonracebench_cutile, zero problems, with the IR text and every
pre-existing descriptor byte-identical and no row added or removed.

A claim-strength fix came out of it. param_pinned, introduced with the
exact bitwise lowering, was a WHOLE-GRAPH flag set by any rewrite that
consumed a scalar param's captured value, and the T0 gate refuses such a
graph. That was safe while those rewrites only happened in address chains,
because a loaded operand was DataDep and the fold refused. With Loaded
bound, radix sort's (key >> bit) & 1 folds, and the two count_ones_in_block
rows fell from proved@T0 to proved@T1 although the value is only counted,
never used to address. _fold_bitwise now records the rewritten TERM and
_pinned_claim marks the graph only when one reaches a position the claim
rests on: an address, mask, path, exit predicate, atomic operand or loop
bound. The bitonic rows still pin and the gate still refuses them.

At L2 tilebench_cutile goes from 53 proofs and 8 abstentions to 57 and 4,
the four being block_sparse_attention and cross_entropy at proved@T1+content
and both destindex rows at proved@T1-launch+content. tritonracebench_cutile
goes from 24 race-free / 24 race / 21 abstain to 26 / 27 / 16, and every
one of those five rows now matches its oracle label, including trb010_gather_no
at proved@T0, the read-only-group rule Route 2 predicts. Level invariance
was measured rather than assumed: the re-capture also filled in init_values,
which is not gated on L2, so both corpora were rerun at L0 against the old
and new specs with zero differences in verdict, terminal or reason.

The scatter litmus in cuTile IR carries the negative control: a permutation
proves content-qualified, one duplicated index reports exactly one race, no
snapshot refuses by name, and single-path reproduces the pre-Route-2 message
verbatim. Record in evaluation/CUTILE_ROUTE2_SNAPSHOT.md. The pinned
evaluation numbers are unchanged; adopting any of this needs a new pinned run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWiTd5LAf1x9kgU5Uf66N7
The real-operator cuTile experiment measured 61 configurations, one per
TileBench operator at case 0 of its own benchmark case grid, because the
capture ran run_benchmark_suite(op, case_indices=[0]). Every operator's grid
holds twenty or more cases, so a second configuration is an already-authored
input shape rather than a new kernel or a fabricated one.

tilebench_cutile_capture gains a --case-index flag: a non-zero index stores
the record under the case name <op>_case<N> and records case_index and
case_params, so the configuration reads off the corpus without the TileBench
checkout. The corpus module builds those as ctb_<op>_case1__<kernel>.

The seven are chosen by rule, not by convenience: every SINGLE-KERNEL
operator whose case 1 changes a shape or a structural parameter rather than
only the element dtype. Single-kernel keeps one operator contributing exactly
one configuration; a shape change makes the configuration genuinely new.
Eight operators qualify and quantize_global is left out, being the same
change on the same shape as fused_activation. The seven are flash_attention
(seq_len 1024 to 2048), flash_decode (seq_len 2048 to 4096),
block_sparse_attention (M 512 to 1024), dequantize_rowwise (cols 512 to
1024), kl_divergence (cols 1024 to 2048), matmul_int8 (K 1024 to 2048) and
fused_activation (n doubled). Four compile to different CuTile IR; the other
three compile to the same text but launch differently, changing the grid or
the array extents the flattened shape parameters carry, so all seven encode
differently.

All seven were captured on the pinned TileBench checkout with cuda.tile 1.5.0
on the RTX 4090, zero errors, one kernel record each. The merge refuses on
anything but an addition: every pre-existing case entry byte-identical, no
new record duplicating an existing one under the capture's own fingerprint,
and the total landing on 68. It reported 7 rows added, 68 total, zero
problems.

Rerun at L2 the corpus is 68 rows, 64 proofs and 4 abstentions. All seven new
configurations prove, and none of the 61 pre-existing rows changed verdict or
proof rung. The four abstentions are the ones the abstention-closure work left
open: histogram_partial against the address-snapshot bound, both radix_sort
rows through tile_scan, and stream-K's data-dependent first_wave walk. The
frozen pinned roster goes from 1256 to 1263.

These rows have no same-operator Triton twin, since the tilebench corpus is at
case 0, so they sit outside the cross-DSL differential; the corpus module says
so. Record in evaluation/CUTILE_SECOND_CONFIGURATIONS.md. The pinned
evaluation numbers are unchanged: the paper's real-operator group may be
restated as 68 with 64 proofs and 4 abstentions only after a pinned run
adopts it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWiTd5LAf1x9kgU5Uf66N7

This branch has not been deployed

No deployments
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