Skip to content

fix(analysis): bill shared windows once, report lane fill, and label memory records - #258

Open
sablin39 wants to merge 12 commits into
tile-ai:mainfrom
sablin39:fix/analysis-shared-window-billing
Open

sablin39 wants to merge 12 commits into
tile-ai:mainfrom
sablin39:fix/analysis-shared-window-billing

Conversation

@sablin39

Copy link
Copy Markdown
Contributor

Why

What

  • Billing: an InsertSlice whose offsets name no mesh coordinate (directly,
    through a capture, or through loop bounds) and a Zeros of a mesh-replicated
    buffer divide the counted unit's share of a target-owned level by the
    position count, so total bills the window once. Mesh-coord offsets and
    topology-owned levels (smem/rmem) keep per-position billing. Probe after:
    predicted-ns 63,452, roofline unchanged; a 2x-destination variant drops
    1,478,217 -> 72,463; a 132-of-132 variant 3,604,839 -> 63,452.
  • Lane fill: a program-side occupied figure — the most positions any mesh
    the selected function executes activates at each topology level. The text
    header prints lanes=40/132 only when occupied differs from declared; the
    JSON wave always gains "occupied". wave_of semantics untouched.
  • Dim binding: evaluate(..., dims=) threads supplied extents through every
    function boundary (a conflict with an argument-pinned shape errors naming
    both values); check --dim now reaches evaluation. A dim declared only in a
    dead binding errors honestly, naming the dims the program does reach and who
    declares them.
  • Reports: - as out PATH streams text or JSON to stdout; every # memory
    line is labeled function=… role=entry|stage covers=self+reached with a
    test pinning text @total == JSON functions[i].totals; check --json
    refuses a missing parent directory at parse time, naming the path.

Contract

  • docs/spec/analysis.md: a write every position performs alike at a
    target-owned level is billed once (the total = per-unit x positions
    invariant itself unchanged); the lanes field and occupied definition;
    the Function-line grammar and the rule that per-function lines MUST NOT be
    summed. docs/spec/cli.md: - writes to stdout; check --json refuses a
    bad target at parse time.
  • Goldens regenerated and verified per-op: exactly the nine replicated
    insert_slice occurrences moved for billing; tutorial diffs are label-only.

Risk

  • Division rounds the per-unit share up, so total can overshoot one window
    by less than one position's share.
  • An offset whose per-position placement hides behind an opaque call keeps the
    old over-billing rather than inventing a division.
  • tests/installed/smoke_analyze.py edits are unverified (needs an installed
    package).
  • Pre-existing at base, not chased: analyze --compute-cost on a mesh-less
    composed module fails (missing cost context); --memory on the same
    StopIterations in wave_of.

sablin39 and others added 12 commits October 21, 2026 15:49
## Why

- First artifact of tile-ai#239: an insert_slice occurrence stated its whole update
window as every executing mesh position's share, and a zeros of a
mesh-replicated buffer stated the whole buffer; total = per-unit share x
positions then billed one gmem write once per position. On a 40-of-132 lane
stream probe (12.5 MB buffer, 320 KB window per trip) the insert billed
r/w 12.50MB per trip at @ToTal and the zeros billed w 500.00MB, pricing the
program at predicted-ns=1117769 against a roofline floor of ideal-ns=13654.

## What

- Price at the level the bytes land in, not in the relation: footprint and
liveness legitimately need the whole window per position, so the fix lives
in call_traffic. When every position performs the same write -- an
InsertSlice whose offsets name no mesh coordinate directly, through a
captured binding, or through the bounds of an enclosing loop, or a Zeros of
a mesh-replicated buffer -- and the level is target-owned and the per-unit
share is the whole window, one position's share becomes the window divided
among the positions, rounding up, and total counts it once. Mesh-placed
windows ((cta.c, 0, 0), or loop bounds naming a coordinate), topology-owned
levels (smem/rmem), and shares already smaller than the whole keep
per-position billing: those bytes are physically each position's own. This
is the tile-ai#194/tile-ai#195 copy rule applied to writes nobody sharded: a sharded
boundary already divides the iteration space; a replicated one needs the
positions divided out at pricing time.
- Probe after: the insert bills r8.00KB/w8.00KB@cta,
r320.00KB/w320.00KB@total and the zeros bills w12.50MB@total;
predicted-ns=63452 with ideal-ns=13654 and the wave=132/132 header
unchanged. A 2x-destination variant drops 1478217 -> 72463; a 132-of-132
variant 3604839 -> 63452. Footprint, peak, logical traffic and compute-cost
are unchanged everywhere.
- tests/analysis/test_shared_window_billing.py pins every billing regime
(shared window, shared zeros, mesh-placed offsets, mesh-bounded loop,
private-level fill) plus function-total and footprint invariants. Two
goldens moved and were verified per op: test_analysis_families.py (the
TiledQKVProjection insert, whose 132 positions run one redundant loop nest)
and test_analyze_at_a_size.py's qwen3_1_7b_pd layer_decode total (exactly
the nine replicated insert_slice occurrences, each by window x
(positions - 1) with loop trips accounted).
- docs/tutorial/schedule.md regenerated: the grid/persistent tf.zeros bills
its 272MB buffer once (predicted-ns 264231132 -> 54324798 grid,
18317269 -> 10533340 persistent; the persistent insert keeps per-position
billing through its mesh-bounded loop). optimize/showcase/migrate tutorial
outputs are byte-identical.

## Contract

- docs/spec/analysis.md traffic and performance constraints state the
carve-out: total = per-unit x positions still holds; what changed is one
position's share of a window every position writes alike at a target-owned
level. Mesh-placed windows and topology-owned levels keep the whole window
as one position's share.

## Risk

- Division rounds the per-unit share up, so total can overshoot one window
by less than one position's share (64.07KB vs 64.00KB at 132 positions).
- The mesh-coordinate test reads HIR value structure only; per-position
placement hidden behind a call the walk cannot see through keeps the old
over-billing rather than inventing a division.
- tests/cli/test_cli.py::test_target_list_expressions_execute_and_show_accepts_their_identities
fails on the base commit as well when the file runs in order (passes
standalone); pre-existing, unrelated.
## Why

- Second artifact of tile-ai#239: a probe occupying 40 of 132 cta lanes printed
  wave=132/132 with no hint that most positions sit idle, so an
  under-filled mesh reads exactly like a full one. wave counts the
  positions the cost model bills; nothing in the report described fill.

## What

- The identity line and the JSON wave record now state the occupied lane
  count next to the declared one. Occupied is the most positions any mesh
  the selected Function executes activates at the topology level -- its
  own execution mesh and every mesh region it enters -- and 1 when the
  Function states no mesh. Text prints lanes=<occupied>/<declared> only
  when the two differ, so a full fill stays silent; JSON always carries
  the field. On the 40-lane probe the header now reads
  wave=132/132 lanes=40/132 while predicted-ns and ideal-ns are unchanged.
- wave_of itself is untouched: footprint and liveness depend on its
  counted-vs-declared semantics, so the new figure lives beside it rather
  than inside it.
- docs/spec/analysis.md states the lanes field and the occupied
  definition. The tutorials are regenerated; only the showcase pages move
  (their GQA stages run 1, 8, or 32 of 132 lanes, so six # analysis
  header lines gain lanes= and nothing else). The same regeneration syncs
  schedule.ipynb's stored outputs with the billing fix 8351857, whose
  numbers schedule.md already showed, and rewrites migrate.ipynb's cell
  sources in the render script's list form; neither changes any content.

## Verification

- tests/analysis/test_lane_fill.py: under-filled (lanes=40/132), full
  fill (silent), meshless (lanes=1/132), and sibling-region max fill, on
  the text header and the JSON wave record.
- tests/analysis: the two by_hand wave-dict assertions updated for the
  new field; the suite passes with them. tests/cli + tests/inspection:
  the retained-occurrences golden updated (occupied=132 there); the one
  unrelated target-list failure in that run passes in isolation.
- Tutorial goldens diff-checked per op: showcase changes are header-only.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Why

- tile-ai#252: a staged operator gives each stage its own tiling axes, and a
  chunk length can be an axis of a stage's own scratch without ever
  touching the entry's inputs. analyze already collects declarations from
  every reachable function, so a live callee-only dim binds there; but
  check then died at evaluation with "resolve_dim: unbound DimVar 'BT'",
  because the evaluator bound dims only from the entry's argument shapes
  and the CLI never threaded the --dim values into the run.

## What

- evaluate() takes dims= and carries them on the evaluation context;
  every function boundary merges them with what the argument shapes pin,
  so a supplied extent is visible inside whichever callee declares the
  dimension, across module boundaries. A supplied extent that
  contradicts an argument shape is an error naming both values; a
  dimension nothing supplied still reports unbound, as before.
- check threads each run's parsed --dim combination into the evaluator
  through CheckRequest.
- For a name that appears only in a binding the block dropped (the
  issue's reproducer: an unused scratch = tf.empty(Tensor[(ROWS, BT)])
  inside the callee), nothing in the program declares the dimension and
  binding it anyway would name nothing. Per the issue's own fallback the
  analyze error now says which functions declare dims: "... has no
  dimension named ['BT']; no function it reaches declares a dimension",
  or, when some do, "the dimensions it reaches are BT (stage), ...".

## Verification

- tests/fixtures/logical/callee_dim.py: the same helper in-module
  (Staged), across a module boundary (Rooted), and the dropped-binding
  case (DeadScratch).
- tests/analysis/test_dim_binding.py: live callee-only dims bind in
  check_program, same module and cross-module; the unknown-name error
  names the declaring function; the dropped-binding case gets the honest
  empty form.
- tests/evaluator/test_supplied_dims.py: the supplied mapping binds at
  the entry, crosses module boundaries, must agree with argument shapes,
  and leaves the nothing-supplied error intact.
- tests/cli/test_cli_check.py: check --dim BT=8 on a callee-only dim now
  runs to PASS; before this change it died with
  "resolve_dim: unbound DimVar 'BT'". The issue's analyze reproducer now
  prints "no function it reaches declares a dimension" instead of the
  entry-centric "it states []".
- tests/analysis + tests/evaluator + tests/cli + tests/ir + tests/dsl:
  574 passed; the 3 failures are environmental and reproduce at base
  8351857 -- two CUDA atom tests need the uninitialized
  third_party/cutlass submodule, and the target-list test trips on a
  stale persisted-registry entry in this venv (an nvidia.l20z probe
  target another workstream added; the test does not strip the
  "   added" marker target list prints for it).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ion records

## Why
- tile-ai#253, three defects in the CLI/report path: analyze's --json help said
  "print ... instead of text" while the command required PATH and wrote
  there, leaving stdout empty; a multi-function text report printed one
  # memory line per function with no marker of whose record it is or how
  it aggregates, and summing the lines over-counts because every record
  already counts everything its function reaches; check --json PATH
  validated nothing, so a typo'd directory cost a full comparison run
  before failing with a bare Errno.

## What
- analyze's out PATH accepts '-' for stdout, text and JSON alike; the
  --json help now says render, since PATH was always the destination.
- Each # memory line is labelled function=<module.qualified>
  role=entry|stage covers=self+reached: a record counts its function and
  everything it reaches, so the entry's line is the program total and the
  lines must not be summed. The text line's @ToTal figures are the same
  whole-program counts --json states under functions[i].totals; a
  regression test pins that.
- check --json validates its parent directory at argument-parse time; the
  refusal names the path.

## Verification
- New regression tests: '-' routes analyze text and JSON to stdout; the
  # memory labels and the text @ToTal == JSON totals agreement hold over
  a three-function closure; check --json refuses a missing parent at
  parse time as a usage error.
- pytest tests/cli tests/analysis tests/inspection: 477 passed, 1 failed
  -- the known target-list registry flake that fails identically at base
  8351857.
- Tutorials regenerated; every docs diff is the label insertion only (old
  content plus the label reproduces the new byte-for-byte).
- The byte-exact schedule golden
  gemm_8192x17408x5120_tma_store.analyzed.txt gained the same label; its
  fixture case passes.
- pre-commit clean.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@zhen8838

Copy link
Copy Markdown
Collaborator

Thanks a lot @sablin39 for this PR! The billing problem you found is real, and your reproducer made it easy to follow.

I took the PR over and pushed my changes directly to this branch, so you do not need to do anything more. On top of your commits:

  • check and analyze bind dims in one place (instantiate).
  • The per-line memory labels are removed, and byte units print as KiB / MiB / GiB.
  • The roofline and the timeline price gmem the same way: by footprint bytes and by the mesh that actually runs (wave=<active>/<physical>). This replaces the shared-window billing patch.
  • The per-CTA gmem bandwidth is capped by Little's law: bytes in flight ÷ memory.gmem.latency (353 ns on H200, from published measurements).
  • A shard-propagation fix, found when the optimize tutorial was changed to use several CTAs.

Thanks again for the contribution!

executed: tuple[str, ...]
metadata_types: tuple[type[IRMetadata], ...]
functions: tuple[Function, ...]
wave: tuple[int, int] | None = None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you add wave here instead of func metadata?

@@ -163,7 +163,7 @@ def build_parser() -> argparse.ArgumentParser:
)
_add_source_argument(analyze)
analyze.add_argument(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original description should be kept here, and the behavior should be corrected.



def test_a_dim_only_a_child_module_declares_binds():
check_program(Rooted, Rooted.entry_function(), dims={"BT": 8})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method should not be used to test. It should be tested through fixture and cooperate with the cli entrance.

assert timeline.end_ns - timeline.start_ns == -(-(_ROW * 2 * 1_000_000_000) // rate)


def test_one_position_is_bound_by_its_bytes_in_flight() -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this test and guard it by checking the analysis structure of fixtRue.

@@ -0,0 +1,95 @@
"""A window every position writes alike moves its bytes once.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Guard through fixtRue

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

2 participants