Skip to content

Measure the memory hierarchy and threadgroup memory, which reorders the target model - #31

Merged
AndreSlavescu merged 2 commits into
mainfrom
agx-memory-hierarchy
Jul 30, 2026
Merged

Measure the memory hierarchy and threadgroup memory, which reorders the target model#31
AndreSlavescu merged 2 commits into
mainfrom
agx-memory-hierarchy

Conversation

@AndreSlavescu

Copy link
Copy Markdown
Owner

meTile recorded one bandwidth number — 120.6 GB/s streaming from DRAM — and it isn't the whole story.
The gap surfaced by accident while scoping attention projections: a 786 KB weight read at 254 GB/s, twice
the recorded ceiling, so something above DRAM was serving it.

The hierarchy

working set GB/s vs DRAM
≤ 2 MB 2386 19.8x
4 MB 555 4.6x
8 MB 192 1.6x
32 MB 134 1.1x
≥ 64 MB 121–128 1.0x

The knee at 2→4 MB is a factor of four in one step. That makes fitting a working set the largest lever
in the target model by some distance
— choosing the matrix unit over scalar is worth 2.4–3.7x, and
instruction scheduling is capped at 1.09x and measured unreachable above MSL. A tiling that fits and one
that misses are not the same kernel, and until now the compiler had no way to know. read_bandwidth_gbps,
resident and tiling_gain give a pass something to consult.

Threadgroup memory is barely faster, and much more fragile

The shared-memory passes all assume it's the fast place to put data. Measured against a resident device
read over the same 32 KB:

stride shared device
16 B 3361 2749
128 B 1216 2079
144 B 2322 2341
256 B 605 2004
512 B 437 1641

Contiguous, staging wins 1.22x — a far smaller margin than the usual scratchpad assumption, so no pass
can justify staging on bandwidth alone. And across strides the shared arm spreads 7.69x against the
device arm's 1.80x, which inverts the habit of treating shared memory as forgiving and device memory as
the thing needing careful access. Here device memory is the forgiving one.

144 bytes is one vector larger than 128 and reads nearly twice as fast, so the collapses are bank
alignment, not distance — 32 banks of 4 bytes puts every lane on the same bank at 128.

This confirms _optimal_pad

It was written from a stated model of 32 four-byte banks and never measured. It pads to an odd stride,
odd strides don't collapse, so the pass was right for the reason it claimed. A test ties the two together
so they can't drift apart.

Three controls, and two wrong turns

  • Loop collapse: the pass loop re-reads the same addresses, so the backend could fold it into a
    multiply. Tripling traffic triples elapsed time at every size; doubling passes doubles it at every
    stride (1.88–2.00x). It isn't folded.
  • Saturation: the sweep must reach the known 120.6 GB/s at large sizes or the thread count is too low
    and everything is a lower bound. It reaches 120.5, and the probe reports the check rather than assuming.
  • Monotonicity: a larger working set is never served faster. This caught the resident regime twice —
    256 KB reading slower than 512 KB, then 512 KB slower than 1 MB. That's the pass loop's bookkeeping
    taking a bigger share at small sizes, so the regime is recorded as one number (2386, a floor) rather
    than four that would claim resolution the probe doesn't have.
  • A wrong diagnosis: a span of 8 vectors measured half speed, so I added a padded arm to break the
    conflict. It got worse — the unpadded span was 7 and +1 moved it onto 128 bytes. Padding is an
    arithmetic result, not a direction.

686 pass. Lint and vulture clean.

🤖 Generated with Claude Code

AndreSlavescu and others added 2 commits July 30, 2026 00:17
… target model

meTile recorded one bandwidth number, 120.6 GB/s streaming from DRAM, and it is not the whole story.
The gap surfaced by accident while scoping attention projections: a 786 KB weight read at 254 GB/s,
twice the recorded ceiling, so something above DRAM was serving it.

Sweeping working-set size finds the levels:

    <= 2 MB     2386 GB/s     19.8x DRAM
    4 MB         555          4.6x
    8 MB         192          1.6x
    16 MB        161          1.3x
    32 MB        134          1.1x
    >= 64 MB     121-128      1.0x

The knee at 2 to 4 MB is a factor of four in one step. That makes fitting a working set the largest
lever in metile/target/agx.py by some distance: choosing the matrix unit over scalar is worth 2.4x to
3.7x, and instruction scheduling is capped at 1.09x and measured unreachable above MSL. A tiling that
fits and a tiling that misses are not the same kernel, and until now the compiler had no way to know
the difference. `read_bandwidth_gbps`, `resident` and `tiling_gain` give a pass something to consult.

Two controls, because a 19x claim earns them. The pass loop re-reads the same addresses, so the
backend could in principle collapse it into a multiply and the fast numbers would be fiction: tripling
the traffic triples the elapsed time at every size, which a collapsed loop could not do. And the sweep
has to reach the known 120.6 GB/s at large sizes or the thread count is too low to saturate and every
number is a lower bound on something else -- it reaches 120.5, and the probe reports the check rather
than assuming it.

The resident regime is recorded as one number rather than four, and finding out why was the useful part.
It measured 1545, 2006, 2138 and 2386 GB/s at 256 KB, 512 KB, 1 MB and 2 MB: bandwidth *rising* with
working set, which cannot be a property of a cache. It is the pass loop again -- a smaller working set
means fewer inner iterations per pass, so bookkeeping takes a larger share, and the effect shrinks as
the set grows. Only the 2 MB figure is close to uncontaminated and it is a floor. Publishing four
numbers would claim a resolution the measurement does not have.

A monotonicity test over the table is what caught that, twice: first the 256 KB entry reading slower
than 512 KB, then 512 KB reading slower than 1 MB. A larger working set is never served faster, so a
table that says otherwise is measuring the harness.

683 pass. Lint and vulture clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…culation was right

The shared-memory passes -- cooperative loads, padding, swizzling, double buffering -- all assume
threadgroup memory is the fast place to put data. On this part that needed checking, because device
memory is not slow when resident: 2386 GB/s under 2 MB against DRAM's 121. The question is not whether
threadgroup memory is fast but whether it beats the cache that would have served the same bytes.

It does, barely. 3361 GB/s contiguous against 2749 for the same read from resident device memory, so
1.22x. That is a much smaller margin than the usual assumption about scratchpad memory and it means no
pass can justify staging on bandwidth alone.

What it gains in peak it gives back in fragility. Across per-lane strides the threadgroup arm spreads
7.69x and the device arm 1.80x, which inverts the usual habit of treating shared memory as forgiving
and device memory as the thing needing careful access. Here device memory is the forgiving one.

The collapses are bank aliasing, and the sweep separates that from any size effect:

    stride    shared   device
     16 B      3361     2749
    128 B      1216     2079     <- power of two
    144 B      2322     2341
    256 B       605     2004     <- power of two
    512 B       437     1641     <- power of two

144 bytes is one vector larger than 128 and reads nearly twice as fast, so this is alignment rather than
distance. Thirty-two banks of four bytes puts every lane on the same bank at 128.

That confirms `_optimal_pad`, which was written from a stated model of 32 four-byte banks and never
measured. It pads to an odd stride, odd strides do not collapse, so the pass was right for the reason it
claimed; a test now ties the two together so the measurement and the pass cannot drift apart.

Getting here took a wrong diagnosis worth recording. A per-thread span of eight vectors measured about
half speed, which looked like a conflict, so an arm was added that padded the span by one to break it up.
Padding made it worse -- the unpadded span was seven and adding one moved it *onto* 128 bytes rather than
off. Padding is not a direction, it is an arithmetic result, and plus one is not automatically safe. The
sweep replaced the guess.

Controls throughout: elapsed time scales with the pass count at every stride, min 1.88x max 2.00x on
doubling, so no loop is being collapsed.

686 pass. Lint and vulture clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AndreSlavescu
AndreSlavescu merged commit 11f7b33 into main Jul 30, 2026
2 checks passed
@AndreSlavescu
AndreSlavescu deleted the agx-memory-hierarchy branch July 30, 2026 07:26
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