39 Commits

Author SHA1 Message Date
mukesh fc4747668e paper(§5): add roofline, capacity planning, parallelism selection subsections
Three new subsections at the top of §5, motivated by the deployment-sizing
questions the analytical visualization tool exposes:

- 5.1 Roofline Analysis — B*, L*, short vs long context batch effect
  (measured: 19× per-token cost reduction at short context, 28% at long)
- 5.2 Capacity Planning — 3-axis sizing (weights / KV / SLO), SLO targets,
  regime rules, deployment templates, per-axis playbook
- 5.3 Parallelism Selection — TP × CP × PP × DP × EP comparison,
  symptom-driven axis selection, add/stop criteria, misconceptions

Restructure:
- 05-gqa.tex trimmed to section header + intro
- Existing 5.4-5.7 content (placement, short/long ctx, composite) moved
  to 05x-fused-kernel.tex
- Summary extracted to 05z-summary.tex, cross-refs updated
- main.tex \input order sets the requested subsection sequence
- lmodern loaded to satisfy microtype font-expansion

Two new figures generated from tests/analytical_visualization/chip_roofline:
- roofline_short_context.png (S_kv=8K, batch wins)
- roofline_long_context.png (S_kv=1M, batch stalls at KV floor)
- Generator: tests/analytical_visualization/_gen_roofline_paper_figs.py

4 tables (regime rules, deployment templates, playbook, parallelism
criteria) use table* so they span both columns without overflow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-30 15:56:41 -07:00
mukesh bfe3e0e8d1 analytical-viz: parallelism rules — Section 11 TP vs CP shared-budget
New Section 11 'TP vs CP: the shared-budget tradeoff' on the
Parallelism rules tab. Captures the core insight that fixing TP·CP
doesn't equalize the two configurations — larger TP additionally
divides model-weight traffic, larger CP only divides the sequence
dimension.

Five sub-tables:

  11a. Same TP·CP = 16 → different weight sharding
       (TP=8/CP=2 reads 4× fewer weight bytes per PE than
        TP=2/CP=8 at the same KV division)
  11b. What each dimension actually shards
       (TP·CP·PP·DP × Weights·KV·Attention-math)
  11c. When to prefer TP vs CP (6 situations mapped to the winning
       axis with reasoning)
  11d. CP communication profile (prefill vs decode) — bytes/hop,
       overlap potential, real cost, notes on kv vs qoml variants
  11e. Decode cost decomposition mental model:
       T_decode = T_weight_read(TP) + T_KV_read(TP,CP)
                + T_TP_comm(TP) + T_CP_merge(CP)

Reframes the 'is CP heavier than TP' question as 'which cost
dominates T_decode, and which axis attacks it?'.

Smoke test asserts the section title appears exactly once.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-30 09:26:22 -07:00
mukesh 55653cbb3e analytical-viz: new 'Parallelism rules' tab
New last tab distilling the parallelism-selection discussion into
tables. Framed up-front as heuristics + hard constraints, not a
production procedure — with public counterexamples (DeepSeek-V3 PP16
no TP; vLLM PP-on-non-NVLink) that break the naive 'start TP at 8'
rule.

Ten sections, all table-first:

  1. The core principle (chattiest comm on fastest links)
  2. Parallelism techniques × comm profile (DP/TP/PP/CP/EP with
     Shards, Pattern, Frequency, Per-link volume as degree ↑,
     Hard ceiling)
  3. Dominant problem → first axis to try (11 rows: weight fit,
     activation memory, single-seq KV, many-seq KV, MoE experts,
     TPOT, aggregate throughput, multi-node, expert layer, global
     batch cap)
  4. When to add / when to stop, per axis (DP/TP/PP/CP/EP/FSDP/SP)
  5. Common misconceptions vs reality (9 rows: KV-head hard
     ceiling, always start TP at 8, EP comm falling, attn-DP
     shards KV for free, DP > CP always, PP inside domain
     worthless, memory is only feasibility, FSDP as substrate,
     pipeline bubble = (P-1)/m)
  6. Inference decision procedure (7 ordered steps)
  7. Inference cases → recommended shape (A–E: fits on 1 GPU /
     within NVLink / multi-node / extreme context / many moderate)
  8. Training baseline recipe (6 ordered steps)
  9. Sanity checks for any candidate config (7 checks)
 10. One-paragraph rule + honest caveat block

All content is st.markdown + st.dataframe — no new pure functions.
Smoke test asserts the tab title + `with tab_prules:` block are both
present in app.py.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-30 09:11:08 -07:00
mukesh 5ce7a5b30b analytical-viz: capacity planning — symbol legend + plain-English formulas
Section 1 rewritten so a layman can read it without a glossary:

1. **Symbol legend table** listing every letter that appears in the
   three-axis formulas (N, b, N·b, HBM_per_PE, S_kv, kv_bpt,
   users_per_replica, TPOT SLO, step_latency, B_at_SLO, n_users,
   N_replicas, ⌈ ⌉). Each row: Symbol / Plain English / Value now
   for the current sidebar model + chip. For example:
     - N = 'Total number of model parameters (attention weights +
       FFN weights, across ALL layers)' → 6.98 billion params for
       Llama 3 8B
     - kv_bpt = 'KV cache bytes per token per user, summed across all
       layers (= 2·H_kv·d_head·b·layers)' → 128 KB/token

2. **Three-axis table** widened from (Formula / Meaning / Grows with)
   to (In symbols / In plain English / What it says / Value now).
   Axis A now shows the actual substituted arithmetic:
   ⌈ 13.96 GB / 6.0 GB ⌉ = ⌈ 2.33 ⌉ = 3 PEs.
   B and C flag 'see calculator' because they depend on user inputs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-30 08:35:59 -07:00
mukesh bc5e704572 analytical-viz: Physical Layout — TTFT & TPOT full-model latency section
New section at the bottom of the Physical Layout tab that sums the
per-stage latencies into end-to-end metrics for the current sharding:

- **TPOT** = one decode-step time × N_layers  (per output token)
- **TTFT** = one prefill pass time × N_layers  (for a prompt of the
  input length, computed in prefill mode)
- **E2E** = TTFT + (N_out − 1) × TPOT
- **Throughput/replica** = B / TPOT  (tokens/sec)

Two number inputs: prompt length (default = sidebar S_kv) and target
N_out tokens (default 100). Four KPI cards + a breakdown table with
per-layer and all-layer µs/ms for attention/FFN in both modes.

Both TTFT and TPOT are computed by cloning the sidebar TopologyConfig
with mode swapped ('decode' for TPOT, 'prefill' for TTFT with T_q =
prompt length), then reusing all_stages / all_ffn_stages — no new
math. Caveat noted in caption: PP=1 assumption (no microbatch
overlap / bubble modeling).

Sanity: Llama 3 8B on CP=2/TP=8/default SIP → TPOT ≈ 4.2 ms/token,
TTFT ≈ 1.1 s for an 8k prompt. Cross-checkable on the Per-stage
latency tab.

Smoke test in test_stage_shapes asserts the section title appears
exactly once and sits inside tab_layout (before tab_memory).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-29 15:51:06 -07:00
mukesh c39bbb16aa analytical-viz: capacity planning tab — add public-example B/replica table
Section 6 (new): 'Actually-provisioned batch sizes (public examples)'.
Ballpark B/replica figures for Gemini 1.5 Pro/Flash 1M, GPT-4.1 1M,
Claude 3.5 Sonnet 200k, and enterprise dedicated tiers, with the
inference basis for each ('pricing ~2x standard suggests low B',
etc). Caption flags that none are officially published; numbers are
backed out from public pricing + SLAs.

Binding-axis playbook renumbered #6#7.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-29 15:44:35 -07:00
mukesh 77abc95d78 analytical-viz: capacity planning tab — add rules of thumb + deployment templates
Two more table sections on the Capacity planning tab:

**Section 4 — Practical rules of thumb (by context regime)**
- Short (S_kv < L*): pack HBM, B ~ 2·B*, high util, cheap tier
- Long (S_kv > L*): fewer users/replica, low B, util drops as
  1/(1 + S_kv/L*), pricier
- Extreme (S_kv >> L*): dedicated pool, heavy CP, disaggregated
  prefill, very low util without sparse attention

Columns: Regime, Batch strategy, Utilization, Cost/token, Deployment.

**Section 5 — Sample deployment templates**
Same base model, three different sharding recipes routed to by the
API gateway based on request context length:
- Config_small : CP=1,  TP=8, PP=1 → 8 GPUs, up to 32k, B=64
- Config_medium: CP=4,  TP=8, PP=1 → 32 GPUs, up to 128k, B=32
- Config_large : CP=32, TP=8, PP=1 → 256 GPUs, up to 1M, B=4

Columns: Tier, CP, TP, PP, Total GPUs/replica, Max context, Typical
B, Best for.

Playbook section renumbered to #6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-29 15:13:08 -07:00
mukesh f54822d247 analytical-viz: move capacity planning to its own tab, table-first layout
New 'Capacity planning' tab (last one, after 'Chip roofline & B*').
Everything below is a dataframe table where a bullet list could have
gone, matching the user's 'mostly try to make tables to describe the
information' preference:

1. **Three-axis GPU sizing formula** (table: A/B/C rows with Formula,
   Meaning, 'Grows with' columns)
2. **Live sizing calculator** (KPI cards + 3 number inputs — this is
   the only interactive block on the tab)
3. **SLO standards by workload** (table: TTFT / TPOT targets for
   voice/code/chat/agentic/batch, with 'what binds' column)
4. **Hybrid deployment layers** (table: Layer 1 elastic pool /
   Layer 2 length-tier routing / Layer 3 disaggregated prefill/decode
   with Serves / Key mechanism / Admission rule / Typical config)
5. **Binding-axis playbook** (table: for each of A/B/C — Symptom,
   First lever, Second lever, Cost impact)

Removed the sizing calculator + 3 expanders from the bottom of the
Chip Roofline tab. Roofline tab is back to pure chip-vs-model /
formulas territory; planning tab has the workload / SLO / deployment
material.

Calculator uses the base sidebar chip (_default_machine) rather than
the roofline tab's knob-scaled chip, so results are stable across
navigation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-29 15:09:58 -07:00
mukesh b7c4c680aa analytical-viz: roofline tab — 'How hyperscalers pick GPU count' section
New section at the bottom of the Chip Roofline tab implementing the
three-axis GPU count decision:

  N_GPUs = max(capacity_floor, KV_headroom, throughput_SLO) × N_replicas

Interactive inputs: concurrent users, avg context/user, TPOT SLO.
Four KPI cards report each axis + the recommended total, with a
'binds:' delta chip showing which axis is the bottleneck.

  - Axis A (capacity):  PEs to hold weights alone
  - Axis B (KV):        PEs to hold weights + all KV of one replica's users
  - Axis C (throughput): replicas needed at B_at_SLO users each
  - Total: max(A,B) × N_replicas

Contextual caption explains what to do about the binding axis. If SLO
is infeasible (even B=1 exceeds SLO), an error block explains the
options (loosen SLO, shorten context, or scale up FLOPs/BW knobs).

Plus three collapsed expanders explaining the hybrid deployment
pattern hyperscalers use:
  - Layer 1: one elastic pool + PagedAttention + continuous batching
  - Layer 2: length-tier routing (standard vs long-context)
  - Layer 3: disaggregated prefill/decode (DistServe, Splitwise)

Pure additions in chip_roofline.py:
- max_batch_within_slo(machine, model, s_kv, slo_s) — analytical
  inverse of step_latency to find the largest per-replica B under SLO
- size_deployment(machine, model, n_users, avg_ctx, slo_s) — returns
  GpuSizingResult with all three axes + binding info

6 new tests cover: capacity scales with model size, KV grows with
users/context, binding axis flips with workload shape, tight SLO
shrinks max batch, weight-time-exceeds-SLO returns 0, total ==
per-replica × replicas.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-29 15:07:20 -07:00
mukesh 6410c0843c analytical-viz: roofline tab — fix NameError from knob-rename refactor
Previous commit renamed slider vars (_rf_flops_mult -> _flops_mult_now,
etc.) and split _ai/_b_star into base vs scaled, but the AI-sensitivity
plot's vertical markers + caption still referenced the old names, so
loading the tab crashed with:

    NameError: name '_rf_flops_mult' is not defined

Fixed all four call sites:
- _axAI1: 'Base AI' reference line now uses _ai_base (was _ai, which
  is now the scaled value).
- _axAI2: 'Base B*' reference line uses _b_star_base; vertical
  markers use _flops_mult_now / _bw_mult_now.
- Caption 'Your knob position' uses the new names.

Grep confirms no _rf_flops_mult / _rf_bw_mult / _ai_scaled /
_b_star_scaled references remain.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-29 14:51:01 -07:00
mukesh 97e765b58f analytical-viz: roofline knobs — absolute TFLOPS / GB/s, drive whole tab
Two changes:

1. **Knob units and ranges**
   - FLOPs slider: absolute TFLOPS, 1..32 (step 0.5). Was 0.25..8× multiplier.
   - HBM BW slider: absolute GB/s, 128..1024 (step 16). Was 0.25..8× multiplier.
   Defaults still initialize to the sidebar Hardware panel's base chip.
   Caption also shows the implied × multipliers of the base chip.

2. **Knobs now drive every roofline number on the tab.**
   Previously FLOPs/BW only affected the AI-sensitivity plot. Now the
   scaled machine feeds:
     - AI / B* / L* KPI cards
     - Regime KPI
     - Good-B / Good-L cards + utilization
     - Headline plots (step latency + cost/token)
     - Regime formulas table (t_com, t_mem_short, t_mem_long values)
     - Formula table (C, W, knee substitution)
   Left unchanged (intentionally):
     - PE memory budget stacks — depend on HBM capacity, not BW/FLOPs.
     - AI-sensitivity plot itself — sweeps around the SIDEBAR base
       chip so the curves are comparable across knob positions.

Header caption now shows both effective chip (from knobs) and base
chip (from sidebar) side by side.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-29 14:47:30 -07:00
mukesh bda76cbd66 analytical-viz: roofline tab — drop formula from step-latency header
Revert the multi-line formula annotation on the 'Latency per decode
step' header; make it a single-line markdown title matching the
'Cost per token' header on the right. Both headline plots now render
at the same height so the plot area lines up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-29 14:14:24 -07:00
mukesh a1c3c28f62 analytical-viz: roofline tab — PE memory budget, AI sensitivity, FLOPs/BW knobs
Three additions to the Chip Roofline tab:

1. **PE memory budget** section (two side-by-side stacked-area plots)
   - Left: per-PE memory vs S_kv (128..1M, log). Stacks: weights,
     KV, transient. HBM budget line + current-S_kv marker.
   - Right: per-PE memory vs B (1..256). Same stacks, current-B
     marker. KV grows linearly with either axis; weights are
     invariant of B and S_kv (fixed by CP·TP·PP sharding).
   Uses actual sharded per-PE quantities from memory_layout so the
   numbers match what the deployment would really allocate.

2. **AI sensitivity** section (two side-by-side line plots)
   - Left: AI vs chip-parameter multiplier. Two curves: scale FLOPs
     (AI grows linearly), scale HBM BW (AI shrinks inversely).
   - Right: B* vs multiplier. Same shape as AI (for BF16 where B*=AI).
   Vertical markers show current FLOPs/BW knob positions.

3. **Two new knobs** in the top row: FLOPs × multiplier and HBM BW ×
   multiplier (0.25..8, step 0.25). The AI-sensitivity plot markers
   move with them; the header caption shows scaled AI + B* for the
   selected point.

Also: formula annotation on the headline 'Latency per decode step'
plot header showing t_step = N·b/W + 2·N·B/C + B·S_kv·kv_bpt/W.

Pure additions in chip_roofline.py (all testable):
- MemoryBudgetPoint dataclass
- memory_budget_curve_vs_skv, memory_budget_curve_vs_batch
- AISensitivityPoint dataclass
- ai_sensitivity_curve(machine, model, mults, axis='flops'|'bw')

9 new tests cover linear scaling of KV with S_kv/B, weight
invariance across sweeps, over-budget flag flip, free_gb clamp,
FLOPs multiplier → AI linear, BW multiplier → AI inverse, B*
tracks AI for BF16, bad axis raises.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-29 14:10:02 -07:00
mukesh 9b4aee83da analytical-viz: roofline tab — trim to 4 plots, cap B at 256 / S_kv at 1M
- Slider caps: B_max = 256, S_kv_max = 1M (matches practical decode
  operating ranges).
- Batch axis on every plot capped at 256 to match the slider.
- Removed the two redundant plots (old Plot 1 and old Plot 4). Both
  were re-drawing the same weight/compute/KV decomposition as the new
  headline 'Cost per token' plot at the top — the only distinguishing
  bits (extra 'Memory total' curve, B* marker) have been folded into
  the headline plot already, so keeping them was pure clutter.
- Every remaining plot now reacts to the S_kv / B knobs:
    * Plot A (step latency): S_kv drives curves, B marker drawn.
    * Plot B (cost/token): same.
    * Plot 2 (no-knee overlay): S_kv drives the L* ratios shown,
      B marker drawn.
    * Plot 3 (knee vs context): S_kv AND B markers drawn on both axes.

Net: 4 plots (headline pair + no-knee + knee-vs-context), all live-
interactive against both sliders.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-29 13:52:40 -07:00
mukesh 221097ef08 analytical-viz: roofline tab — headline step-latency + cost-per-token plots, B knob
Two new plots at the top of the Chip Roofline tab (right after the
knob row), side by side:

1. **Latency per decode step** — one forward pass's total time as B
   grows. weight_fetch (flat in B), compute (linear), KV (linear),
   total. The SLO / TTFT view — bigger B = longer step.

2. **Cost per token** = step latency ÷ B. weight (shrinks 1/B),
   compute (flat), KV (flat), total. The efficiency / cost view —
   bigger B (up to ~2·B*) = cheaper per token.

Same underlying decomposition, two divisors. Puts the classic
throughput ↔ latency tradeoff in one glance.

Also added a **B (batch size) slider** next to the existing S_kv
slider. Both plots draw a vertical marker at the current B; the
regime KPI ("memory-bound / compute-bound / kv-bound") now uses the
slider B, so you can sweep B live and watch the label flip when you
cross B*.

step_latency_curve + StepLatencyPoint added to chip_roofline; 5 new
tests cover: weight_s batch-invariant, compute_s/kv_s linear in B,
step_total == per_token_total × B (definitional), and step ==
per_token at B=1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-29 13:40:37 -07:00
mukesh c99a238826 analytical-viz: roofline tab — S_kv knob, regime formulas, good B/L, decomp plot, split table
Bundle of roofline-tab enhancements requested in-thread:

1. **S_kv slider at top of tab** — override the sidebar's S_kv locally
   so all four plots update as you drag it. Handy for exploring how
   the KV wall arrives without disturbing the rest of the app config.

2. **Good B / Good L KPI cards** — 3 metrics under the main KPI row:
     - Good B = 2·B* (Pope's rule of thumb)
     - Good L ceiling = L* (compute-friendly context ceiling)
     - Utilization @ current S_kv = 1 / (1 + S_kv/L*)

3. **Plot 4 — latency-decomposition per decode step** — five curves
   on one axis:
     - Compute (dashed, flat)
     - Weight fetch (triangles, shrinks 1/B)
     - KV fetch (squares, flat — batching doesn't help)
     - Memory total (weights + KV, purple)
     - Total (compute + memory, black bold)
   Makes "which term dominates at this B?" visible at a glance.

4. **Regime formulas table** — one row per cost term (t_com, weight
   fetch, KV fetch, bottleneck) × two columns (short-context vs
   long-context regime), plus a 'value now' column using the
   current S_kv slider.

5. **How to pick B and S_kv — one-paragraph guidance** with the
   numeric recommendations plugged in.

6. **Split formula table** — was cramming symbolic + substituted
   form into one cell with newlines; now has four proper columns:
   Symbol / Formula / With numbers / Meaning / Value.

New pure functions in chip_roofline.py: t_mem_short, t_mem_long,
t_com, good_batch, good_context, utilization_at. All roofline math
stays in the pure module; app.py just calls them and formats.

10 new tests bring the roofline test count to 27 (all 67 tests still
green): t_mem_long(L*) == t_com, doubling B halves t_mem_short,
good_batch scales with sparsity, utilization at 0/L*/2L* returns
1.0/0.5/0.333, monotonic decrease with context.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-29 13:27:17 -07:00
mukesh 04c7d247f0 analytical-viz: roofline formula table shows substituted numeric form
Each row in the 'Formulas & interpretation' table now shows both the
symbolic form AND the actual numbers plugged in, on a second line in
the Formula column. Row height bumped to fit two lines. Rendering:

  AI:      C / W
           = 8.00e+12 / 2.56e+11
           = 31.25 FLOPs/byte

  L*:      2 · N_active · W / (C · kv_bpt)
           = 2 · 6.98e+09 · 2.56e+11 / (8.00e+12 · 131,072)
           = 3,407 tokens

  B_knee:  B* / (1 - S_kv/L*)
           = 31 / (1 - 8,192/3,407)
           = 31 / (1 - 2.404)   [<= 0 -> no knee]

Makes the derivation traceable at a glance without needing to plug the
numbers back into the abstract formula. Purely a display change; no
runtime behavior shifts, all 57 tests still pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-29 12:04:47 -07:00
mukesh 1ddd1baa7a analytical-viz: 'Chip roofline & B*' tab — AI, B*, L*, cost curves
New tab surfaces the arithmetic-intensity story from LLM-serving
practice for the current sidebar chip + model. All derived from
MachineParams + ModelConfig; no new configuration.

chip_roofline module (pure functions):
- arithmetic_intensity      = C / W   (FLOPs per byte HBM BW)
- critical_batch (B*)       = C * b / (2 * W) * sparsity
- balance_context (L*)      = 2 * N_active * W / (C * kv_bpt)
- knee_batch (B_knee)       = B* / (1 - S_kv/L*)  (None past L*)
- per_token_latency_curve   = weight/B + compute + KV, per B
- bound_regime              = which term dominates now

Peak roofline convention (no compute_util factor) so weight_s ==
compute_s exactly at B*. Comm + TP/CP sharding intentionally
excluded — stage_latencies is the full latency model; this is the
back-of-envelope chip-vs-model view.

Tab shows:
- 4 KPI cards: AI, B*, L*, current regime at (B, S_kv)
- MoE hint when preset flags MoE ('300 x sparsity' rule)
- Plot 1: cost vs B at current S_kv (weight, compute floor, KV
  floor, total) with B* marker line
- Plot 2: cost curves at S_kv/L* = 0.25, 0.5, 1, 2, 5 — shows the
  no-knee regime past L*
- Plot 3: B_knee vs S_kv — knee slides right, diverges at L*
- Formulas + interpretation table

test_chip_roofline covers B* against H100 reference (295), sparsity
scaling, L* scaling with HBM BW, knee divergence at L*, per-token
curve monotonicity + asymptote to compute+KV floor, regime
classification. Smoke test guards the tab is wired in app.py.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-29 11:59:19 -07:00
mukesh 5e92b89821 analytical-viz: PE view keeps CP rank locator visible at TP>=8
The Per-PE layout diagram used to color-code + label CP ranks only
when cp_placement=='pe' (CP packed intra-cube). At TP>=8 the TP dim
fills the cube, cp_placement gets pushed to 'cube', the intra-cube CP
branch is skipped, and the CP rank tag silently disappeared from
cubes, per-PE headers, and the figure title. Users at TP>=8 had no
way to tell which of the CP groups the diagram was drawing.

Fix: when cp_placement=='cube' and CP>1, add
  - cube header suffix:   '[CP rank 0 of {CP}]'
  - per-PE header suffix: '  |  CP=0'
  - figure title:         'CP={CP} (showing 1 of CP groups; others identical)'

Behavior at cp_placement=='pe' is unchanged — the existing per-CP-rank
palette + 'CP={rank}' label still fire. CP=1 adds no locators at all.

test_pe_weight_layout covers four cases: TP=8/CP=4 (cube placement,
locator appears), TP=2/CP=4 forced to pe placement (regression:
per-rank labels still there, cube tag not added), CP=1 (no locators),
TP=16/CP=2 (both spilled cubes carry the CP-rank-0 tag).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-29 10:19:39 -07:00
mukesh 3360be2488 analytical-viz: consolidate shape tables under Physical Layout tab
Move the per-stage shape tables (attention + FFN) off the Per-stage
latency tab and combine them with the weight + KV shape tables into a
single 'All tensor shapes (per PE)' expander at the bottom of the
Physical Layout tab. Five subsections in one place:
  - Attention weights (global + per-PE shape, per layer)
  - FFN weights (same schema)
  - KV cache (same schema)
  - Per-stage attention shapes (input / weight / output)
  - Per-stage FFN shapes (same)

Rationale: the shape catalog belongs next to the physical-layout view
that shows how the tensors are placed on hardware. The Per-stage
latency tab now focuses purely on time-per-stage. Weight/KV tables
stay on the Memory Breakdown tab too because the summary metrics
underneath still consume the same rows.

Guard test verifies the section string appears exactly once, is
positioned inside tab_layout (before tab_memory / tab_stages), and
that neither attn_stage_shape_rows nor ffn_stage_shape_rows is called
inside the tab_stages block anymore.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-29 10:14:08 -07:00
mukesh adc14c84af analytical-viz: per-stage tensor shape tables (attention + FFN)
New stage_shapes module reports per-PE input / weight / output tensor
shapes for every attention (S1..S10, C1..C3) and FFN (F1..F5, CF1)
stage. Streamlit's Stages tab picks these up below the existing
latency table + bar chart as two separate dataframes ('Attention
stages' and 'FFN stages'), so anyone who wants to see the exact
sharded tensor dims per stage can, without cluttering the latency
view.

Rows mirror the conditional structure of all_stages / all_ffn_stages:
S8 merge only when CP>1; C1 CP ring only in prefill with CP>1; C3
Score AR only when kv_shard_mode='split' and TP>H_kv; C2 TP AR only
when TP>1; CF1 FFN AR only when the FFN divisor > 1. Batch B flows
into every activation dim; weights stay B-invariant.

test_stage_shapes covers column presence, conditional row insertion
(decode omits C1; CP=1 omits S8; prefill+CP>1 inserts C1), batch
scaling of the leading dim, and that S5 output references both T_q
and S_local for the current cfg.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-29 10:05:02 -07:00
mukesh 9c5062bdfb analytical-viz: memory-min buttons reset stale placement/mode session keys
auto_suggest only explores (cp, tp, pp, cp_placement); every other
TopologyConfig field is left at its dataclass default when the score
is computed. The sidebar's memory-min apply used to update only
cp/tp/pp/dp/cp_placement, so any stale session_state value from a
previous user interaction (most damaging: tp_placement="cube") would
carry over into the rerun and produce a topology the memory-min search
never scored — visibly, cubes_used could balloon (e.g. Llama 3 70B
Attn scope: auto_suggest picks 1 cube; stale tp_placement="cube" made
the applied config span 8 cubes).

Fix: after picking a Suggestion, reset tp_placement, kv_mode,
cp_ring_variant, ep back to their TopologyConfig defaults, and drop
the ffn_scope_label key (dynamic string; falls back to the TP+CP
default index on re-render). Now the sliders + placement + mode state
after apply exactly reproduce the topology auto_suggest scored.

test_auto_suggest_cubes_match_default_topology asserts, across four
model/skv combinations and all three scopes, that a TopologyConfig
built from (cp, tp, pp, cp_placement) plus dataclass defaults has the
same cubes_used the Suggestion reports.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 23:45:38 -07:00
mukesh 4ded67a443 analytical-viz: sidebar memory-min buttons are scope-aware (Attn / FFN / Attn+FFN)
Replace the three memory-optimal Pareto-search buttons in the Parallelism
sidebar with three scope-aware memory-min autosuggest buttons. Each
button sizes for a different per-PE memory footprint:

  - Attn      -> attention weights + KV cache (no FFN weights)
  - FFN/MoE   -> FFN weights only (no attention, no KV)
  - Attn+FFN  -> everything (traditional autosuggest)

memory_layout: per_pe_weight_bytes and compute_memory take
include_attention / include_ffn flags; KV cache is zeroed when attention
scope is excluded.

autosuggest: _score_candidate and auto_suggest forward the flags into
compute_memory, so the smallest-fit search now respects the chosen
scope.

app.py: single "Apply memory-min" caption + 3 column buttons. Each
button runs auto_suggest with its scope filter, snaps the sliders to
the picked (CP, TP, PP), and sets the Physical Layout scope filter to
match. Removes the standalone Apply memory-min button (was duplicating
the Attn+FFN case) and the Pareto-search import.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 23:34:05 -07:00
mukesh 47c4f40f4d analytical-viz: batch B in every formula string + rename 'latency-optimal' → 'memory-optimal'
Two related fixes.

1) Formula strings show B alongside every other symbol. Previously the
   numeric FLOPs/mem_bytes/comm_bytes were scaled by B, but the printed
   formula strings still read like B=1 amounts. That made the per-stage
   table confusing at high B: numbers moved but formulas didn't. Updated
   every attention + FFN stage's `formula` / `flops_formula` /
   `mem_formula` / `comm_formula` to include B explicitly. Weight-bytes
   lines now say "(shared across batch)" / "(weight, B-invariant)" so it's
   obvious what does and doesn't scale.

   Stages touched: S1..S10 (attention), C1/C2/C3 (attn comm), F1..F5
   (FFN), CF1 (FFN AR).

2) Renamed the button/spinner/help copy from "latency-optimal" to
   "memory-optimal" to match the smallest-fit pick semantics I put in
   place earlier. The buttons already picked smallest-fit; the labels
   just still said "latency-optimal" from the previous iteration.

   Left the Auto Hardware sensitivity chart's "latency-optimal baseline"
   label alone — that panel is a HW co-design view where "how fast can
   each HW go?" is the intended question.

Verified: 24 pytest tests still pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 23:20:32 -07:00
mukesh 50aab8d591 analytical-viz: complete batch (B) scaling in remaining stages + comm
Finishes the batch scaffold. All stages that produce per-request work
now scale flops / activation-memory / comm-bytes by B; weight bytes stay
fixed (shared across the batch).

Attention block, previously partial:
  - S6 stage_softmax: elems + bytes × B
  - S8 stage_merge: flops × B; O/m/l AR bytes M × B
  - S9 stage_normalize: flops × B
  - S10 stage_wo: flops × B
  - C1 comm_cp_ring: decode O/m/l AR M × B; prefill K/V ring + Q+O/m/l
    variants both × B
  - C2 comm_tp_allreduce (W_O output): bytes × B
  - C3 comm_kv_split_allreduce (head-split scores): bytes_per_hop × B

FFN block, previously untouched:
  - F1 stage_ffn_rmsnorm: activation × B (weight fixed)
  - F2 stage_ffn_gate (via _ffn_gemm): flops × B
  - F3 stage_ffn_up: same
  - F4 stage_ffn_swiglu: elems + flops × B
  - F5 stage_ffn_down: flops × B
  - CF1 comm_ffn_allreduce (batched FFN output): bytes × B

Verified with a smoke check on Qwen 3 8B / 128K decode:
  B=1  per-layer visible latency:  363 us
  B=8                                716 us  (sub-linear — many stages
                                              stay weight-bound)
  B=64                              4023 us  (approaching linear scaling
                                              as batch dominates)

All 24 pytest tests still pass at default B=1 (backward compat).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 23:10:39 -07:00
mukesh fc1dcdde24 analytical-viz: tensor sharding — show batch B on KV cache
Three additions to the tensor sharding view when batch B > 1:
  1. Shape label appends "B={B}": e.g. (128, S_kv=131072, B=8).
  2. Note under the KV cache: "batch B={B} => {B}x KV bytes per PE".
  3. Visual "stack" — up to 5 dashed offset rectangles drawn behind
     the KV cache to hint at the batch stacking dimension. Capped at 5
     so a big B doesn't overwhelm the diagram.
  4. Title also gets B={B} between CP and FFN scope.

Attention/FFN weight tensors are NOT stacked — weights are shared
across the batch (correct: only activations + KV scale with B).

At B=1, all four additions are no-ops so the diagram looks unchanged
from before.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 22:56:48 -07:00
mukesh fd45bd2408 analytical-viz: add sidebar Batch B selectbox + wire through all suggesters
New sidebar widget in the Workload expander:
  Batch B (concurrent requests) — selectbox 1/2/4/…/256, default 1.

Wired end-to-end:
  - app.py sidebar → forwards b_batch when constructing the main topo
  - auto_suggest(..., b=1) — passes b to _score_candidate, which builds
    TopologyConfig with b=b
  - run_auto_explore(..., b=1) — sets topo.b = b for every enumerated
    candidate before scoring
  - joint_explore(..., b=1) — forwards b to _best_parallelism_for_hw
    and _best_parallelism_two_stage; both set topo.b = b before scoring

All button handlers (sidebar Apply-scope, Physical Layout scope,
Auto Suggest tab sweep, Auto Hardware tab sweep) now pass b_batch.

Combined with the earlier partial-scaffold commits (memory_layout
scales KV by B; stage_rmsnorm / stage_wq / stage_wkv / stage_kv_append /
_per_hop_qkT_pv scale flops + activation memory by B), changing B in
the sidebar now affects reported latency and per-PE memory footprint
in the visible parts of the pipeline. The remaining FFN + comm-AR
stages still ignore B (they'll be next); their contribution is small
for the memory-bound decode case that matters most, but latency for
FFN-heavy configs at high B will be slightly under-reported until
those are scaled too.

Verified: 24 pytest tests pass at default B=1 (backward compat).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 22:51:56 -07:00
mukesh 674e194dc9 analytical-viz: hot-reload guard — include model_config + model_presets, ordered
The reload guard didn't include model_config.py. When the batch (B)
field was added to TopologyConfig, Streamlit kept the stale
pre-B dataclass in sys.modules and every downstream module hit
"AttributeError: 'TopologyConfig' object has no attribute 'b'".

Fix: add model_config and model_presets to the reload list, and order
model_config FIRST so downstream modules that reload after it pick up
the new dataclass definition. Also reordered the rest so upstream
dependencies (autosuggest, memory_layout, stage_latencies) reload
before their consumers (auto_explore, auto_hardware).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 22:43:47 -07:00
mukesh 770aed6291 analytical-viz: Auto Suggest tab — add Top-10 by memory footprint table
Adds a second table beneath the Pareto configurations table, sorted by
(hbm_utilization ↑, pes_used ↑, latency ↑) — top 10 smallest-memory
Pareto configs first.

Columns highlight the memory story:
  HBM % | weights (GB) | KV (GB) | PEs | SIPs | lat (ms) | CP TP PP DP | kv | ffn | tp_place | cp_place

Useful when memory pressure is the real binding constraint of a
deployment — e.g., picking a config for a per-PE HBM-limited SIP,
or spotting configs that trade small latency headroom for large
memory savings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 22:24:38 -07:00
mukesh 1bce080d79 analytical-viz: buttons pick smallest-fit Pareto point, not fastest
Sidebar Attention/FFN/Attn+FFN buttons and Physical Layout tab buttons
now select the SMALLEST-FIT config from the scope's Pareto set — key
changed from (latency ↑, pes_used ↑, hbm_utilization ↑) to (pes_used ↑,
hbm_utilization ↑, latency ↑). Answers "smallest deployment that's
still Pareto-optimal for this scope" instead of "fastest deployment
achievable".

Left unchanged:
  - "Best latency" metric displays in Auto Suggest / Auto Hardware tabs
    still show the fastest number (informational; the metric labels say
    "Best latency").
  - auto_hardware._best_parallelism_for_hw stays latency-min: it's
    inside HW co-design, where "how fast can each HW candidate go" is
    the primary question.

Verified: 24 pytest tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 22:23:34 -07:00
mukesh 5d7ef48b14 analytical-viz: partial batch (B) scaffold — TopologyConfig field + KV / early stages
WIP toward batch-size support. This first commit is behavior-preserving
at the default B=1 (every new multiplication is by max(1, cfg.topo.b) =
1 today) so all existing tests pass. Follow-up commits will:
  - scale the remaining stages (S6/S7/S8/S9/S10 + C1/C2/C3 + FFN + FFN AR)
  - add a batch selectbox to the sidebar
  - forward b through auto_suggest / auto_explore / auto_hardware

Changes so far:
  - TopologyConfig: new b: int = 1 field (batch size).
  - memory_layout.per_pe_kv_cache_bytes: * B (each concurrent request
    keeps its own KV cache slice).
  - stage_rmsnorm / stage_wq / stage_wkv / stage_kv_append /
    _per_hop_qkT_pv: FLOPs and activation memory scaled by B; weight
    bytes stay fixed (weights shared across the batch).

Verified: 24 pytest tests still pass at default B=1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 22:21:37 -07:00
mukesh 2a0bc26db0 analytical-viz: compound tiebreaker — fewer PEs / lower HBM% when latency ties
Every place that picks a single "best" config from the Pareto set now
uses a compound sort key: (latency_ns ↑, pes_used ↑, hbm_utilization ↑).
Primary = latency; when configs tie on latency (common — cp_ring
variants, some placement variants often produce identical numbers),
prefer smaller-footprint picks.

Places updated:
  - app.py:  sidebar Apply Attn/FFN/Attn+FFN button
  - app.py:  Physical Layout tab Attn/FFN/Attn+FFN button
  - app.py:  Auto Suggest tab "Best latency" metric
  - app.py:  Auto Hardware tab "Best latency" metric (uses parallelism.pes_used
             + parallelism.hbm_utilization since JointScore wraps ConfigScore)
  - auto_hardware.py:  _best_parallelism_for_hw iteration key

No behavior change when there's a strict latency winner. When there are
ties, the picked config uses fewer PEs and lower HBM utilization.

Verified: all 24 pytest tests pass (default include_attention=True and
include_ffn=True paths unchanged).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 22:16:02 -07:00
mukesh 6f61657ba4 analytical-viz: topology map — color PEs by CP rank when cp_placement=pe
The topology map previously colored a whole cube (border + all PE
fills) by the single cp_rank assigned to that cube in the cube→(pp,cp)
mapping. Under cp_placement=cube this is right (each cube = one CP
rank). Under cp_placement=pe, however, multiple CP ranks are PACKED
into the same cube's PEs, so the whole-cube coloring makes every PE
look identical (defaulting to cp_rank=0's palette entry — bright red).

Fix: in the per-PE loop, if cp_packed = (cp_placement=="pe" and cp>1),
compute each PE's own cp_rank = pe_id // tp and look up its color via
_cp_color(pp_stage, pe_cp_rank, cp_size). Border still uses the
cube-level color (cp_rank=0), so the outer bounding box is unchanged,
but the interior PEs now show all four (or however many) CP-rank
colors visibly.

For cp_placement=cube: unchanged (single pe_fill per cube).

Verified with a headless render at Qwen 3 8B, CP=4, TP=2,
cp_placement=pe: 145 patches → 8 distinct facecolors (4 for the CP
ranks in the packed cube + inactive/border tints), where before it
was fewer distinct colors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 22:07:50 -07:00
mukesh da1909fdf2 analytical-viz: extend hot-reload guard to all viz sub-modules
Previously only reloaded auto_explore / auto_hardware / autosuggest /
stage_latencies / memory_layout. When we edit pe_weight_layout,
tensor_sharding, topology_map, pipeline_diagram, or optimization_report,
Streamlit was still holding the pre-edit versions until a full restart.

Add all five to the reload list so any visualization-module edit lands
on the next Streamlit rerun without needing Ctrl+C.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 22:03:42 -07:00
mukesh ed28eea156 analytical-viz: color-code CP groups in PE layout when cp_placement=pe
When cp_placement=pe packs multiple CP ranks intra-cube, the PE-level
layout now:

  1. Colors each PE's background by its CP rank (8-color palette wraps
     for cp > 8). Cp_placement=cube keeps the historical light-blue
     styling (only one CP rank per cube tile).

  2. Adds "TP=N  |  CP=M" to the per-PE header so users can read the
     (cp_rank, tp_rank) pair without inferring from position.

  3. Fixes a head-assignment bug: _q_heads_for_pe / _kv_heads_for_pe /
     _per_pe_bytes used to be called with the raw PE-in-group index,
     which treated every PE as a distinct TP rank. Under cp_placement=pe
     this gave wrong Q/KV head lists for PEs beyond the first TP group
     (PE 2..7 with tp=2, cp=4 would ask for head slots 32..127 in a
     32-head model). Now called with tp_rank = pe_id % tp, so all CP
     ranks in the cube share the correct head split.

Verified via a headless matplotlib smoke test with CP=4/TP=2 on Qwen 3
8B: 8 PE patches + 1 cube patch → 5 distinct facecolors (cube + 4 CP
ranks), no errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 21:59:18 -07:00
mukesh f1cf0257d3 analytical-viz: restore Apply memory-min button
An "Apply memory-min" button was removed when the 3 latency-optimal
buttons were added. Consequence: after clicking any latency-optimal
button, the sidebar sliders drift from the memory-min caption and
there was no way to sync them back without restarting the app or
manually adjusting each slider.

Restore the button, positioned below the caption and above the 3
latency-optimal buttons. Clicking it snaps cp/tp/pp/dp/cp_placement
to what the caption shows and clears _pl_active_scope to "full" so
the Physical Layout tab stops filtering.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 21:55:29 -07:00
mukesh afe35ee0b5 Revert "analytical-viz: default intra-cube (PE↔PE) BW to 128 GB/s"
This reverts commit 84abf847c4.
2026-07-28 21:50:17 -07:00
mukesh 84abf847c4 analytical-viz: default intra-cube (PE↔PE) BW to 128 GB/s
Was 512 GB/s in the analytical viz MachineParams default. Bringing the
default down to 128 GB/s to match what the modelled physical link now
represents (parity with inter-cube D2D and topology.yaml).

Files touched (all three needed to keep defaults coherent):
  - model_config.py: MachineParams.bw_intra_gbs = 128.0 (was 512.0)
  - app.py: sidebar selectbox default index = 0 (128 GB/s) instead of 2 (512)
  - auto_hardware.py: _HW_KNOB_DEFAULTS + BALANCED + COARSE bw_intra_gbs
    baselines shifted so cost_score at defaults remains 6.0 and the
    sensitivity sweep starts from the new baseline.

Verified: 24 pytest tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 21:44:21 -07:00
mukesh a7f39ade7e analytical-viz: autosuggest prefers fewer cubes, tries cp_placement=pe
Two changes to the memory-only auto_suggest so a "fits" that packs into
one cube is preferred over a "fits" that spreads across multiple cubes.

Suggestion dataclass gains cubes_used and cp_placement fields.

_score_candidate: for each (CP,TP,PP) triple, try cp_placement="cube"
(historical default: CP spans cubes) and, when CP·TP fits in one cube's
PE count, also cp_placement="pe" (pack CP into intra-cube PEs). Keep
the placement with fewer cubes; break ties toward "cube".

auto_suggest: switch sort key from (pes_used ↑, pp ↑, tp ↑, cp ↑) to
(cubes_used ↑, pes_used ↑, pp ↑, tp ↑, cp ↑). Fewer cubes wins first
because a cube is the physical die-level unit; PE count is the
tiebreaker.

Sidebar caption now also displays cubes_used + cp_placement so the
user can see the packed layout at a glance. Preset-change auto-reset
also applies the picked cp_placement.

Observed effect (verified):
  Qwen 3 8B / 128K decode:  CP=4 TP=2, "pe" → 1 cube, 8 PEs
                             (was 4 cubes with default "cube")
  Llama 3.1 70B / 128K:      CP=4 TP=16, "cube" → 8 cubes (unchanged;
                             TP=16 > 8 PEs/cube, can't pack)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 16:04:17 -07:00
27 changed files with 4534 additions and 720 deletions
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

+7 -1
View File
@@ -15,6 +15,7 @@
\usepackage{caption} \usepackage{caption}
\usepackage{subcaption} \usepackage{subcaption}
\captionsetup{font=small,labelfont=bf} \captionsetup{font=small,labelfont=bf}
\usepackage{lmodern} % scalable Latin Modern fonts (required by microtype expansion)
\usepackage{microtype} \usepackage{microtype}
\usepackage{tikz} \usepackage{tikz}
\usetikzlibrary{arrows.meta,positioning,calc,fit} \usetikzlibrary{arrows.meta,positioning,calc,fit}
@@ -39,7 +40,12 @@ AGI Computing Lab, System Technology Group\\
\input{sections/02-platform} \input{sections/02-platform}
\input{sections/03-gemm} \input{sections/03-gemm}
\input{sections/04-allreduce} \input{sections/04-allreduce}
\input{sections/05-gqa} \input{sections/05-gqa} % section header + intro
\input{sections/05a-roofline} % 5.1 Roofline Analysis
\input{sections/05b-capacity-planning} % 5.2 Capacity Planning
\input{sections/05c-parallelism-selection} % 5.3 Parallelism Selection
\input{sections/05x-fused-kernel} % 5.4-5.7 Placement + short/long ctx + composite
\input{sections/05z-summary} % 5.8 Summary
\input{sections/06-agentic} \input{sections/06-agentic}
\input{sections/07-hw-spec-search} \input{sections/07-hw-spec-search}
\input{sections/08-discussion} \input{sections/08-discussion}
@@ -24,533 +24,3 @@ kernel that uses the composite command and PE\_IPCQ at the same time.
Multi-head attention (MHA) was studied in prior work and serves here as Multi-head attention (MHA) was studied in prior work and serves here as
the established baseline rather than being re-derived. the established baseline rather than being re-derived.
\subsection{Data Placement Policy}
\label{sec:gqa-placement}
Long-context decode is bound by the KV cache, so the first-order design
question is how to place that cache---and the running softmax state it
feeds---across the two hardware axes the machine exposes: the CUBEs and,
within each CUBE, the PEs (here $C{=}8$ CUBEs $\times$ $P{=}8$ PEs, for
$C\!\cdot\!P{=}64$ attention engines over one KV-head group on the
LLaMA-3.1-70B target). Each axis can \emph{replicate} the KV cache or
\emph{shard} it, and a shard can run along the sequence dimension
$S_{kv}$ or the head dimension $d_{\text{head}}$. The cross product is a
small, enumerable taxonomy; six placements span its meaningful corners
(Figure~\ref{fig:gqa-kv-sharding}).
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_long_ctx_6cases_kv_sharding_diagram.png}
\caption{The six KV-placement strategies, drawn on the
$S_{kv}\!\times\!d_{\text{head}}$ KV tensor (rows = sequence, columns =
head dimension). Cube colour bands and dashed PE dividers show which
axis each level shards. Cases~1--3 either replicate the cache or shard
it on a single axis (8-way at most); Cases~4--6 reach a full 64-way
split, three different ways: Case~4 splits $S_{kv}$ across CUBEs and
$d_{\text{head}}$ across PEs, Case~5 the mirror, and Case~6~$\star$
splits $S_{kv}$ on \emph{both} axes.}
\label{fig:gqa-kv-sharding}
\end{figure}
Two quantities decide which placement is viable, and they pull against
each other. The first is \textbf{per-PE KV memory}. With a per-PE HBM
budget of \SI{6.0}{\giga\byte} and \SI{1.76}{\giga\byte} of attention
weights resident, the KV headroom is \SI{4.24}{\giga\byte} per PE. At a
production context of $S_{kv}{=}1\,\text{M}$ tokens the unsharded cache
is \SI{40}{\giga\byte}/PE (Case~1), an 8-way shard is \SI{5}{\giga\byte}
(Cases~2--3)---both \emph{over} the headroom---while only the 64-way
placements bring it to \SI{640}{\mega\byte}/PE (Cases~4--6), comfortably
inside budget. Memory alone therefore eliminates Cases~1--3 at long
context. The second quantity is \textbf{communication per token}, and it
is what separates the three survivors. Sharding $d_{\text{head}}$
(Cases~4--5) makes each PE hold only a slice of every head, so the
$Q\!\cdot\!K^{\top}$ score is \emph{partial} and must be all-reduced
across the slice owners on every token---a reduction whose volume scales
with $S_{kv}$ ($\sim$\SI{166}{\mega\byte}/token analytically, intra-CUBE
on the NoC for Case~4, inter-CUBE on UCIe for Case~5). Case~6~$\star$
instead shards $S_{kv}$ on both axes, so every PE computes a
\emph{complete} score over its own token range and only the small running
softmax state $(m,\ell,O)$ is merged across PEs
($\sim$\SI{6.2}{\mega\byte}/token)---a $\sim$27$\times$ lighter collective
than the $d_{\text{head}}$-split designs.
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_long_ctx_6cases_summary.png}
\caption{Long-context placement analysis at $S_{kv}{=}1\,\text{M}$ tokens.
\emph{Left:} the per-PE HBM budget---\SI{1.76}{\giga\byte} of attention
weights leave \SI{4.24}{\giga\byte} of KV headroom (red line).
\emph{Middle:} per-PE KV memory per case (log scale); only the 64-way
placements (Cases~4--6, \SI{640}{\mega\byte}) clear the headroom, while
the unsharded (\SI{40}{\giga\byte}) and 8-way (\SI{5}{\giga\byte}) cases
overflow. \emph{Right:} analytical communication per token (log scale);
the $d_{\text{head}}$-split Cases~4--5 pay a partial-score all-reduce
($\sim$\SI{166}{\mega\byte}/token) that the both-axes-$S_{kv}$ split of
Case~6~$\star$ avoids ($\sim$\SI{6.2}{\mega\byte}/token, merging only the
softmax state). On these two axes Case~6 (marked $\star$ in the figure)
is the single placement that lands both inside the memory budget and at
low per-token communication; whether that combination is the right one to
pick is a regime-specific question taken up next.}
\label{fig:gqa-budget}
\end{figure}
These two costs---per-PE KV memory and per-token communication---are
intrinsic properties of each placement, fixed by how it shards the cache
and independent of the workload regime. They do not by themselves name a
winner: a short prompt where the whole cache fits on one PE values low
communication and tolerates replication, whereas a million-token decode
is bound by the memory wall and will pay communication to escape it.
Which placement is appropriate is therefore a per-regime question, which
the short- and long-context subsections that follow answer by running the
options on the simulator and reading off latency, traffic, and the
redundant compute each one induces.
\subsection{Inference with Short-Context Length}
\label{sec:gqa-short}
The fused GQA kernel issues its matrix products as scheduler-managed
composite commands and keeps the online-softmax merge and the cross-device
KV reduction inside the kernel, on PE\_IPCQ. Two kernel families cover the
two phases. The \emph{prefill} kernel splits the query tile across the PEs
of a group and broadcasts each KV tile from the group's root PE over
intra-CUBE IPCQ, so the HBM K/V read is paid once per group instead of
once per PE. The \emph{decode} kernel sequence-shards the KV cache across
the same group of PEs, runs per-PE local attention, then chain-reduces the
partial $(m,\ell,O)$ triples back up to the group root. Two further
primitives make long context practical: a \emph{lazy load} that issues the
KV \textsf{DMA\_READ} and returns immediately, auto-waiting only at first
use so KV load overlaps score computation; and per-tile \emph{scratch
recycling} that keeps the running accumulators in a persistent arena while
freeing per-tile temporaries, so the kernel fits the
\SI{1}{\mebi\byte} scratch budget across many tiles. A further refinement
restructures the decode step so the per-tile matrix products and the
online-softmax merge are issued as \emph{composite} commands rather than
hand-tiled primitives; \S\ref{sec:gqa-composite} measures it against the
primitive baseline at long context.
This is a different decomposition from the six-case placement taxonomy of
\S\ref{sec:gqa-placement}: that taxonomy spreads a single KV-head group
across all 64 PEs and is the right lens for long context
(\S\ref{sec:gqa-long}), whereas short context---where the whole cache fits
comfortably---turns instead on how the eight KV heads are distributed
across the eight CUBEs. The design question for short context is therefore
not whether to fuse softmax---that is settled---but how to distribute the
eight KV heads across the eight CUBEs of a SIP. Four mappings cover the spectrum, named
by KV-head count per CUBE: \textsf{1-kv-per-cube} dedicates one whole
CUBE to each head and uses all eight PEs of that CUBE on the head's
sequence shard; \textsf{2-kv-per-cube} packs two heads per CUBE with
group size four; \textsf{4-kv-per-cube} packs four heads with group size
two; \textsf{8-kv-per-cube} loads all heads onto a single CUBE with one
PE per head. The mapping fixes a trade-off: as KV heads per CUBE grows, the per-CUBE KV
footprint grows linearly and the mapping uses proportionally fewer CUBEs
(eight down to one), but the intra-CUBE IPCQ broadcast/reduce cost shrinks
to zero (8-kv-per-cube uses a single PE per head and has no group
communication).
A second axis is how each GEMM tile is issued. We isolate this with three
\emph{composite tiers} applied to the same mapping: \textsf{without
composite} uses primitive \textsf{tl.dot} and unfused softmax;
\textsf{with composite (GEMM-only)} issues the GEMMs as
\textsf{tl.composite(op="gemm")} commands so the scheduler can pipeline
the tile stages but leaves softmax as primitives; \textsf{with composite
+ softmax\_merge} additionally folds the softmax fold into the $P\!\cdot\!V$
composite through a named \textsf{softmax\_merge} prologue recipe.
We sweep all four mappings $\times$ three composite tiers $\times$
$S_{kv}\in\{8\text{K},16\text{K},32\text{K},64\text{K}\}$ for both
phases (prefill uses $T_q{=}8$ sliced tiles; decode uses $T_q{=}1$). The
headline latency comparison is in Figure~\ref{fig:gqa-short-wall-baseline}.
The mappings separate cleanly, and in proportion to how many PEs each
dedicates to a head: \textsf{1-kv-per-cube} spreads one head across all
eight PEs of its CUBE, \textsf{8-kv-per-cube} gives each head a single PE,
and the four mappings form a $\{64,32,16,8\}$-active-PE ladder. Decode
latency tracks that ladder inversely---at $S_{kv}{=}64$K the
\textsf{8-kv-per-cube}/\textsf{1-kv-per-cube} ratio is $7.4\times$
($\SI{87.0}{}$ vs.\ $\SI{11.8}{\micro\second}$), and even at $8$K it is
$4.8\times$ ($\SI{10.8}{}$ vs.\ $\SI{2.2}{\micro\second}$); prefill shows
the same ordering more gently ($2.3\times$ at $64$K). The reason is that
decode here is \emph{bandwidth-bound}: each active PE streams its KV shard
at \SI{46}{}--\SI{76}{\percent} of the \SI{256}{\giga\byte\per\second}
per-PE ceiling (rising with context), so a mapping's speed is set by how
many PEs it puts to work---more PEs, more aggregate HBM bandwidth, lower
latency. The $M{=}G{=}8$ score GEMM is skinny and leaves the MAC array
lightly loaded throughout; the lever here is data movement, not compute.
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_short_context/wall_variant1_mode_compare.png}
\caption{Wall-clock latency of the four short-context mappings, baseline
kernel (no composite; log scale). The mappings separate along the
$\{64,32,16,8\}$-active-PE ladder set by heads-per-CUBE:
\textsf{1-kv-per-cube} (64 PEs) is fastest and \textsf{8-kv-per-cube}
(8 PEs) slowest, by $4.8\times$ at $8$K growing to $7.4\times$ at $64$K in
decode ($2.3\times$ at $64$K in prefill). Decode is bandwidth-bound, so
latency scales inversely with the number of active PEs. Composite tiers
track the baseline at this skinny shape
(Figure~\ref{fig:gqa-short-a1-variant}).}
\label{fig:gqa-short-wall-baseline}
\end{figure}
Figure~\ref{fig:gqa-short-tradeoff} shows what the mappings trade for that
latency. The differentiator is \emph{density}: \textsf{8-kv-per-cube}
concentrates all eight heads onto one CUBE, so its per-CUBE KV footprint is
$8\times$ that of \textsf{1-kv-per-cube} (one CUBE per head), and it
occupies a single one of the SIP's sixteen CUBEs against
\textsf{1-kv-per-cube}'s eight. Per-PE HBM \emph{utilization}, by contrast,
is similar across mappings---every active PE runs near the same
\SI{46}{}--\SI{76}{\percent} of the per-PE ceiling---so the latency ladder
comes from the \emph{count} of active PEs, not from any per-PE
concentration. IPCQ traffic runs opposite to density:
\textsf{1-kv-per-cube} pays the eight-PE chain-reduce while
\textsf{8-kv-per-cube} (one PE per head) pays none, but absolute IPCQ volume
stays under \SI{120}{\kibi\byte} per run, two orders of magnitude below the
HBM traffic. The result is a genuine trade-off rather than a single winner:
\textsf{1-kv-per-cube} minimizes per-request latency by spending the most
hardware (eight CUBEs, 64 PEs) on one request, while denser mappings leave
CUBEs free for other requests---the batched-serving axis taken up below.
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_short_context/per_cube_tradeoff_mode_compare.png}
\caption{Per-CUBE trade-off across the four mappings (baseline kernel;
log scale). Top row: per-CUBE KV footprint---\textsf{8-kv-per-cube} packs
all eight heads onto one CUBE, an $8\times$ larger per-CUBE KV than
\textsf{1-kv-per-cube} and one CUBE used against eight (the $8\times$ ratio
is context-invariant; the absolute footprint grows with $S_{kv}$). Bottom
row: IPCQ traffic per run---\textsf{1-kv-per-cube} pays the eight-PE
chain-reduce while \textsf{8-kv-per-cube} (single PE per head) pays zero.
The density (top) is what sets how many concurrent requests a SIP can hold.}
\label{fig:gqa-short-tradeoff}
\end{figure}
The composite ablation in Figure~\ref{fig:gqa-short-a1-variant} shows the
three tiers within \textsf{1-kv-per-cube}: wall-clock is nearly flat, with
the GEMM-only composite tier a few percent \emph{slower}
(\SI{14.0}{} vs.\ \SI{11.8}{\micro\second} at $64$K decode) and the
\textsf{softmax\_merge} tier matching the baseline. This is the expected
outcome for the decode-skinny shape---$M{=}G{=}8$ is well below the
scheduler's $\textsf{TILE\_M}{=}32$ supertile (\S\ref{sec:gemm}), so the
composite path pads $M$ by $4\times$ with zeros and the fusion has no slack
to win back; the padding is pure overhead here. It shows up in
Figure~\ref{fig:gqa-short-gemm-util} as inflated GEMM-engine busy time on
the composite tiers---the padded-$M$ GEMM is charged in full against a
sub-\SI{15}{\micro\second} decode wall, driving the accounted utilization
well above the baseline's \SI{12}{}--\SI{18}{\percent}. Fusion benefit
requires lifting $M$, either by the long-context Q-tile split
(\S\ref{sec:gqa-composite}) or by batching multiple users---which is also
the lever that converts a mapping's density into throughput.
\paragraph{Latency versus batched throughput.} The latency ladder and the
density trade-off pull in opposite directions once a SIP serves more than
one request. Decode users are independent---each attends its own KV
cache---and these mappings generate no cross-CUBE traffic, so a SIP runs
several users at once on disjoint CUBE groups, up to $16/C$ users: two for
\textsf{1-kv-per-cube}, sixteen for \textsf{8-kv-per-cube}. We measure this
directly, launching $B$ concurrent users and reading aggregate latency off
the shared clock (Figure~\ref{fig:gqa-batch}). The overlap is nearly
perfect: aggregate latency stays within \SI{3}{}--\SI{5}{\percent} of the
single-user latency all the way to a full SIP (\textsf{8-kv-per-cube} at
$16$ users is \SI{11.4}{} vs.\ \SI{10.8}{\micro\second}), so that small
residual is the only cross-user interference the shared fabric adds.
Aggregate throughput at $8$K therefore rises almost linearly with $B$, from
\SI{0.86}{}~requests\,/\,\si{\micro\second} (\textsf{1-kv-per-cube}, capped
at two users) to \SI{1.41}{} (\textsf{8-kv-per-cube}, sixteen users): the
\emph{dense} mapping wins on throughput even though it is the \emph{slowest}
per request.
The reason is worth spelling out, because both mappings fill the same
hardware: at capacity \textsf{1-kv-per-cube} runs two users
$\times$ 64 PEs and \textsf{8-kv-per-cube} sixteen users $\times$ 8 PEs, so
each puts all 128 PEs of the SIP to work. What differs is per-PE
\emph{efficiency}. Splitting one head across eight PEs
(\textsf{1-kv-per-cube}) leaves each PE only $S_{kv}/8$ tokens---a single
tile---so its fixed overheads (pipeline fill on the lazy KV load, and the
eight-PE online-softmax chain-reduce) dominate the little streaming work it
does, and it sustains just \SI{46}{\percent} of the per-PE HBM ceiling.
Giving each head a whole PE (\textsf{8-kv-per-cube}) streams the full
$S_{kv}$-token cache contiguously with no reduce, amortizing those overheads
over $8\times$ more work and reaching \SI{76}{\percent}. The measured
throughput ratio ($1.41/0.86 = 1.6\times$) matches the utilization ratio
($0.76/0.46 = 1.7\times$) almost exactly: because decode is
bandwidth-bound, aggregate throughput is set by total HBM efficiency, not
by PE count. \textsf{1-kv-per-cube} is really spending hardware on
\emph{latency}---eight PEs per head buy only a $4.8\times$ speedup, a $60\%$
strong-scaling efficiency---and that same $40\%$ overhead is what caps its
throughput once the SIP is full. The design conclusion is regime-dependent: \textsf{1-kv-per-cube} is the
latency-optimal choice for single-stream or low-batch decode, while denser
mappings are the throughput-optimal choice for high-batch short-context
serving. At long context the per-request latencies already scale as $1/C$,
so the same accounting predicts the throughputs converge; a measured
long-context batch sweep is 2H work (\S\ref{sec:future}).
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_short_context/batch_scaling.png}
\caption{Batch scaling at $S_{kv}{=}8$K: aggregate throughput (requests per
\si{\micro\second}) versus the number of concurrent decode users on one SIP,
each user on a disjoint CUBE group. Each mapping scales almost linearly with
$B$ up to its SIP capacity $16/C$---\textsf{1-kv-per-cube} saturates at two
users, \textsf{8-kv-per-cube} at sixteen. Solid segments are measured
concurrent runs; the dashed extensions are the saturated-throughput ceiling
beyond capacity, where further users run in waves at that sustained rate
(so the SIP is full, not idle). The dense mapping reaches the highest
ceiling; the spread mapping is latency-optimal but capacity-limited.
Concurrent users overlap to within \SI{3}{}--\SI{5}{\percent} of the
single-user latency.}
\label{fig:gqa-batch}
\end{figure}
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_short_context/wall_a1_variant_compare.png}
\caption{Composite-tier ablation on \textsf{1-kv-per-cube} (log scale):
wall-clock is nearly flat across tiers---the GEMM-only composite runs a few
percent slower (padding overhead) and \textsf{softmax\_merge} matches the
baseline. With $M{=}G{=}8 < \textsf{TILE\_M}{=}32$ the composite scheduler
pads $M$ by $4\times$, leaving no fusion slack to recover at this shape.
Fusion pays off only when $M$ fills the supertile---long-context Q-tile
splitting or batched-$M$ inference.}
\label{fig:gqa-short-a1-variant}
\end{figure}
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_short_context/gemm_util_a1_variant_ablation.png}
\caption{GEMM-engine busy fraction on \textsf{1-kv-per-cube} across the
three composite tiers. The baseline runs at \SI{12}{}--\SI{18}{\percent};
the composite tiers read much higher---up to and past
\SI{100}{\percent} of the short decode wall---because the padded
$8\!\to\!32$ $M$ dimension is charged as GEMM time, bookkeeping overhead
rather than useful work. The engine is never the bottleneck at this shape;
decode is KV-bandwidth-bound.}
\label{fig:gqa-short-gemm-util}
\end{figure}
\subsection{Inference with Long-Context Length}
\label{sec:gqa-long}
% TODO: prefill long-context kernel implementation description
% (Sequence-Parallel partition of S_kv, per-case mechanics).
% Bench code: src/kernbench/benches/gqa_helpers/long_ctx/
% TODO: prefill long-context performance figure.
Long-context decode is the regime where the KV cache, not attention
compute, sets serving cost, so the placement question of
\S\ref{sec:gqa-placement} becomes decisive here. To pick the right
placement for this regime we run each of the six options as a fused
decode kernel on the simulator (one decode step on the LLaMA-3.1-70B
single-KV-head-group target, $C{=}8$ CUBEs $\times$ $P{=}8$ PEs) at a
tractable $S_{kv}{=}8192$, and read off end-to-end latency, on-device op
traffic, and the redundant compute each one induces. The swept context is
small enough that all six fit in memory at $S_{kv}{=}8192$; the
placements the long-context memory budget rules out (Cases~1--3) are
drawn in red, run here only to expose their issue and communication
structure.
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_decode_long_ctx_6cases_latency.png}
\caption{Measured end-to-end decode latency per placement
($S_{kv}{=}8192$; red = ruled out by the long-context memory budget,
blue = the predicted Pareto choice). The fastest raw latency belongs to
Case~3 (\SI{17.8}{\micro\second})---but Case~3 replicates the full KV
cache into every CUBE, so it overflows the per-PE budget at production
context and wastes 8$\times$ the compute
(Figure~\ref{fig:gqa-6cases-par}). Among the placements that actually fit
1\,M-token memory (Cases~4--6), Case~6~$\star$ is the fastest
(\SI{30.6}{\micro\second}, versus \SI{31.4}{} and \SI{34.5}{\micro\second}
for the $d_{\text{head}}$-split Cases~4 and~5)---making it the placement
of choice for long-context decode.}
\label{fig:gqa-6cases-lat}
\end{figure}
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_decode_long_ctx_6cases_parallelism.png}
\caption{Redundant compute per placement, measured as active-PE
$\times$ $S_{\text{local}}$ (PE-tokens; lower means less wasted work).
The minimum is \num{8192} PE-tokens---one pass over the sequence.
Case~3 inflates this 8$\times$ to \num{65536} by replicating the KV
cache across all eight CUBEs so every CUBE redundantly re-attends the
whole sequence; the $d_{\text{head}}$-split Cases~4--5 likewise carry
\num{65536} because each token is processed across eight head slices.
Case~6~$\star$ achieves the full 64-way split at the minimal
\num{8192} PE-tokens---fully parallel, no replication.}
\label{fig:gqa-6cases-par}
\end{figure}
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_decode_long_ctx_6cases_traffic.png}
\caption{Measured on-device op traffic per placement. The unsharded
Case~1 issues no IPCQ copies (each PE has the full cache, nothing to
reduce); the single-axis Case~3 charges 168. Among the 64-way splits,
the $d_{\text{head}}$-split Cases~4--5 charge the most---280 IPCQ copies
and 8 DMA writes each, the partial-score all-reduce that head-slicing
forces---while Case~6~$\star$ needs only 189 IPCQ copies and a single DMA
write, because it merges just the running softmax state $(m,\ell,O)$
rather than partial scores. This is the on-device collective traffic that
PE\_IPCQ and the torus links of \S\ref{sec:allreduce} are provisioned to
absorb at link speed.}
\label{fig:gqa-6cases-traffic}
\end{figure}
Taken together the three panels select the placement. The only
memory-feasible family at production context is the 64-way splits
(Cases~4--6), and within it Case~6~$\star$ is both the fastest and the
lightest-communicating, because it merges only the running softmax state
rather than the partial scores that the $d_{\text{head}}$-split
Cases~4--5 must all-reduce. Case~3's lower raw latency is beside the
point---it replicates the full cache into every CUBE, overflowing the
per-PE budget and wasting $8\times$ the compute. For long-context decode
the placement of choice is therefore the both-axes sequence shard, and the
cross-PE softmax reduction it does pay is precisely the traffic the
communication-side codesign of this report is built to move quickly.
\subsection{Use of Composite Commands}
\label{sec:gqa-composite}
The decode kernel of \S\ref{sec:gqa-long} issues its local attention as
primitive operations: it walks each PE's $S_{\text{local}}$ token slice in
\SI{1024}{}-token tiles, and for every tile issues a $Q\!\cdot\!K^{\top}$
\textsf{dot}, the online-softmax primitives, and a $P\!\cdot\!V$
\textsf{dot}, merging the running $(m,\ell,O)$ state by hand. The number
of PE\_CPU commands this costs grows with the context. At a production
context of $S_{kv}{=}1\,\text{M}$ tokens the Case-6 64-way split gives
each PE $S_{\text{local}}{=}16384$ tokens, so its local attention is a
$Q\!\cdot\!K^{\top}$ of $(8,128)\!\cdot\!(128,16384)$ and a
$P\!\cdot\!V$ of $(8,16384)\!\cdot\!(16384,128)$---sixteen hand-issued
tiles, each a fresh batch of CPU commands.
The composite command lets the kernel hand that tiling to PE\_SCHEDULER.
We compare three command forms of the \emph{same} Case-6 kernel---identical
placement and identical $(m,\ell,O)$ reduce, differing only in how the
local attention is issued:
\begin{itemize}
\item \textbf{primitive}---the hand-tiled \textsf{dot}/softmax kernel
above (the baseline of \S\ref{sec:gqa-long}).
\item \textbf{composite}---each matrix product is one coarse
\textsf{composite} GEMM over the \emph{whole} $S_{\text{local}}$, with
$K$ and $V$ passed as HBM references so PE\_SCHEDULER streams and
tiles them on the fixed $32\!\times\!64\!\times\!32$ MAC tile; the
softmax stays primitive.
\item \textbf{composite\,+\,softmax\_merge}---additionally folds the
online-softmax merge and $P\!\cdot\!V$ into a single stateful
\textsf{softmax\_merge} recipe composite.
\end{itemize}
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_decode_long_ctx_composite.png}
\caption{Three command forms of the Case-6 decode kernel, swept over
context length ($S_{\text{local}}{=}S_{kv}/64$ per PE). \emph{Right:}
PE\_CPU commands issued. The hand-tiled primitive kernel rises
$O(n_{\text{tiles}})$---from 96 commands at one tile to 426 at the
1\,M-token, sixteen-tile production point---while both composite forms
issue a context-\emph{independent} $O(1)$ count (94 and 98) that
\emph{saturates}: one coarse descriptor offloads the entire per-tile
fan-out. \emph{Left:} the consequence for wall-clock latency is none---all
three land on the same curve (\SI{30.6}{}, \SI{231}{},
\SI{461}{\micro\second} at 8\,K\,/\,64\,K\,/\,128\,K), because decode is
bound by streaming the KV cache, not by issue. Command-count is measured
at emit time (exact, to 1\,M); latency on the data-mode engine over the
tractable range.}
\label{fig:gqa-composite}
\end{figure}
Figure~\ref{fig:gqa-composite} reads off the two quantities that matter,
and they point in opposite directions. The PE\_CPU command count (right)
collapses from a context-growing $O(n_{\text{tiles}})$ to a flat $O(1)$:
at 1\,M tokens the composite form issues \num{94} commands against the
primitive kernel's \num{426}, a $4.5\times$ reduction
($4.2\times$ in modeled dispatch cost), and---crucially---that number no
longer grows with context. The wall-clock latency (left), by contrast,
is unchanged across all three forms: decode is bound by streaming the KV
cache out of HBM, so the command form does not move the critical path.
That juxtaposition is the point. The composite command is not a latency
optimization for this memory-bound decode; it is a \emph{CPU-issue}
optimization. Its value is removing the per-tile dispatch work that would
otherwise grow without bound as context grows, freeing PE\_CPU to run
ahead and keep the engines fed---which is exactly what lets the
data-movement cost analyzed next show through as the true bottleneck
rather than being masked by issue overhead. The \textsf{softmax\_merge}
recipe folds the online merge into the same descriptor; on this
memory-bound path its marginal cost over the plain GEMM composite is small
(98 vs.\ 94 commands), and like the plain composite it keeps the issued
count flat as context scales.
\paragraph{The compute-bound mirror: prefill.} Decode's verdict---command
form is latency-neutral---is a property of its regime, not of the
composite command. A decode step has $T_q{=}1$, so its score and context
products are skinny ($M{=}G\,T_q{=}8$): the MAC array is barely fed and
the kernel is bound by streaming the KV cache. Prefill is the opposite
corner. It processes a block of query positions at once, so $M{=}G\,T_q$
is large and tile-filling, the GEMMs carry real arithmetic intensity
($\sim$$M$ flops/byte, well above the roofline ridge), and the kernel is
\emph{compute-bound}. This is the regime the composite command was built
for (\S\ref{sec:gemm}): it streams the per-HW-tile
DMA$\rightleftarrows$compute pipeline so the MAC array stays fed, whereas
the primitive kernel's blocking \textsf{tl.dot} serializes each tile's
load and compute and starves the array between tiles. We run the same
three command forms on a single-rank compute-bound prefill (FlashAttention
$Q$-block $\times$ $S_{kv}$-tile, online softmax) and sweep the context
length (Figure~\ref{fig:gqa-prefill-cb}).
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_prefill_compute_bound.png}
\caption{Compute-bound prefill, three command forms, swept over context
length ($M{=}8\,T_q$ tile-filling). \emph{Left:} end-to-end latency.
\emph{Right:} MAC utilization (achieved $\div$ the
\SI{8}{\tera\flop\per\second} per-PE peak). The hand-tiled primitive sits
flat at $\sim$\SI{68}{\percent}---its serial load$\to$dot path leaves the
MAC array idle between tiles regardless of context. The composite forms
climb with context (\SI{67}{}$\to$\SI{80}{\percent} plain,
\SI{67}{}$\to$\SI{83}{\percent} with the recipe) because a deeper $P\!\cdot
\!V$ reduction gives more HW tiles to pipeline, and they convert that into
wall-clock: at \num{1024} the recipe form is \SI{646.9}{} vs.\
\SI{794.1}{\micro\second} (\SI{19}{\percent} faster). The margin
\emph{grows} with context---the compute-bound mirror of the GEMM result of
\S\ref{sec:gemm}.}
\label{fig:gqa-prefill-cb}
\end{figure}
The two studies together state the composite command's value precisely. It
has two distinct benefits, and which one matters is set by the workload's
roofline position. The first is \emph{host-issue offload}: one macro
command in place of $O(n_{\text{tiles}})$ fine ones, which removes
PE\_CPU dispatch work and is regime-independent (it shows in the decode
command count). The second is \emph{MAC-array feeding}: the
scheduler-internal per-tile DMA$\rightleftarrows$compute pipeline, which
only converts to latency when the workload is compute-bound enough to have
a MAC array worth keeping busy (it shows in the prefill utilization). A
memory-bound decode exercises only the first; a compute-bound prefill
exercises both. The composite command is the single mechanism that
delivers each where it applies.
\subsection{Summary}
\label{sec:gqa-analysis}
The section's results line up into one picture of the fused kernel.
Placement is decided by memory first and communication second: only the
64-way splits fit production context, and among them the both-axes
sequence shard (Case~6) minimizes the per-token collective by merging
softmax state rather than partial scores. Command form is decided by
roofline position: the composite command removes the per-tile issue work
in both regimes, but converts to wall-clock only in compute-bound prefill,
while memory-bound decode stays bound by KV streaming regardless of command
form. The thread connecting the two is that, once composite issue makes
GEMM cheap and leaves the MAC array idle, the fused kernel's latency is set
almost entirely by data movement---streaming the KV cache and reducing
partials across PEs---which is exactly the cost the communication-side
work (on-device reduction, lazy load/compute overlap, fast TCM staging and
torus links) is built to attack. What this implies for hardware investment
across the report as a whole is taken up in the discussion.
% TODO: cross-regime DP (data parallelism) applicability:
% - Does Case-4 long-context placement compose with batch-level DP
% without further changes?
% - Does the short-context placement compose the same way?
% - Implications for multi-user serving (single vs. mixed regimes).
@@ -0,0 +1,85 @@
\subsection{Roofline Analysis: Batch and Context Regimes}
\label{sec:roofline}
Before deploying any sharding strategy, the workload's position on the
decode roofline sets what \emph{can} be improved and what \emph{cannot}.
Two quantities matter: the machine's \textbf{critical batch}
$B^\ast = C \cdot b / (2 W)$ (where $C$ is per-PE FLOPs, $W$ is per-PE
HBM bandwidth, $b$ is bytes per parameter), and its \textbf{balance
context} $L^\ast = 2 N / (\mathrm{AI} \cdot \mathrm{kv\_bpt})$ (where
$N$ is active parameters and $\mathrm{AI} = C/W$ is arithmetic
intensity). Above $B^\ast$ the deployment leaves the memory-bound
regime; above $L^\ast$ per-user KV streaming dominates and no amount
of batching hides it. For Llama-3.1-70B on the default AHBM machine
($C \!=\! \SI{8}{TFLOPS}$/PE, $W\!=\! \SI{256}{GB/s}$/PE), we get
$B^\ast \!\approx\! 31$ and $L^\ast \!\approx\! \SI{13.4}{K}$ tokens.
\paragraph{Short-context regime ($S_{kv} \!<\! L^\ast$).}
Figure~\ref{fig:roofline-short} shows one decode step at
$S_{kv}\!=\! \SI{8}{K}$. The step-latency panel (left) makes it
obvious that weight fetch dominates at low $B$: at $B\!=\!1$, one
step spends $\sim\!\SI{535}{ms}$ streaming weights and only
$\SI{28}{ms}$ on per-sequence compute and KV combined. The
cost-per-token panel (right) is where the batch story lives: dividing
step latency by $B$ shrinks the weight term as $1/B$, so per-token
cost drops from $\SI{562}{ms}$ at $B\!=\!1$ to $\SI{29.7}{ms}$ at
$B\!=\!256$ --- a $19\times$ reduction. This is the memory-bound-to-
compute-bound crossover: past $B^\ast$, weight cost is fully
amortized and per-token time asymptotes to the compute + KV floor
($\sim\!\SI{27}{ms}$). \textbf{At short context, batching directly
lowers cost per token.}
\begin{figure*}[h]
\centering
\includegraphics[width=\textwidth]{roofline_short_context.png}
\caption{Roofline decomposition for Llama-3.1-70B decode at
$S_{kv}\!=\! \SI{8}{K}$ (short, well below $L^\ast\!\approx\! \SI{13.4}{K}$).
\textbf{Left}: step latency vs.\ batch $B$; weight fetch is flat
(one HBM sweep per step), compute and KV grow linearly with $B$.
\textbf{Right}: per-token cost ($=$ step $\div B$); the $1/B$
weight-fetch term amortizes rapidly, dropping total per-token time
from \SI{562}{ms} at $B\!=\!1$ to \SI{29.7}{ms} at $B\!=\!256$
--- a $19\times$ throughput gain from batching alone.}
\label{fig:roofline-short}
\end{figure*}
\paragraph{Long-context regime ($S_{kv} \!\gg\! L^\ast$).}
Figure~\ref{fig:roofline-long} shows the same decomposition at
$S_{kv}\!=\! \SI{1}{M}$ ($\sim\!78 \times L^\ast$). The step-latency
panel shows KV fetch has swelled by two orders of magnitude:
per-token KV cost is now $\sim\!\SI{1342}{ms}$, dwarfing both
weight ($\SI{535}{ms}$ at $B\!=\!1$) and compute ($\SI{17}{ms}$).
The cost-per-token panel is the punchline: increasing $B$ still
shrinks the weight term but leaves the giant KV floor untouched,
because \textbf{KV fetch is per-sequence} --- adding another user
adds a full extra copy of their KV read. Total per-token cost falls
only from \SI{1894}{ms} at $B\!=\!1$ to \SI{1361}{ms} at $B\!=\!256$
--- a mere $28\%$ reduction despite $256\times$ the batch.
\textbf{At long context, batching stops paying because per-user KV
streaming dominates.}
\begin{figure*}[h]
\centering
\includegraphics[width=\textwidth]{roofline_long_context.png}
\caption{Same decomposition at $S_{kv}\!=\! \SI{1}{M}$ (long,
$\sim\!78\times L^\ast$). KV fetch has grown to \SI{1342}{ms}/token
and is now the dominant term at every $B$. Batching only shrinks
the weight component; the KV floor is unmovable because each new
user brings a full per-sequence KV read. Total per-token cost falls
just $28\%$ from $B\!=\!1$ to $B\!=\!256$, versus $19\times$ at
short context.}
\label{fig:roofline-long}
\end{figure*}
\paragraph{Implication for deployment.} The two regimes call for
opposite strategies. In the short-context regime, the operator packs
$B$ as high as HBM allows to sit on the compute floor --- this is
where cost-per-token is minimized and hardware utilization is
highest. In the long-context regime, per-user KV is the binding
resource; batching offers little benefit, so the operator instead
shrinks $\mathrm{kv\_bpt}$ (GQA / MQA / MLA, INT4 KV, sparse
attention) and shards the sequence dimension itself (CP), routing
long-context requests to a dedicated pool with more chips per user.
The next two subsections (\S\ref{sec:capacity-planning},
\S\ref{sec:parallelism-selection}) turn these regime observations
into concrete sizing and sharding rules.
@@ -0,0 +1,118 @@
\subsection{Capacity Planning: LLM Serving on AHBM}
\label{sec:capacity-planning}
Once a fused kernel is chosen, the deployment question is orthogonal:
\emph{how many cubes (or SIPs) does a given model, context length, and
latency SLO require?} Hyperscalers decide this along three axes that
must all be satisfied simultaneously. The total PE count is
$\max(A, B, C) \times N_{\text{replicas}}$, where $A$ is the capacity
floor, $B$ the KV-cache headroom, and $C$ the throughput SLO
(Table~\ref{tab:capacity-axes}).
\begin{table}[h]
\centering
\small
\caption{Three-axis sizing. $N$ = active params, $b$ = bytes per param,
$\mathrm{HBM}_{\mathrm{PE}}$ = per-PE HBM budget, $S_{kv}$ = context
length, $\mathrm{kv\_bpt}$ = KV cache bytes per token per user
($=2 \cdot h_{kv} \cdot d_{\text{head}} \cdot b \cdot L$).}
\label{tab:capacity-axes}
\begin{tabular}{@{}l l@{}}
\toprule
Axis & Formula \\
\midrule
A. Capacity floor & $\lceil N b / \mathrm{HBM}_{\mathrm{PE}} \rceil$ \\
B. KV headroom & $\lceil (N b + u \cdot S_{kv} \cdot \mathrm{kv\_bpt}) / \mathrm{HBM}_{\mathrm{PE}} \rceil$ \\
C. Throughput SLO & $N_{\text{replicas}} = \lceil n_{\text{users}} / B_{\mathrm{SLO}} \rceil$ \\
\bottomrule
\end{tabular}
\end{table}
\paragraph{SLO targets.} The per-token latency budget $B_{\mathrm{SLO}}$
follows the use case. TTFT (Time To First Token) is dominated by
prefill; TPOT (Time Per Output Token) by one decode step
(Table~\ref{tab:slo-targets}).
\begin{table}[h]
\centering
\small
\caption{Typical SLO targets. Voice needs a tight per-token cadence;
chat balances both; batch is priced on throughput not latency.}
\label{tab:slo-targets}
\begin{tabular}{@{}l l l@{}}
\toprule
Workload & TTFT & TPOT \\
\midrule
Voice / real-time & 200--300 ms & 10--25 ms \\
Interactive chat & 300 ms -- 1 s & 20--50 ms \\
Batch / offline & seconds -- minutes & N/A \\
\bottomrule
\end{tabular}
\end{table}
\paragraph{Regime-aware rules of thumb.} Context length relative to the
machine's balance point $L^*=2N/(\mathrm{AI}\cdot\mathrm{kv\_bpt})$
determines which strategy applies. Below $L^*$ the deployment is
compute-bound and batch scales to $\sim\!2B^*$; above, KV streaming
dominates and $B$ must shrink (Table~\ref{tab:regime-rules}).
\begin{table*}[t]
\centering
\small
\caption{Deployment strategy by context regime.}
\label{tab:regime-rules}
\begin{tabular}{@{}l l l@{}}
\toprule
Regime & Batch & Cost/token \\
\midrule
Short ($S_{kv} < L^*$) & $B \approx 2 B^*$ & low \\
Long ($S_{kv} > L^*$) & smaller $B$, more CP & higher \\
Extreme ($S_{kv} \gg L^*$) & dedicated pool, disagg.\ prefill & much higher \\
\bottomrule
\end{tabular}
\end{table*}
\paragraph{Sample deployment templates.} A single base model is
typically routed to one of several sharding tiers depending on the
request's expected context length; the API gateway dispatches to the
appropriate replica pool (Table~\ref{tab:deploy-templates}).
\begin{table*}[t]
\centering
\small
\caption{Sample deployment tiers for one base model. TP is fixed to
match $h_{kv}$; CP grows with context; $B$ shrinks to hold TPOT.}
\label{tab:deploy-templates}
\begin{tabular}{@{}l c c c c@{}}
\toprule
Tier & CP$\times$TP & Cubes/repl. & Max $S_{kv}$ & Typical $B$ \\
\midrule
Small & $1 \times 8$ & 1 & 32k & 64 \\
Medium & $4 \times 8$ & 4 & 128k & 32 \\
Large & $32 \times 8$ & 32 & 1M & 4 \\
\bottomrule
\end{tabular}
\end{table*}
\paragraph{What to do when each axis binds.} The dominant axis dictates
the first lever to pull (Table~\ref{tab:axis-playbook}). Axes $A$ and
$C$ scale nearly linearly with hardware; axis $B$ is the one where
algorithmic techniques (GQA, MLA, INT4 KV, sparse attention) buy the
most because they attack $\mathrm{kv\_bpt}$ directly rather than adding
chips.
\begin{table*}[t]
\centering
\small
\caption{Playbook per binding axis.}
\label{tab:axis-playbook}
\begin{tabular}{@{}l l l@{}}
\toprule
Binding & First lever & Second lever \\
\midrule
A. Capacity & $\uparrow$ TP / PP & bigger-HBM chip; FP8 / INT4 weights \\
B. KV & $\uparrow$ CP; $\downarrow B$ & GQA / MQA / MLA; INT4 KV; sparse attn \\
C. Throughput & $\uparrow$ replicas (DP) & loosen SLO; smaller model; spec.\ decoding \\
\bottomrule
\end{tabular}
\end{table*}
@@ -0,0 +1,91 @@
\subsection{Parallelism Selection: TP $\times$ CP $\times$ PP $\times$ DP $\times$ EP}
\label{sec:parallelism-selection}
Given a capacity-feasible layout (\S\ref{sec:capacity-planning}), the
remaining decision is \emph{how to shard}: which axes to enable and at
what degrees. Memory is the feasibility filter; once you fit, the
design problem is minimising \emph{exposed} communication (communication
that could not be overlapped with compute). The core principle is to
\textbf{rank techniques by how much they communicate per unit of
compute, and assign the chattiest ones to the fastest links.}
\begin{table*}[h]
\centering
\small
\caption{Parallelism axes at a glance. TP AllReduce volume does not
shrink with degree — only compute does — which sets its practical
ceiling near a fast-link (NVLink / intra-SIP) domain.}
\label{tab:parallelism-compare}
\begin{tabular}{@{}l l l l l@{}}
\toprule
Axis & Shards & Comm pattern & Frequency & Hard ceiling \\
\midrule
DP & optimizer state (w/ ZeRO) & AllReduce (grads) & 1$\times$/step & critical batch \\
TP & weights + acts + KV heads & AllReduce & 2$\times$/layer & fast-link domain; $\lesssim h_{kv}$ \\
PP & weights + KV by layer & P2P activation hand-off & per stage boundary & $n_{\text{layers}}$ \\
CP & activations + KV by position & Ring P2P per hop & per layer (overlappable) & $\mathrm{seq\_len} / \mathrm{block}$ \\
EP & expert weights (MoE only) & All-to-all & 2$\times$/MoE layer & $n_{\text{experts}}$ \\
\bottomrule
\end{tabular}
\end{table*}
\paragraph{Symptom-driven axis selection.} The dominant problem dictates
the first knob to try (Table~\ref{tab:parallelism-problem}).
\begin{table}[h]
\centering
\small
\caption{Which axis to reach for first, by dominant problem.}
\label{tab:parallelism-problem}
\begin{tabular}{@{}l l@{}}
\toprule
Dominant problem & First axis \\
\midrule
Weight memory doesn't fit & TP within a fast domain \\
KV of ONE long sequence & CP \\
KV for MANY sequences & DP replicas \\
Single-request TPOT & TP ($\lesssim 8$) \\
Aggregate throughput & Outer DP \\
Model spans multiple nodes & TP intra-node, PP inter-node \\
MoE expert weight memory & EP \\
\bottomrule
\end{tabular}
\end{table}
\paragraph{When to add, when to stop.} Every axis has a natural
saturation point past which further degree hurts more than it helps
(Table~\ref{tab:parallelism-criteria}). Sizing decisions are almost
always constrained by two axes simultaneously (e.g.\ TP by NVLink
domain and $h_{kv}$; CP by ring-hop hiding and per-rank block size).
\begin{table*}[t]
\centering
\small
\caption{Add / stop criteria per axis.}
\label{tab:parallelism-criteria}
\begin{tabular}{@{}l l l@{}}
\toprule
Axis & Add when & Stop when \\
\midrule
DP & more independent requests & global batch $>$ critical \\
TP & weights need sharding or TPOT tight & collectives dominate; local GEMMs shrink \\
PP & depth too large; TP would cross slow links & bubble $>$ $\sim$10\% \\
CP & one sequence needs position sharding & ring hops can't overlap compute \\
EP & MoE expert memory & expert GEMMs too small; routing imbalance \\
\bottomrule
\end{tabular}
\end{table*}
\paragraph{Common misconceptions.} Three widely repeated rules survive
contact with real workloads only partially:
\emph{(i)} memory is not just a feasibility filter but a continuous
performance variable --- more free HBM enables larger $B$, more prefix
cache, higher throughput even after weights fit;
\emph{(ii)} the ``TP $\leq h_{kv}$'' ceiling is an efficiency
preference, not a correctness constraint --- vLLM and others support
KV-head replication and head-dim splitting for TP $>$ $h_{kv}$;
\emph{(iii)} ``always start at TP=8'' is disproven by public
disclosures (DeepSeek-V3 inference uses TP=4, some MLPs at TP=1;
DeepSeek-V3 training uses PP=16 + EP=64 with no TP), which shows the
right starting point is the smallest TP that fits memory and meets
latency, followed by measurement.
@@ -0,0 +1,515 @@
\subsection{Data Placement Policy}
\label{sec:gqa-placement}
Long-context decode is bound by the KV cache, so the first-order design
question is how to place that cache---and the running softmax state it
feeds---across the two hardware axes the machine exposes: the CUBEs and,
within each CUBE, the PEs (here $C{=}8$ CUBEs $\times$ $P{=}8$ PEs, for
$C\!\cdot\!P{=}64$ attention engines over one KV-head group on the
LLaMA-3.1-70B target). Each axis can \emph{replicate} the KV cache or
\emph{shard} it, and a shard can run along the sequence dimension
$S_{kv}$ or the head dimension $d_{\text{head}}$. The cross product is a
small, enumerable taxonomy; six placements span its meaningful corners
(Figure~\ref{fig:gqa-kv-sharding}).
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_long_ctx_6cases_kv_sharding_diagram.png}
\caption{The six KV-placement strategies, drawn on the
$S_{kv}\!\times\!d_{\text{head}}$ KV tensor (rows = sequence, columns =
head dimension). Cube colour bands and dashed PE dividers show which
axis each level shards. Cases~1--3 either replicate the cache or shard
it on a single axis (8-way at most); Cases~4--6 reach a full 64-way
split, three different ways: Case~4 splits $S_{kv}$ across CUBEs and
$d_{\text{head}}$ across PEs, Case~5 the mirror, and Case~6~$\star$
splits $S_{kv}$ on \emph{both} axes.}
\label{fig:gqa-kv-sharding}
\end{figure}
Two quantities decide which placement is viable, and they pull against
each other. The first is \textbf{per-PE KV memory}. With a per-PE HBM
budget of \SI{6.0}{\giga\byte} and \SI{1.76}{\giga\byte} of attention
weights resident, the KV headroom is \SI{4.24}{\giga\byte} per PE. At a
production context of $S_{kv}{=}1\,\text{M}$ tokens the unsharded cache
is \SI{40}{\giga\byte}/PE (Case~1), an 8-way shard is \SI{5}{\giga\byte}
(Cases~2--3)---both \emph{over} the headroom---while only the 64-way
placements bring it to \SI{640}{\mega\byte}/PE (Cases~4--6), comfortably
inside budget. Memory alone therefore eliminates Cases~1--3 at long
context. The second quantity is \textbf{communication per token}, and it
is what separates the three survivors. Sharding $d_{\text{head}}$
(Cases~4--5) makes each PE hold only a slice of every head, so the
$Q\!\cdot\!K^{\top}$ score is \emph{partial} and must be all-reduced
across the slice owners on every token---a reduction whose volume scales
with $S_{kv}$ ($\sim$\SI{166}{\mega\byte}/token analytically, intra-CUBE
on the NoC for Case~4, inter-CUBE on UCIe for Case~5). Case~6~$\star$
instead shards $S_{kv}$ on both axes, so every PE computes a
\emph{complete} score over its own token range and only the small running
softmax state $(m,\ell,O)$ is merged across PEs
($\sim$\SI{6.2}{\mega\byte}/token)---a $\sim$27$\times$ lighter collective
than the $d_{\text{head}}$-split designs.
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_long_ctx_6cases_summary.png}
\caption{Long-context placement analysis at $S_{kv}{=}1\,\text{M}$ tokens.
\emph{Left:} the per-PE HBM budget---\SI{1.76}{\giga\byte} of attention
weights leave \SI{4.24}{\giga\byte} of KV headroom (red line).
\emph{Middle:} per-PE KV memory per case (log scale); only the 64-way
placements (Cases~4--6, \SI{640}{\mega\byte}) clear the headroom, while
the unsharded (\SI{40}{\giga\byte}) and 8-way (\SI{5}{\giga\byte}) cases
overflow. \emph{Right:} analytical communication per token (log scale);
the $d_{\text{head}}$-split Cases~4--5 pay a partial-score all-reduce
($\sim$\SI{166}{\mega\byte}/token) that the both-axes-$S_{kv}$ split of
Case~6~$\star$ avoids ($\sim$\SI{6.2}{\mega\byte}/token, merging only the
softmax state). On these two axes Case~6 (marked $\star$ in the figure)
is the single placement that lands both inside the memory budget and at
low per-token communication; whether that combination is the right one to
pick is a regime-specific question taken up next.}
\label{fig:gqa-budget}
\end{figure}
These two costs---per-PE KV memory and per-token communication---are
intrinsic properties of each placement, fixed by how it shards the cache
and independent of the workload regime. They do not by themselves name a
winner: a short prompt where the whole cache fits on one PE values low
communication and tolerates replication, whereas a million-token decode
is bound by the memory wall and will pay communication to escape it.
Which placement is appropriate is therefore a per-regime question, which
the short- and long-context subsections that follow answer by running the
options on the simulator and reading off latency, traffic, and the
redundant compute each one induces.
\subsection{Inference with Short-Context Length}
\label{sec:gqa-short}
The fused GQA kernel issues its matrix products as scheduler-managed
composite commands and keeps the online-softmax merge and the cross-device
KV reduction inside the kernel, on PE\_IPCQ. Two kernel families cover the
two phases. The \emph{prefill} kernel splits the query tile across the PEs
of a group and broadcasts each KV tile from the group's root PE over
intra-CUBE IPCQ, so the HBM K/V read is paid once per group instead of
once per PE. The \emph{decode} kernel sequence-shards the KV cache across
the same group of PEs, runs per-PE local attention, then chain-reduces the
partial $(m,\ell,O)$ triples back up to the group root. Two further
primitives make long context practical: a \emph{lazy load} that issues the
KV \textsf{DMA\_READ} and returns immediately, auto-waiting only at first
use so KV load overlaps score computation; and per-tile \emph{scratch
recycling} that keeps the running accumulators in a persistent arena while
freeing per-tile temporaries, so the kernel fits the
\SI{1}{\mebi\byte} scratch budget across many tiles. A further refinement
restructures the decode step so the per-tile matrix products and the
online-softmax merge are issued as \emph{composite} commands rather than
hand-tiled primitives; \S\ref{sec:gqa-composite} measures it against the
primitive baseline at long context.
This is a different decomposition from the six-case placement taxonomy of
\S\ref{sec:gqa-placement}: that taxonomy spreads a single KV-head group
across all 64 PEs and is the right lens for long context
(\S\ref{sec:gqa-long}), whereas short context---where the whole cache fits
comfortably---turns instead on how the eight KV heads are distributed
across the eight CUBEs. The design question for short context is therefore
not whether to fuse softmax---that is settled---but how to distribute the
eight KV heads across the eight CUBEs of a SIP. Four mappings cover the spectrum, named
by KV-head count per CUBE: \textsf{1-kv-per-cube} dedicates one whole
CUBE to each head and uses all eight PEs of that CUBE on the head's
sequence shard; \textsf{2-kv-per-cube} packs two heads per CUBE with
group size four; \textsf{4-kv-per-cube} packs four heads with group size
two; \textsf{8-kv-per-cube} loads all heads onto a single CUBE with one
PE per head. The mapping fixes a trade-off: as KV heads per CUBE grows, the per-CUBE KV
footprint grows linearly and the mapping uses proportionally fewer CUBEs
(eight down to one), but the intra-CUBE IPCQ broadcast/reduce cost shrinks
to zero (8-kv-per-cube uses a single PE per head and has no group
communication).
A second axis is how each GEMM tile is issued. We isolate this with three
\emph{composite tiers} applied to the same mapping: \textsf{without
composite} uses primitive \textsf{tl.dot} and unfused softmax;
\textsf{with composite (GEMM-only)} issues the GEMMs as
\textsf{tl.composite(op="gemm")} commands so the scheduler can pipeline
the tile stages but leaves softmax as primitives; \textsf{with composite
+ softmax\_merge} additionally folds the softmax fold into the $P\!\cdot\!V$
composite through a named \textsf{softmax\_merge} prologue recipe.
We sweep all four mappings $\times$ three composite tiers $\times$
$S_{kv}\in\{8\text{K},16\text{K},32\text{K},64\text{K}\}$ for both
phases (prefill uses $T_q{=}8$ sliced tiles; decode uses $T_q{=}1$). The
headline latency comparison is in Figure~\ref{fig:gqa-short-wall-baseline}.
The mappings separate cleanly, and in proportion to how many PEs each
dedicates to a head: \textsf{1-kv-per-cube} spreads one head across all
eight PEs of its CUBE, \textsf{8-kv-per-cube} gives each head a single PE,
and the four mappings form a $\{64,32,16,8\}$-active-PE ladder. Decode
latency tracks that ladder inversely---at $S_{kv}{=}64$K the
\textsf{8-kv-per-cube}/\textsf{1-kv-per-cube} ratio is $7.4\times$
($\SI{87.0}{}$ vs.\ $\SI{11.8}{\micro\second}$), and even at $8$K it is
$4.8\times$ ($\SI{10.8}{}$ vs.\ $\SI{2.2}{\micro\second}$); prefill shows
the same ordering more gently ($2.3\times$ at $64$K). The reason is that
decode here is \emph{bandwidth-bound}: each active PE streams its KV shard
at \SI{46}{}--\SI{76}{\percent} of the \SI{256}{\giga\byte\per\second}
per-PE ceiling (rising with context), so a mapping's speed is set by how
many PEs it puts to work---more PEs, more aggregate HBM bandwidth, lower
latency. The $M{=}G{=}8$ score GEMM is skinny and leaves the MAC array
lightly loaded throughout; the lever here is data movement, not compute.
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_short_context/wall_variant1_mode_compare.png}
\caption{Wall-clock latency of the four short-context mappings, baseline
kernel (no composite; log scale). The mappings separate along the
$\{64,32,16,8\}$-active-PE ladder set by heads-per-CUBE:
\textsf{1-kv-per-cube} (64 PEs) is fastest and \textsf{8-kv-per-cube}
(8 PEs) slowest, by $4.8\times$ at $8$K growing to $7.4\times$ at $64$K in
decode ($2.3\times$ at $64$K in prefill). Decode is bandwidth-bound, so
latency scales inversely with the number of active PEs. Composite tiers
track the baseline at this skinny shape
(Figure~\ref{fig:gqa-short-a1-variant}).}
\label{fig:gqa-short-wall-baseline}
\end{figure}
Figure~\ref{fig:gqa-short-tradeoff} shows what the mappings trade for that
latency. The differentiator is \emph{density}: \textsf{8-kv-per-cube}
concentrates all eight heads onto one CUBE, so its per-CUBE KV footprint is
$8\times$ that of \textsf{1-kv-per-cube} (one CUBE per head), and it
occupies a single one of the SIP's sixteen CUBEs against
\textsf{1-kv-per-cube}'s eight. Per-PE HBM \emph{utilization}, by contrast,
is similar across mappings---every active PE runs near the same
\SI{46}{}--\SI{76}{\percent} of the per-PE ceiling---so the latency ladder
comes from the \emph{count} of active PEs, not from any per-PE
concentration. IPCQ traffic runs opposite to density:
\textsf{1-kv-per-cube} pays the eight-PE chain-reduce while
\textsf{8-kv-per-cube} (one PE per head) pays none, but absolute IPCQ volume
stays under \SI{120}{\kibi\byte} per run, two orders of magnitude below the
HBM traffic. The result is a genuine trade-off rather than a single winner:
\textsf{1-kv-per-cube} minimizes per-request latency by spending the most
hardware (eight CUBEs, 64 PEs) on one request, while denser mappings leave
CUBEs free for other requests---the batched-serving axis taken up below.
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_short_context/per_cube_tradeoff_mode_compare.png}
\caption{Per-CUBE trade-off across the four mappings (baseline kernel;
log scale). Top row: per-CUBE KV footprint---\textsf{8-kv-per-cube} packs
all eight heads onto one CUBE, an $8\times$ larger per-CUBE KV than
\textsf{1-kv-per-cube} and one CUBE used against eight (the $8\times$ ratio
is context-invariant; the absolute footprint grows with $S_{kv}$). Bottom
row: IPCQ traffic per run---\textsf{1-kv-per-cube} pays the eight-PE
chain-reduce while \textsf{8-kv-per-cube} (single PE per head) pays zero.
The density (top) is what sets how many concurrent requests a SIP can hold.}
\label{fig:gqa-short-tradeoff}
\end{figure}
The composite ablation in Figure~\ref{fig:gqa-short-a1-variant} shows the
three tiers within \textsf{1-kv-per-cube}: wall-clock is nearly flat, with
the GEMM-only composite tier a few percent \emph{slower}
(\SI{14.0}{} vs.\ \SI{11.8}{\micro\second} at $64$K decode) and the
\textsf{softmax\_merge} tier matching the baseline. This is the expected
outcome for the decode-skinny shape---$M{=}G{=}8$ is well below the
scheduler's $\textsf{TILE\_M}{=}32$ supertile (\S\ref{sec:gemm}), so the
composite path pads $M$ by $4\times$ with zeros and the fusion has no slack
to win back; the padding is pure overhead here. It shows up in
Figure~\ref{fig:gqa-short-gemm-util} as inflated GEMM-engine busy time on
the composite tiers---the padded-$M$ GEMM is charged in full against a
sub-\SI{15}{\micro\second} decode wall, driving the accounted utilization
well above the baseline's \SI{12}{}--\SI{18}{\percent}. Fusion benefit
requires lifting $M$, either by the long-context Q-tile split
(\S\ref{sec:gqa-composite}) or by batching multiple users---which is also
the lever that converts a mapping's density into throughput.
\paragraph{Latency versus batched throughput.} The latency ladder and the
density trade-off pull in opposite directions once a SIP serves more than
one request. Decode users are independent---each attends its own KV
cache---and these mappings generate no cross-CUBE traffic, so a SIP runs
several users at once on disjoint CUBE groups, up to $16/C$ users: two for
\textsf{1-kv-per-cube}, sixteen for \textsf{8-kv-per-cube}. We measure this
directly, launching $B$ concurrent users and reading aggregate latency off
the shared clock (Figure~\ref{fig:gqa-batch}). The overlap is nearly
perfect: aggregate latency stays within \SI{3}{}--\SI{5}{\percent} of the
single-user latency all the way to a full SIP (\textsf{8-kv-per-cube} at
$16$ users is \SI{11.4}{} vs.\ \SI{10.8}{\micro\second}), so that small
residual is the only cross-user interference the shared fabric adds.
Aggregate throughput at $8$K therefore rises almost linearly with $B$, from
\SI{0.86}{}~requests\,/\,\si{\micro\second} (\textsf{1-kv-per-cube}, capped
at two users) to \SI{1.41}{} (\textsf{8-kv-per-cube}, sixteen users): the
\emph{dense} mapping wins on throughput even though it is the \emph{slowest}
per request.
The reason is worth spelling out, because both mappings fill the same
hardware: at capacity \textsf{1-kv-per-cube} runs two users
$\times$ 64 PEs and \textsf{8-kv-per-cube} sixteen users $\times$ 8 PEs, so
each puts all 128 PEs of the SIP to work. What differs is per-PE
\emph{efficiency}. Splitting one head across eight PEs
(\textsf{1-kv-per-cube}) leaves each PE only $S_{kv}/8$ tokens---a single
tile---so its fixed overheads (pipeline fill on the lazy KV load, and the
eight-PE online-softmax chain-reduce) dominate the little streaming work it
does, and it sustains just \SI{46}{\percent} of the per-PE HBM ceiling.
Giving each head a whole PE (\textsf{8-kv-per-cube}) streams the full
$S_{kv}$-token cache contiguously with no reduce, amortizing those overheads
over $8\times$ more work and reaching \SI{76}{\percent}. The measured
throughput ratio ($1.41/0.86 = 1.6\times$) matches the utilization ratio
($0.76/0.46 = 1.7\times$) almost exactly: because decode is
bandwidth-bound, aggregate throughput is set by total HBM efficiency, not
by PE count. \textsf{1-kv-per-cube} is really spending hardware on
\emph{latency}---eight PEs per head buy only a $4.8\times$ speedup, a $60\%$
strong-scaling efficiency---and that same $40\%$ overhead is what caps its
throughput once the SIP is full. The design conclusion is regime-dependent: \textsf{1-kv-per-cube} is the
latency-optimal choice for single-stream or low-batch decode, while denser
mappings are the throughput-optimal choice for high-batch short-context
serving. At long context the per-request latencies already scale as $1/C$,
so the same accounting predicts the throughputs converge; a measured
long-context batch sweep is 2H work (\S\ref{sec:future}).
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_short_context/batch_scaling.png}
\caption{Batch scaling at $S_{kv}{=}8$K: aggregate throughput (requests per
\si{\micro\second}) versus the number of concurrent decode users on one SIP,
each user on a disjoint CUBE group. Each mapping scales almost linearly with
$B$ up to its SIP capacity $16/C$---\textsf{1-kv-per-cube} saturates at two
users, \textsf{8-kv-per-cube} at sixteen. Solid segments are measured
concurrent runs; the dashed extensions are the saturated-throughput ceiling
beyond capacity, where further users run in waves at that sustained rate
(so the SIP is full, not idle). The dense mapping reaches the highest
ceiling; the spread mapping is latency-optimal but capacity-limited.
Concurrent users overlap to within \SI{3}{}--\SI{5}{\percent} of the
single-user latency.}
\label{fig:gqa-batch}
\end{figure}
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_short_context/wall_a1_variant_compare.png}
\caption{Composite-tier ablation on \textsf{1-kv-per-cube} (log scale):
wall-clock is nearly flat across tiers---the GEMM-only composite runs a few
percent slower (padding overhead) and \textsf{softmax\_merge} matches the
baseline. With $M{=}G{=}8 < \textsf{TILE\_M}{=}32$ the composite scheduler
pads $M$ by $4\times$, leaving no fusion slack to recover at this shape.
Fusion pays off only when $M$ fills the supertile---long-context Q-tile
splitting or batched-$M$ inference.}
\label{fig:gqa-short-a1-variant}
\end{figure}
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_short_context/gemm_util_a1_variant_ablation.png}
\caption{GEMM-engine busy fraction on \textsf{1-kv-per-cube} across the
three composite tiers. The baseline runs at \SI{12}{}--\SI{18}{\percent};
the composite tiers read much higher---up to and past
\SI{100}{\percent} of the short decode wall---because the padded
$8\!\to\!32$ $M$ dimension is charged as GEMM time, bookkeeping overhead
rather than useful work. The engine is never the bottleneck at this shape;
decode is KV-bandwidth-bound.}
\label{fig:gqa-short-gemm-util}
\end{figure}
\subsection{Inference with Long-Context Length}
\label{sec:gqa-long}
% TODO: prefill long-context kernel implementation description
% (Sequence-Parallel partition of S_kv, per-case mechanics).
% Bench code: src/kernbench/benches/gqa_helpers/long_ctx/
% TODO: prefill long-context performance figure.
Long-context decode is the regime where the KV cache, not attention
compute, sets serving cost, so the placement question of
\S\ref{sec:gqa-placement} becomes decisive here. To pick the right
placement for this regime we run each of the six options as a fused
decode kernel on the simulator (one decode step on the LLaMA-3.1-70B
single-KV-head-group target, $C{=}8$ CUBEs $\times$ $P{=}8$ PEs) at a
tractable $S_{kv}{=}8192$, and read off end-to-end latency, on-device op
traffic, and the redundant compute each one induces. The swept context is
small enough that all six fit in memory at $S_{kv}{=}8192$; the
placements the long-context memory budget rules out (Cases~1--3) are
drawn in red, run here only to expose their issue and communication
structure.
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_decode_long_ctx_6cases_latency.png}
\caption{Measured end-to-end decode latency per placement
($S_{kv}{=}8192$; red = ruled out by the long-context memory budget,
blue = the predicted Pareto choice). The fastest raw latency belongs to
Case~3 (\SI{17.8}{\micro\second})---but Case~3 replicates the full KV
cache into every CUBE, so it overflows the per-PE budget at production
context and wastes 8$\times$ the compute
(Figure~\ref{fig:gqa-6cases-par}). Among the placements that actually fit
1\,M-token memory (Cases~4--6), Case~6~$\star$ is the fastest
(\SI{30.6}{\micro\second}, versus \SI{31.4}{} and \SI{34.5}{\micro\second}
for the $d_{\text{head}}$-split Cases~4 and~5)---making it the placement
of choice for long-context decode.}
\label{fig:gqa-6cases-lat}
\end{figure}
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_decode_long_ctx_6cases_parallelism.png}
\caption{Redundant compute per placement, measured as active-PE
$\times$ $S_{\text{local}}$ (PE-tokens; lower means less wasted work).
The minimum is \num{8192} PE-tokens---one pass over the sequence.
Case~3 inflates this 8$\times$ to \num{65536} by replicating the KV
cache across all eight CUBEs so every CUBE redundantly re-attends the
whole sequence; the $d_{\text{head}}$-split Cases~4--5 likewise carry
\num{65536} because each token is processed across eight head slices.
Case~6~$\star$ achieves the full 64-way split at the minimal
\num{8192} PE-tokens---fully parallel, no replication.}
\label{fig:gqa-6cases-par}
\end{figure}
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_decode_long_ctx_6cases_traffic.png}
\caption{Measured on-device op traffic per placement. The unsharded
Case~1 issues no IPCQ copies (each PE has the full cache, nothing to
reduce); the single-axis Case~3 charges 168. Among the 64-way splits,
the $d_{\text{head}}$-split Cases~4--5 charge the most---280 IPCQ copies
and 8 DMA writes each, the partial-score all-reduce that head-slicing
forces---while Case~6~$\star$ needs only 189 IPCQ copies and a single DMA
write, because it merges just the running softmax state $(m,\ell,O)$
rather than partial scores. This is the on-device collective traffic that
PE\_IPCQ and the torus links of \S\ref{sec:allreduce} are provisioned to
absorb at link speed.}
\label{fig:gqa-6cases-traffic}
\end{figure}
Taken together the three panels select the placement. The only
memory-feasible family at production context is the 64-way splits
(Cases~4--6), and within it Case~6~$\star$ is both the fastest and the
lightest-communicating, because it merges only the running softmax state
rather than the partial scores that the $d_{\text{head}}$-split
Cases~4--5 must all-reduce. Case~3's lower raw latency is beside the
point---it replicates the full cache into every CUBE, overflowing the
per-PE budget and wasting $8\times$ the compute. For long-context decode
the placement of choice is therefore the both-axes sequence shard, and the
cross-PE softmax reduction it does pay is precisely the traffic the
communication-side codesign of this report is built to move quickly.
\subsection{Use of Composite Commands}
\label{sec:gqa-composite}
The decode kernel of \S\ref{sec:gqa-long} issues its local attention as
primitive operations: it walks each PE's $S_{\text{local}}$ token slice in
\SI{1024}{}-token tiles, and for every tile issues a $Q\!\cdot\!K^{\top}$
\textsf{dot}, the online-softmax primitives, and a $P\!\cdot\!V$
\textsf{dot}, merging the running $(m,\ell,O)$ state by hand. The number
of PE\_CPU commands this costs grows with the context. At a production
context of $S_{kv}{=}1\,\text{M}$ tokens the Case-6 64-way split gives
each PE $S_{\text{local}}{=}16384$ tokens, so its local attention is a
$Q\!\cdot\!K^{\top}$ of $(8,128)\!\cdot\!(128,16384)$ and a
$P\!\cdot\!V$ of $(8,16384)\!\cdot\!(16384,128)$---sixteen hand-issued
tiles, each a fresh batch of CPU commands.
The composite command lets the kernel hand that tiling to PE\_SCHEDULER.
We compare three command forms of the \emph{same} Case-6 kernel---identical
placement and identical $(m,\ell,O)$ reduce, differing only in how the
local attention is issued:
\begin{itemize}
\item \textbf{primitive}---the hand-tiled \textsf{dot}/softmax kernel
above (the baseline of \S\ref{sec:gqa-long}).
\item \textbf{composite}---each matrix product is one coarse
\textsf{composite} GEMM over the \emph{whole} $S_{\text{local}}$, with
$K$ and $V$ passed as HBM references so PE\_SCHEDULER streams and
tiles them on the fixed $32\!\times\!64\!\times\!32$ MAC tile; the
softmax stays primitive.
\item \textbf{composite\,+\,softmax\_merge}---additionally folds the
online-softmax merge and $P\!\cdot\!V$ into a single stateful
\textsf{softmax\_merge} recipe composite.
\end{itemize}
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_decode_long_ctx_composite.png}
\caption{Three command forms of the Case-6 decode kernel, swept over
context length ($S_{\text{local}}{=}S_{kv}/64$ per PE). \emph{Right:}
PE\_CPU commands issued. The hand-tiled primitive kernel rises
$O(n_{\text{tiles}})$---from 96 commands at one tile to 426 at the
1\,M-token, sixteen-tile production point---while both composite forms
issue a context-\emph{independent} $O(1)$ count (94 and 98) that
\emph{saturates}: one coarse descriptor offloads the entire per-tile
fan-out. \emph{Left:} the consequence for wall-clock latency is none---all
three land on the same curve (\SI{30.6}{}, \SI{231}{},
\SI{461}{\micro\second} at 8\,K\,/\,64\,K\,/\,128\,K), because decode is
bound by streaming the KV cache, not by issue. Command-count is measured
at emit time (exact, to 1\,M); latency on the data-mode engine over the
tractable range.}
\label{fig:gqa-composite}
\end{figure}
Figure~\ref{fig:gqa-composite} reads off the two quantities that matter,
and they point in opposite directions. The PE\_CPU command count (right)
collapses from a context-growing $O(n_{\text{tiles}})$ to a flat $O(1)$:
at 1\,M tokens the composite form issues \num{94} commands against the
primitive kernel's \num{426}, a $4.5\times$ reduction
($4.2\times$ in modeled dispatch cost), and---crucially---that number no
longer grows with context. The wall-clock latency (left), by contrast,
is unchanged across all three forms: decode is bound by streaming the KV
cache out of HBM, so the command form does not move the critical path.
That juxtaposition is the point. The composite command is not a latency
optimization for this memory-bound decode; it is a \emph{CPU-issue}
optimization. Its value is removing the per-tile dispatch work that would
otherwise grow without bound as context grows, freeing PE\_CPU to run
ahead and keep the engines fed---which is exactly what lets the
data-movement cost analyzed next show through as the true bottleneck
rather than being masked by issue overhead. The \textsf{softmax\_merge}
recipe folds the online merge into the same descriptor; on this
memory-bound path its marginal cost over the plain GEMM composite is small
(98 vs.\ 94 commands), and like the plain composite it keeps the issued
count flat as context scales.
\paragraph{The compute-bound mirror: prefill.} Decode's verdict---command
form is latency-neutral---is a property of its regime, not of the
composite command. A decode step has $T_q{=}1$, so its score and context
products are skinny ($M{=}G\,T_q{=}8$): the MAC array is barely fed and
the kernel is bound by streaming the KV cache. Prefill is the opposite
corner. It processes a block of query positions at once, so $M{=}G\,T_q$
is large and tile-filling, the GEMMs carry real arithmetic intensity
($\sim$$M$ flops/byte, well above the roofline ridge), and the kernel is
\emph{compute-bound}. This is the regime the composite command was built
for (\S\ref{sec:gemm}): it streams the per-HW-tile
DMA$\rightleftarrows$compute pipeline so the MAC array stays fed, whereas
the primitive kernel's blocking \textsf{tl.dot} serializes each tile's
load and compute and starves the array between tiles. We run the same
three command forms on a single-rank compute-bound prefill (FlashAttention
$Q$-block $\times$ $S_{kv}$-tile, online softmax) and sweep the context
length (Figure~\ref{fig:gqa-prefill-cb}).
\begin{figure}[t]
\centering
\includegraphics[width=\linewidth]{gqa_prefill_compute_bound.png}
\caption{Compute-bound prefill, three command forms, swept over context
length ($M{=}8\,T_q$ tile-filling). \emph{Left:} end-to-end latency.
\emph{Right:} MAC utilization (achieved $\div$ the
\SI{8}{\tera\flop\per\second} per-PE peak). The hand-tiled primitive sits
flat at $\sim$\SI{68}{\percent}---its serial load$\to$dot path leaves the
MAC array idle between tiles regardless of context. The composite forms
climb with context (\SI{67}{}$\to$\SI{80}{\percent} plain,
\SI{67}{}$\to$\SI{83}{\percent} with the recipe) because a deeper $P\!\cdot
\!V$ reduction gives more HW tiles to pipeline, and they convert that into
wall-clock: at \num{1024} the recipe form is \SI{646.9}{} vs.\
\SI{794.1}{\micro\second} (\SI{19}{\percent} faster). The margin
\emph{grows} with context---the compute-bound mirror of the GEMM result of
\S\ref{sec:gemm}.}
\label{fig:gqa-prefill-cb}
\end{figure}
The two studies together state the composite command's value precisely. It
has two distinct benefits, and which one matters is set by the workload's
roofline position. The first is \emph{host-issue offload}: one macro
command in place of $O(n_{\text{tiles}})$ fine ones, which removes
PE\_CPU dispatch work and is regime-independent (it shows in the decode
command count). The second is \emph{MAC-array feeding}: the
scheduler-internal per-tile DMA$\rightleftarrows$compute pipeline, which
only converts to latency when the workload is compute-bound enough to have
a MAC array worth keeping busy (it shows in the prefill utilization). A
memory-bound decode exercises only the first; a compute-bound prefill
exercises both. The composite command is the single mechanism that
delivers each where it applies.
% Summary moved to sections/05z-summary.tex so the three new
% subsections (roofline, capacity planning, parallelism selection)
% can appear between the technical results and the closing summary.
% TODO: cross-regime DP (data parallelism) applicability:
% - Does Case-4 long-context placement compose with batch-level DP
% without further changes?
% - Does the short-context placement compose the same way?
% - Implications for multi-user serving (single vs. mixed regimes).
@@ -0,0 +1,22 @@
\subsection{Summary}
\label{sec:gqa-analysis}
The section's results line up into one picture of the fused kernel.
Placement is decided by memory first and communication second: only the
64-way splits fit production context, and among them the both-axes
sequence shard (Case~6) minimizes the per-token collective by merging
softmax state rather than partial scores. Command form is decided by
roofline position: the composite command removes the per-tile issue work
in both regimes, but converts to wall-clock only in compute-bound prefill,
while memory-bound decode stays bound by KV streaming regardless of command
form. The thread connecting the two is that, once composite issue makes
GEMM cheap and leaves the MAC array idle, the fused kernel's latency is set
almost entirely by data movement---streaming the KV cache and reducing
partials across PEs---which is exactly the cost the communication-side
work (on-device reduction, lazy load/compute overlap, fast TCM staging and
torus links) is built to attack. Roofline framing
(\S\ref{sec:roofline}) turns this observation into a sizing and sharding
programme in the next two subsections: capacity planning
(\S\ref{sec:capacity-planning}) and parallelism selection
(\S\ref{sec:parallelism-selection}). What this implies for hardware
investment across the report as a whole is taken up in the discussion.
@@ -0,0 +1,104 @@
"""Generate roofline figures for the paper's Roofline Analysis section.
Produces two 2-panel PNGs into docs/report/1H-codesign-paper/figures/:
- roofline_short_context.png (S_kv = 8K) — batch drops per-token cost
- roofline_long_context.png (S_kv = 1M) — batch effect vanishes
Each figure shows both the step-latency view (raw step time vs B, with
weight/compute/KV components) and the cost-per-token view (÷B), which
together make the batch-vs-context story visible at a glance.
Run: python tests/analytical_visualization/_gen_roofline_paper_figs.py
"""
from __future__ import annotations
import sys
from pathlib import Path
# Ensure the repo root is on sys.path so `tests.analytical_visualization`
# imports resolve when invoked as a script.
_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from tests.analytical_visualization.chip_roofline import (
critical_batch,
per_token_latency_curve,
step_latency_curve,
)
from tests.analytical_visualization.model_config import MachineParams
from tests.analytical_visualization.model_presets import PRESETS
FIGURES_DIR = Path(__file__).resolve().parents[2] / (
"docs/report/1H-codesign-paper/figures"
)
FIGURES_DIR.mkdir(parents=True, exist_ok=True)
MACHINE = MachineParams()
MODEL = PRESETS["Llama 3 70B"].model
B_RANGE = [1, 2, 4, 8, 16, 32, 64, 128, 256]
def _plot_pair(s_kv: int, label: str, out_path: Path) -> None:
step = step_latency_curve(MACHINE, MODEL, B_RANGE, s_kv=s_kv)
tok = per_token_latency_curve(MACHINE, MODEL, B_RANGE, s_kv=s_kv)
xs = [p.batch for p in step]
b_star = critical_batch(MACHINE, MODEL)
fig, (axA, axB) = plt.subplots(1, 2, figsize=(11, 4.2))
# ── Left: step latency (undivided) ────────────────────────────────
axA.plot(xs, [p.weight_s * 1e3 for p in step], "^-",
color="#ffbe0b", label="Weight fetch (flat)")
axA.plot(xs, [p.compute_s * 1e3 for p in step], "o-",
color="#3a86ff", label="Compute (linear)")
axA.plot(xs, [p.kv_s * 1e3 for p in step], "s-",
color="#d90429", label="KV fetch (linear)")
axA.plot(xs, [p.total_s * 1e3 for p in step], "-",
color="#212529", linewidth=2.5, label="Total")
axA.set_xscale("log", base=2)
axA.set_yscale("log")
axA.set_xlabel("Batch size B")
axA.set_ylabel("Step time (ms)")
axA.set_title(f"Step latency ({label} context, $S_{{kv}}={s_kv:,}$)")
axA.grid(True, which="both", alpha=0.3)
axA.legend(fontsize=8, loc="upper left")
# ── Right: cost per token (÷ B) ───────────────────────────────────
axB.plot(xs, [p.weight_s * 1e3 for p in tok], "^-",
color="#ffbe0b", label="Weight fetch ($\\propto 1/B$)")
axB.plot(xs, [p.compute_s * 1e3 for p in tok], "o--",
color="#3a86ff", label="Compute (flat)")
axB.plot(xs, [p.kv_s * 1e3 for p in tok], "s--",
color="#d90429", label="KV fetch (flat)")
axB.plot(xs, [p.total_s * 1e3 for p in tok], "-",
color="#212529", linewidth=2.5, label="Total")
axB.axvline(b_star, linestyle=":", color="#2e7d32",
label=f"$B^*={b_star:.0f}$")
axB.set_xscale("log", base=2)
axB.set_yscale("log")
axB.set_xlabel("Batch size B")
axB.set_ylabel("Per-token time (ms)")
axB.set_title(f"Cost per token ({label} context, $S_{{kv}}={s_kv:,}$)")
axB.grid(True, which="both", alpha=0.3)
axB.legend(fontsize=8, loc="upper right")
plt.tight_layout()
fig.savefig(out_path, dpi=150, bbox_inches="tight")
plt.close(fig)
print(f" wrote {out_path.name}")
def main() -> None:
print("Generating roofline figures for the paper ...")
_plot_pair(8_192, "short", FIGURES_DIR / "roofline_short_context.png")
_plot_pair(1_048_576, "long", FIGURES_DIR / "roofline_long_context.png")
print(f"Done. Output: {FIGURES_DIR}")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -419,6 +419,7 @@ def run_auto_explore(
mode: str = "decode", mode: str = "decode",
include_attention: bool = True, include_attention: bool = True,
include_ffn: bool = True, include_ffn: bool = True,
b: int = 1,
) -> AutoExploreResult: ) -> AutoExploreResult:
"""Enumerate all configs, score each, extract Pareto frontier. """Enumerate all configs, score each, extract Pareto frontier.
@@ -433,6 +434,8 @@ def run_auto_explore(
all_scores: list[ConfigScore] = [] all_scores: list[ConfigScore] = []
total_enumerated = 0 total_enumerated = 0
for topo in enumerate_configs(model, s_kv, mode): for topo in enumerate_configs(model, s_kv, mode):
# Inject the caller's batch B into every candidate.
topo.b = b
total_enumerated += 1 total_enumerated += 1
cfg = FullConfig(model=model, topo=topo, machine=machine) cfg = FullConfig(model=model, topo=topo, machine=machine)
all_scores.append(score_config( all_scores.append(score_config(
@@ -237,6 +237,7 @@ def _best_parallelism_for_hw(
model: ModelConfig, machine: MachineParams, model: ModelConfig, machine: MachineParams,
s_kv: int, mode: str, s_kv: int, mode: str,
include_attention: bool = True, include_ffn: bool = True, include_attention: bool = True, include_ffn: bool = True,
b: int = 1,
) -> ConfigScore | None: ) -> ConfigScore | None:
"""Return the latency-minimum feasible parallelism for this HW. """Return the latency-minimum feasible parallelism for this HW.
@@ -245,14 +246,20 @@ def _best_parallelism_for_hw(
matches the caller's attention/FFN/full choice. matches the caller's attention/FFN/full choice.
""" """
best: ConfigScore | None = None best: ConfigScore | None = None
best_key: tuple | None = None
for topo in _iter_reduced_parallelism(model, s_kv, mode): for topo in _iter_reduced_parallelism(model, s_kv, mode):
topo.b = b
cfg = FullConfig(model=model, topo=topo, machine=machine) cfg = FullConfig(model=model, topo=topo, machine=machine)
s = score_config(cfg, include_attention=include_attention, s = score_config(cfg, include_attention=include_attention,
include_ffn=include_ffn) include_ffn=include_ffn)
if not (s.fits_memory and s.placement_valid): if not (s.fits_memory and s.placement_valid):
continue continue
if best is None or s.total_latency_ns < best.total_latency_ns: # Compound tiebreaker: prefer fewer PEs and lower HBM % when latency
# ties across variant knobs (cp_ring, placement, kv_shard_mode, etc.).
_k = (s.total_latency_ns, s.pes_used, s.hbm_utilization)
if best is None or _k < best_key:
best = s best = s
best_key = _k
return best return best
@@ -260,13 +267,14 @@ def _best_parallelism_two_stage(
model: ModelConfig, machine: MachineParams, model: ModelConfig, machine: MachineParams,
s_kv: int, mode: str, s_kv: int, mode: str,
include_attention: bool = True, include_ffn: bool = True, include_attention: bool = True, include_ffn: bool = True,
b: int = 1,
) -> ConfigScore | None: ) -> ConfigScore | None:
"""Fast fallback: use autosuggest's memory-min (CP,TP,PP) then score it.""" """Fast fallback: use autosuggest's memory-min (CP,TP,PP) then score it."""
sug = auto_suggest(model, machine, s_kv, mode) sug = auto_suggest(model, machine, s_kv, mode)
if not sug.fits: if not sug.fits:
return None return None
topo = TopologyConfig( topo = TopologyConfig(
cp=sug.cp, tp=sug.tp, pp=sug.pp, dp=1, cp=sug.cp, tp=sug.tp, pp=sug.pp, dp=1, b=b,
s_kv=s_kv, mode=mode, s_kv=s_kv, mode=mode,
kv_shard_mode="split", kv_shard_mode="split",
ffn_shard_scope="TP+CP", ffn_shard_scope="TP+CP",
@@ -361,6 +369,7 @@ def joint_explore(
depth: str = "balanced", depth: str = "balanced",
include_attention: bool = True, include_attention: bool = True,
include_ffn: bool = True, include_ffn: bool = True,
b: int = 1,
) -> JointExploreResult: ) -> JointExploreResult:
"""Sweep HW candidates × parallelism, return joint Pareto + sensitivity. """Sweep HW candidates × parallelism, return joint Pareto + sensitivity.
@@ -378,11 +387,13 @@ def joint_explore(
par = _best_parallelism_two_stage( par = _best_parallelism_two_stage(
model, machine, s_kv, mode, model, machine, s_kv, mode,
include_attention=include_attention, include_ffn=include_ffn, include_attention=include_attention, include_ffn=include_ffn,
b=b,
) )
else: else:
par = _best_parallelism_for_hw( par = _best_parallelism_for_hw(
model, machine, s_kv, mode, model, machine, s_kv, mode,
include_attention=include_attention, include_ffn=include_ffn, include_attention=include_attention, include_ffn=include_ffn,
b=b,
) )
if par is None: if par is None:
continue continue
+55 -15
View File
@@ -32,6 +32,8 @@ class Suggestion:
fits: bool fits: bool
pes_used: int pes_used: int
sips_used: int sips_used: int
cubes_used: int = 0 # picked to minimize this (see _score_candidate)
cp_placement: str = "cube" # "pe" packs CP into intra-cube PEs when it fits
reason: str = "" reason: str = ""
@@ -58,10 +60,33 @@ def _iter_candidates(model: ModelConfig) -> Iterable[tuple[int, int, int]]:
def _score_candidate(cp: int, tp: int, pp: int, def _score_candidate(cp: int, tp: int, pp: int,
model: ModelConfig, machine: MachineParams, model: ModelConfig, machine: MachineParams,
s_kv: int, mode: str, s_kv: int, mode: str,
slack_frac: float = 0.10) -> Suggestion: slack_frac: float = 0.10,
topo = TopologyConfig(cp=cp, tp=tp, pp=pp, s_kv=s_kv, mode=mode) b: int = 1,
include_attention: bool = True,
include_ffn: bool = True) -> Suggestion:
# For each triple, try both cp_placement options and keep the one
# with fewer cubes (breaks the historical "CP always across cubes"
# default when a smaller pack is possible). The pe placement is only
# valid when CP·TP fits within a single cube's PE count.
best_topo: TopologyConfig | None = None
best_placement = "cube"
_pe_per_cube_hw = TopologyConfig().pes_per_cube_hw # instance-invariant HW const
_placements_to_try = ["cube"]
if cp * tp <= _pe_per_cube_hw:
_placements_to_try.append("pe")
for _place in _placements_to_try:
_t = TopologyConfig(
cp=cp, tp=tp, pp=pp, s_kv=s_kv, mode=mode, b=b,
cp_placement=_place,
)
if best_topo is None or _t.cubes_used < best_topo.cubes_used:
best_topo = _t
best_placement = _place
topo = best_topo
cfg = FullConfig(model=model, topo=topo, machine=machine) cfg = FullConfig(model=model, topo=topo, machine=machine)
mem = compute_memory(cfg) mem = compute_memory(
cfg, include_attention=include_attention, include_ffn=include_ffn,
)
fits = (not mem.over_budget fits = (not mem.over_budget
and mem.slack_bytes >= slack_frac * mem.budget_bytes) and mem.slack_bytes >= slack_frac * mem.budget_bytes)
@@ -80,27 +105,42 @@ def _score_candidate(cp: int, tp: int, pp: int,
fits=fits, fits=fits,
pes_used=topo.total_pes, pes_used=topo.total_pes,
sips_used=topo.sips_used, sips_used=topo.sips_used,
cubes_used=topo.cubes_used,
cp_placement=best_placement,
reason=reason, reason=reason,
) )
def auto_suggest(model: ModelConfig, machine: MachineParams, def auto_suggest(model: ModelConfig, machine: MachineParams,
s_kv: int, mode: str = "decode", s_kv: int, mode: str = "decode",
slack_frac: float = 0.10) -> Suggestion: slack_frac: float = 0.10,
"""Return the smallest-PE-count (CP, TP, PP) that fits. b: int = 1,
include_attention: bool = True,
include_ffn: bool = True) -> Suggestion:
"""Return the smallest deployment (fewer cubes, then fewer PEs)
that fits.
Sort key: (cubes_used ↑, pes_used ↑, pp ↑, tp ↑, cp ↑).
Fewer cubes wins first because a cube is the physical die-level
hardware unit; PE count is the tiebreaker. Preferring fewer PP then
TP then CP keeps the earlier historical bias (avoid pipeline
bubbles / head-dim splits) among equal-cube-and-PE ties.
If no candidate fits, returns the best-effort one (highest slack, If no candidate fits, returns the best-effort one (highest slack,
even if negative) with fits=False. even if negative) with fits=False.
""" """
best_fit: Suggestion | None = None scored: list[Suggestion] = []
best_effort: Suggestion | None = None
for cp, tp, pp in _iter_candidates(model): for cp, tp, pp in _iter_candidates(model):
s = _score_candidate(cp, tp, pp, model, machine, s_kv, mode, slack_frac) scored.append(
if s.fits and best_fit is None: _score_candidate(cp, tp, pp, model, machine, s_kv, mode,
best_fit = s slack_frac, b=b,
break # candidates are pre-sorted by PE count include_attention=include_attention,
if best_effort is None or s.slack_gb > best_effort.slack_gb: include_ffn=include_ffn)
best_effort = s )
scored.sort(key=lambda s: (s.cubes_used, s.pes_used, s.pp, s.tp, s.cp))
return best_fit or best_effort # type: ignore for s in scored:
if s.fits:
return s
# No fit — return the best-effort (largest slack, closest to fitting).
return max(scored, key=lambda s: s.slack_gb)
@@ -0,0 +1,518 @@
"""Chip-level roofline math: AI, B*, L*, per-token latency curves.
Surfaces the arithmetic-intensity story from LLM-serving practice:
- **AI** = C / W (peak FLOPs per byte of HBM bandwidth).
- **B\*** = C * b / (2 * W) * sparsity — the critical batch size at
which weight-fetch time equals compute time for one decode step.
Sparsity = N_total / N_active (MoE factor; 1 for dense).
- **L\*** = 2 * N_active / (AI * kv_bytes_per_token) — the balance
context length at which KV-read time equals compute time.
- **B_knee(S_kv)** = B* / (1 - S_kv/L*) — the batch size where the
cost curve bends (weight-fetch drops below the compute+KV floor).
Diverges at S_kv = L* and no knee exists past it.
Per-token decode-step latency, per PE (dense-approx, no comm):
t(B, S_kv) = N_active * b / (W * B) <-- weight fetch, 1/B
+ 2 * N_active / C <-- compute (peak), flat
+ S_kv * kv_bpt / W <-- KV read, flat
All numbers per PE / per one forward pass. **Peak roofline — no
utilization factor.** Comm cost and TP/CP sharding are intentionally
NOT in the roofline — this is the back-of-envelope chip-vs-model view
the transcript talks about, not the full latency model that
stage_latencies.py builds. With this convention weight_s == compute_s
exactly at B*.
"""
from __future__ import annotations
from dataclasses import dataclass, replace
from .model_config import FullConfig, MachineParams, ModelConfig
# Per-token bytes of BF16 MAC arithmetic: one multiply + one add = 2 FLOPs.
_FLOPS_PER_PARAM_PER_TOKEN = 2
# ── Chip / model derived quantities ────────────────────────────────
def arithmetic_intensity(machine: MachineParams) -> float:
"""FLOPs per byte of HBM bandwidth. Peak roofline; utilization
is applied only in the compute-time formula, not here."""
return machine.peak_flops / machine.bw_hbm
def total_active_params(model: ModelConfig) -> int:
"""Full-model parameter count (attention + FFN, all layers).
Attention: 4 projections × hidden × H_q * d_head effective per layer.
(W_Q hidden×H_q*d_h, W_O H_q*d_h×hidden, W_K/W_V hidden×H_kv*d_h.)
FFN: 3 × hidden × ffn_dim per layer (gate, up, down).
"""
m = model
attn = (
m.hidden * m.h_q * m.d_head # W_Q
+ m.hidden * m.h_kv * m.d_head * 2 # W_K + W_V
+ m.h_q * m.d_head * m.hidden # W_O
)
ffn = 3 * m.hidden * m.ffn_dim
return (attn + ffn) * m.layers
def kv_bytes_per_token(model: ModelConfig) -> int:
"""Bytes of KV cache one new token adds across ALL layers, per one
sequence, un-sharded (K + V, H_kv heads * d_h * bytes)."""
m = model
return 2 * m.h_kv * m.d_head * m.bytes_per_elem * m.layers
def critical_batch(machine: MachineParams, model: ModelConfig,
sparsity: float = 1.0) -> float:
"""B* = C * b / (2 * W) * sparsity.
Sparsity = N_total / N_active (>= 1). Dense = 1. MoE 8-of-256 = 8.
"""
b = model.bytes_per_elem
ai = arithmetic_intensity(machine)
return ai * b / _FLOPS_PER_PARAM_PER_TOKEN * sparsity
def balance_context(machine: MachineParams, model: ModelConfig) -> float:
"""L* = 2 * N_active / (AI * kv_bpt).
Context length (in tokens) at which per-step KV read matches the
per-step compute cost. Beyond L*, the KV term is dominant and no
batch size gets you compute-bound.
"""
n_active = total_active_params(model)
ai = arithmetic_intensity(machine)
kv_bpt = kv_bytes_per_token(model)
return _FLOPS_PER_PARAM_PER_TOKEN * n_active / (ai * kv_bpt)
def knee_batch(machine: MachineParams, model: ModelConfig,
s_kv: int) -> float | None:
"""B_knee(S_kv) = B* / (1 - S_kv/L*).
Returns None when S_kv >= L* (no knee exists — the total-cost
curve never touches the compute floor).
"""
b_star = critical_batch(machine, model)
l_star = balance_context(machine, model)
r = s_kv / l_star
if r >= 1.0:
return None
return b_star / (1.0 - r)
# ── Per-token latency curves ───────────────────────────────────────
@dataclass
class RooflinePoint:
batch: int
weight_s: float # weight fetch time, 1/B
compute_s: float # compute time, flat
kv_s: float # KV read time, flat
total_s: float
def per_token_latency_curve(machine: MachineParams, model: ModelConfig,
batch_range: list[int],
s_kv: int) -> list[RooflinePoint]:
"""Per-token decode-step latency curve across a range of batch sizes.
Returns one point per batch. All times per PE, dense-approx,
utilization from machine.compute_util. Comm and TP/CP sharding are
excluded — this is the roofline model.
"""
n_active = total_active_params(model)
b = model.bytes_per_elem
weight_bytes = n_active * b
compute_flops = _FLOPS_PER_PARAM_PER_TOKEN * n_active
kv_read_bytes = s_kv * kv_bytes_per_token(model)
compute_s = compute_flops / machine.peak_flops
kv_s = kv_read_bytes / machine.bw_hbm
points: list[RooflinePoint] = []
for bs in batch_range:
weight_s = weight_bytes / machine.bw_hbm / max(1, bs)
points.append(RooflinePoint(
batch=bs,
weight_s=weight_s,
compute_s=compute_s,
kv_s=kv_s,
total_s=weight_s + compute_s + kv_s,
))
return points
def bound_regime(machine: MachineParams, model: ModelConfig,
batch: int, s_kv: int) -> str:
"""Which term dominates at the current (batch, S_kv) point.
Returns 'memory-bound' if weight_fetch is the largest term,
'kv-bound' if KV read is largest, 'compute-bound' if compute.
"""
pts = per_token_latency_curve(machine, model, [batch], s_kv)
p = pts[0]
parts = {"memory-bound": p.weight_s,
"kv-bound": p.kv_s,
"compute-bound": p.compute_s}
return max(parts, key=parts.get)
# ── Regime-dependent cost terms ────────────────────────────────────
def t_mem_short(machine: MachineParams, model: ModelConfig,
batch: int) -> float:
"""Per-token weight-fetch time (short-context regime term).
N_active · b / (W · B). Shrinks as B grows — this is what
batching amortizes.
"""
return (total_active_params(model) * model.bytes_per_elem
/ (machine.bw_hbm * max(1, batch)))
def t_mem_long(machine: MachineParams, model: ModelConfig,
s_kv: int) -> float:
"""Per-token KV-read time (long-context regime term).
S_kv · kv_bpt / W. Independent of B — each sequence reads its
own KV cache; batching doesn't help.
"""
return s_kv * kv_bytes_per_token(model) / machine.bw_hbm
def t_com(machine: MachineParams, model: ModelConfig) -> float:
"""Per-token compute time. Same in both regimes: 2·N/C, peak."""
return _FLOPS_PER_PARAM_PER_TOKEN * total_active_params(model) / machine.peak_flops
# ── "Good" batch / context recommendations ─────────────────────────
@dataclass
class BatchRecommendation:
target: float # Pope's rule: 2 × B*
b_star: float # B* itself
effective: float # what we recommend using
reason: str # short explanation
@dataclass
class ContextRecommendation:
l_star: float # balance context length
max_efficient: float # same as l_star (compute-friendly ceiling)
utilization_at: float # utilization at current s_kv
reason: str
def good_batch(machine: MachineParams, model: ModelConfig,
sparsity: float = 1.0) -> BatchRecommendation:
"""Recommended batch size: 2 × B* (Pope's rule of thumb).
Below B*: memory-bound, doubling B halves cost/token.
At 2×B*: 50% excess over compute floor — the sweet spot.
Beyond 3×B*: diminishing returns; latency keeps growing linearly.
"""
b_star = critical_batch(machine, model, sparsity)
target = 2 * b_star
return BatchRecommendation(
target=target, b_star=b_star, effective=target,
reason=(f"2·B* = 2 · {b_star:.0f} = {target:.0f}. "
"Below B*: memory-bound (doubling B halves cost/token). "
"Beyond 3·B*: diminishing returns."),
)
def good_context(machine: MachineParams, model: ModelConfig,
s_kv: int) -> ContextRecommendation:
"""Recommended max context: L* — the compute-friendly ceiling.
Below L*: KV read is cheap relative to compute → good utilization.
Above L*: KV bandwidth wall → utilization = 1/(1 + S_kv/L*).
"""
l_star = balance_context(machine, model)
util = utilization_at(s_kv, l_star)
return ContextRecommendation(
l_star=l_star, max_efficient=l_star, utilization_at=util,
reason=(f"L* = {l_star:,.0f} tokens. Below L*: compute-bound "
f"(good util). At {s_kv:,} tokens: peak utilization ≈ "
f"{util*100:.1f}% (1 / (1 + S_kv/L*))."),
)
def utilization_at(s_kv: int, l_star: float) -> float:
"""Peak compute utilization at context length s_kv, given L*.
util = compute / (compute + KV_read) = 1 / (1 + S_kv/L*).
At S_kv=0: 100%. At S_kv=L*: 50%. At 2·L*: 33.3%. At 5·L*: 16.7%.
"""
return 1.0 / (1.0 + s_kv / l_star)
# ── Per-step latency (undivided by B) ─────────────────────────────
@dataclass
class StepLatencyPoint:
batch: int
weight_s: float # N·b / W — flat in B (loaded once per step)
compute_s: float # 2·N·B / C — linear in B
kv_s: float # B · S_kv · kv_bpt / W — linear in B
total_s: float
def step_latency_curve(machine: MachineParams, model: ModelConfig,
batch_range: list[int],
s_kv: int) -> list[StepLatencyPoint]:
"""Total time of one decode step (one forward pass), across a
range of batch sizes. NOT divided by B — this is the SLO view.
step_weight = N·b / W (batch-invariant)
step_compute = 2·N·B / C (linear in B)
step_kv = B · S_kv · kv_bpt / W (linear in B)
Per-token cost = step_total / B — the two views trade off:
bigger B lowers cost/token but raises step latency.
"""
n_active = total_active_params(model)
b = model.bytes_per_elem
weight_bytes = n_active * b
kv_bpt = kv_bytes_per_token(model)
step_weight = weight_bytes / machine.bw_hbm
points: list[StepLatencyPoint] = []
for bs in batch_range:
B = max(1, bs)
step_compute = _FLOPS_PER_PARAM_PER_TOKEN * n_active * B / machine.peak_flops
step_kv = B * s_kv * kv_bpt / machine.bw_hbm
total = step_weight + step_compute + step_kv
points.append(StepLatencyPoint(
batch=bs,
weight_s=step_weight,
compute_s=step_compute,
kv_s=step_kv,
total_s=total,
))
return points
# ── PE memory budget curves ───────────────────────────────────────
@dataclass
class MemoryBudgetPoint:
axis_val: int # S_kv or B being swept
weights_gb: float
kv_gb: float
transient_gb: float
used_gb: float
free_gb: float # max(0, hbm_gb - used_gb)
over_budget: bool
def _budget_point(weights_bytes: int, kv_bytes: int, transient_bytes: int,
hbm_bytes: int, axis_val: int) -> MemoryBudgetPoint:
used = weights_bytes + kv_bytes + transient_bytes
return MemoryBudgetPoint(
axis_val=axis_val,
weights_gb=weights_bytes / 1e9,
kv_gb=kv_bytes / 1e9,
transient_gb=transient_bytes / 1e9,
used_gb=used / 1e9,
free_gb=max(0, hbm_bytes - used) / 1e9,
over_budget=(used > hbm_bytes),
)
def memory_budget_curve_vs_skv(cfg: FullConfig,
s_kv_range: list[int],
batch: int) -> list[MemoryBudgetPoint]:
"""Per-PE memory as S_kv sweeps. Uses cfg's current sharding
(CP, TP, PP). Sets topo.b = batch. Returns one point per S_kv."""
from .memory_layout import (
per_pe_kv_cache_bytes, per_pe_transient_bytes, per_pe_weight_bytes,
)
hbm_bytes = int(cfg.machine.pe_budget_bytes)
weights_bytes = per_pe_weight_bytes(cfg)
transient_bytes = per_pe_transient_bytes(cfg)
points: list[MemoryBudgetPoint] = []
for skv in s_kv_range:
swept_topo = replace(cfg.topo, s_kv=int(skv), b=max(1, int(batch)))
swept_cfg = FullConfig(model=cfg.model, topo=swept_topo,
machine=cfg.machine)
kv_bytes = per_pe_kv_cache_bytes(swept_cfg)
points.append(_budget_point(weights_bytes, kv_bytes,
transient_bytes, hbm_bytes, int(skv)))
return points
def memory_budget_curve_vs_batch(cfg: FullConfig,
b_range: list[int],
s_kv: int) -> list[MemoryBudgetPoint]:
"""Per-PE memory as B sweeps. Sets topo.s_kv = s_kv."""
from .memory_layout import (
per_pe_kv_cache_bytes, per_pe_transient_bytes, per_pe_weight_bytes,
)
hbm_bytes = int(cfg.machine.pe_budget_bytes)
weights_bytes = per_pe_weight_bytes(cfg)
transient_bytes = per_pe_transient_bytes(cfg)
points: list[MemoryBudgetPoint] = []
for bs in b_range:
swept_topo = replace(cfg.topo, b=max(1, int(bs)), s_kv=int(s_kv))
swept_cfg = FullConfig(model=cfg.model, topo=swept_topo,
machine=cfg.machine)
kv_bytes = per_pe_kv_cache_bytes(swept_cfg)
points.append(_budget_point(weights_bytes, kv_bytes,
transient_bytes, hbm_bytes, int(bs)))
return points
# ── AI / B* sensitivity to hardware knobs ──────────────────────────
@dataclass
class AISensitivityPoint:
multiplier: float # scale factor applied to the base machine
peak_tflops: float
bw_gbs: float
ai: float # C / W
b_star: float # C·b/(2·W)
# ── GPU count sizing (three-axis) ─────────────────────────────────
@dataclass
class GpuSizingResult:
# Inputs echoed back for display / debugging
n_users: int
avg_ctx_tokens: int
tpot_slo_s: float
# Per-replica values
pes_axis_a_capacity: int # PEs to hold weights alone (bare floor)
pes_axis_b_kv: int # PEs to hold weights + KV of this replica's users
pes_per_replica: int # max(A, B)
# Throughput axis
b_at_slo: int # largest per-replica B satisfying SLO
users_per_replica: int # min(n_users, b_at_slo), at least 1
n_replicas: int # ceil(n_users / users_per_replica)
# Grand total
total_pes: int
binding_axis: str # 'capacity' | 'kv' | 'throughput'
def max_batch_within_slo(machine: MachineParams, model: ModelConfig,
s_kv: int, tpot_slo_s: float) -> int:
"""Largest per-replica B such that decode step latency ≤ SLO.
step_latency(B) = N·b/W + 2·N·B/C + B·S_kv·kv_bpt/W
= weight_fetch + B · (compute + kv_read)
Returns 0 if even B=1 exceeds SLO (weight fetch alone too large,
or per-sequence term already blows the budget).
"""
n = total_active_params(model)
b_elem = model.bytes_per_elem
weight_time = n * b_elem / machine.bw_hbm
per_seq_time = (_FLOPS_PER_PARAM_PER_TOKEN * n / machine.peak_flops
+ s_kv * kv_bytes_per_token(model) / machine.bw_hbm)
if weight_time + per_seq_time > tpot_slo_s:
return 0
remaining = tpot_slo_s - weight_time
b_max = int(remaining / per_seq_time)
return max(1, b_max)
def size_deployment(machine: MachineParams, model: ModelConfig,
n_users: int, avg_ctx: int,
tpot_slo_s: float) -> GpuSizingResult:
"""Three-axis GPU count for a target workload.
Axis A — capacity floor: PEs to hold one replica's weights.
Axis B — KV headroom: PEs to hold weights + all KV of the
users assigned to this replica.
Axis C — throughput SLO: replicas needed so per-replica B ≤ b_at_slo.
Result: pes_per_replica = max(A, B); total = pes_per_replica × replicas.
Binding axis is whichever grew the count the most.
"""
n = total_active_params(model)
b_elem = model.bytes_per_elem
weight_bytes = n * b_elem
hbm_pe = int(machine.pe_hbm_gb * 1e9)
kv_bpt = kv_bytes_per_token(model)
# Axis C: throughput
b_at_slo = max_batch_within_slo(machine, model, avg_ctx, tpot_slo_s)
users_per_replica = max(1, min(n_users, b_at_slo)) if b_at_slo > 0 else 1
n_replicas = (n_users + users_per_replica - 1) // users_per_replica
# Axis A: bare weights
pes_a = (weight_bytes + hbm_pe - 1) // hbm_pe
# Axis B: weights + KV load for this replica's users
kv_per_replica = users_per_replica * avg_ctx * kv_bpt
pes_b = (weight_bytes + kv_per_replica + hbm_pe - 1) // hbm_pe
pes_per_replica = max(pes_a, pes_b)
total = pes_per_replica * n_replicas
if n_replicas > 1:
binding = "throughput"
elif pes_b > pes_a:
binding = "kv"
else:
binding = "capacity"
return GpuSizingResult(
n_users=n_users,
avg_ctx_tokens=avg_ctx,
tpot_slo_s=tpot_slo_s,
pes_axis_a_capacity=int(pes_a),
pes_axis_b_kv=int(pes_b),
pes_per_replica=int(pes_per_replica),
b_at_slo=b_at_slo,
users_per_replica=int(users_per_replica),
n_replicas=int(n_replicas),
total_pes=int(total),
binding_axis=binding,
)
def ai_sensitivity_curve(machine: MachineParams, model: ModelConfig,
multipliers: list[float],
axis: str = "flops") -> list[AISensitivityPoint]:
"""Sweep FLOPs OR BW while holding the other fixed. Returns AI + B*
at each multiplier.
axis='flops' → scales peak_tflops_f16 (AI grows linearly).
axis='bw' → scales bw_hbm_gbs (AI shrinks inversely).
"""
if axis not in ("flops", "bw"):
raise ValueError(f"axis must be 'flops' or 'bw', got {axis!r}")
base_flops = machine.peak_tflops_f16
base_bw = machine.bw_hbm_gbs
points: list[AISensitivityPoint] = []
for k in multipliers:
if axis == "flops":
m = replace(machine, peak_tflops_f16=base_flops * k)
else:
m = replace(machine, bw_hbm_gbs=base_bw * k)
points.append(AISensitivityPoint(
multiplier=k,
peak_tflops=m.peak_tflops_f16,
bw_gbs=m.bw_hbm_gbs,
ai=arithmetic_intensity(m),
b_star=critical_batch(m, model),
))
return points
@@ -30,12 +30,19 @@ class MemoryBreakdown:
return self.used_bytes > self.budget_bytes return self.used_bytes > self.budget_bytes
def per_pe_weight_bytes(cfg: FullConfig) -> int: def per_pe_weight_bytes(cfg: FullConfig,
include_attention: bool = True,
include_ffn: bool = True) -> int:
"""Attention + FFN weights per PE, bf16. Divided by TP, PP, and EP. """Attention + FFN weights per PE, bf16. Divided by TP, PP, and EP.
EP divides the FFN (experts) across ranks; attention weights are EP divides the FFN (experts) across ranks; attention weights are
unaffected. When kv_shard_mode='replicate' and TP > H_kv, each KV unaffected. When kv_shard_mode='replicate' and TP > H_kv, each KV
head is replicated (per-PE W_K/W_V size doesn't drop below one head). head is replicated (per-PE W_K/W_V size doesn't drop below one head).
Scope flags let callers count only attention or only FFN weights —
useful for "smallest deployment for just this block" sizing. Both
True (default) matches the traditional full-model per-PE weight
footprint.
""" """
m = cfg.model m = cfg.model
tp = cfg.topo.tp tp = cfg.topo.tp
@@ -50,18 +57,23 @@ def per_pe_weight_bytes(cfg: FullConfig) -> int:
# Head-dim split: fractional head allowed (< 1 head bytes when TP > H_kv). # Head-dim split: fractional head allowed (< 1 head bytes when TP > H_kv).
hkv_per_pe_bytes = m.h_kv / tp hkv_per_pe_bytes = m.h_kv / tp
per_layer_attn = 0
if include_attention:
per_layer_attn = ( per_layer_attn = (
m.hidden * hq_per_pe * m.d_head + # W_Q m.hidden * hq_per_pe * m.d_head + # W_Q
m.hidden * hkv_per_pe_bytes * m.d_head + # W_K m.hidden * hkv_per_pe_bytes * m.d_head + # W_K
m.hidden * hkv_per_pe_bytes * m.d_head + # W_V m.hidden * hkv_per_pe_bytes * m.d_head + # W_V
hq_per_pe * m.d_head * m.hidden # W_O hq_per_pe * m.d_head * m.hidden # W_O
) )
per_layer_ffn = 0
if include_ffn:
# FFN divisor: TP (default) or TP*CP or TP*CP*DP if user opts in. # FFN divisor: TP (default) or TP*CP or TP*CP*DP if user opts in.
# EP further divides (MoE experts). # EP further divides (MoE experts).
ffn_div = cfg.ffn_shard_divisor * ep ffn_div = cfg.ffn_shard_divisor * ep
per_layer_ffn = 3 * m.hidden * (m.ffn_dim // ffn_div) per_layer_ffn = 3 * m.hidden * (m.ffn_dim // ffn_div)
per_layer = (per_layer_attn + per_layer_ffn) * m.bytes_per_elem
per_layer = (per_layer_attn + per_layer_ffn) * m.bytes_per_elem
layers_per_stage = (m.layers + pp - 1) // pp layers_per_stage = (m.layers + pp - 1) // pp
return int(per_layer * layers_per_stage) return int(per_layer * layers_per_stage)
@@ -74,17 +86,19 @@ def per_pe_kv_cache_bytes(cfg: FullConfig) -> int:
TP > H_kv, each KV head is duplicated across TP/H_kv ranks so TP > H_kv, each KV head is duplicated across TP/H_kv ranks so
per-PE storage doesn't shrink below 1 head. per-PE storage doesn't shrink below 1 head.
- PP: only layers_per_stage layers stored per PE. - PP: only layers_per_stage layers stored per PE.
- Batch (B): each concurrent request keeps its own KV cache slice.
""" """
m = cfg.model m = cfg.model
pp = cfg.topo.pp pp = cfg.topo.pp
tp = cfg.topo.tp tp = cfg.topo.tp
B = max(1, cfg.topo.b)
if cfg.topo.kv_shard_mode == "replicate": if cfg.topo.kv_shard_mode == "replicate":
hkv_per_pe_bytes = max(1.0, m.h_kv / tp) hkv_per_pe_bytes = max(1.0, m.h_kv / tp)
else: else:
hkv_per_pe_bytes = m.h_kv / tp hkv_per_pe_bytes = m.h_kv / tp
layers_per_stage = (m.layers + pp - 1) // pp layers_per_stage = (m.layers + pp - 1) // pp
per_layer = 2 * cfg.topo.s_local * hkv_per_pe_bytes * m.d_head * m.bytes_per_elem per_layer = 2 * cfg.topo.s_local * hkv_per_pe_bytes * m.d_head * m.bytes_per_elem
return int(per_layer * layers_per_stage) return int(per_layer * layers_per_stage * B)
def per_pe_transient_bytes(cfg: FullConfig) -> int: def per_pe_transient_bytes(cfg: FullConfig) -> int:
@@ -101,10 +115,19 @@ def per_pe_transient_bytes(cfg: FullConfig) -> int:
return int(2 * tile_score + T_q * m.hidden * m.bytes_per_elem) return int(2 * tile_score + T_q * m.hidden * m.bytes_per_elem)
def compute_memory(cfg: FullConfig) -> MemoryBreakdown: def compute_memory(cfg: FullConfig,
include_attention: bool = True,
include_ffn: bool = True) -> MemoryBreakdown:
"""Per-PE memory breakdown. Scope flags let callers count only the
attention or only the FFN block (KV cache goes with the attention
block; transient activation buffer is small either way and left in).
"""
kv_bytes = per_pe_kv_cache_bytes(cfg) if include_attention else 0
return MemoryBreakdown( return MemoryBreakdown(
weights_bytes=per_pe_weight_bytes(cfg), weights_bytes=per_pe_weight_bytes(
kv_cache_bytes=per_pe_kv_cache_bytes(cfg), cfg, include_attention=include_attention, include_ffn=include_ffn,
),
kv_cache_bytes=kv_bytes,
transient_bytes=per_pe_transient_bytes(cfg), transient_bytes=per_pe_transient_bytes(cfg),
budget_bytes=cfg.machine.pe_budget_bytes, budget_bytes=cfg.machine.pe_budget_bytes,
) )
@@ -43,6 +43,7 @@ class TopologyConfig:
pp: int = 1 # pipeline stages (layer shard) pp: int = 1 # pipeline stages (layer shard)
dp: int = 1 # data-parallel replicas dp: int = 1 # data-parallel replicas
ep: int = 1 # expert-parallel degree (MoE only) ep: int = 1 # expert-parallel degree (MoE only)
b: int = 1 # batch size (concurrent requests per PP stage)
s_kv: int = 4096 s_kv: int = 4096
mode: str = "decode" # "decode" or "prefill" mode: str = "decode" # "decode" or "prefill"
kv_shard_mode: str = "split" # "split" or "replicate" (used when TP > H_kv) kv_shard_mode: str = "split" # "split" or "replicate" (used when TP > H_kv)
@@ -114,6 +114,14 @@ def draw_pe_layout(cfg: FullConfig, ax=None):
cube_h = 6.0 cube_h = 6.0
cube_gap = 0.6 cube_gap = 0.6
# When TP fills the cube (TP>=8), cp_placement is "cube" and CP
# ranks span cubes — the intra-cube CP-color branch below is
# skipped. Surface which CP rank this figure is showing so users
# aren't guessing which of `CP` groups is drawn.
_cube_place_tag = ""
if cfg.topo.cp_placement == "cube" and cfg.topo.cp > 1:
_cube_place_tag = f" [CP rank 0 of {cfg.topo.cp}]"
for cube_idx in range(n_cubes): for cube_idx in range(n_cubes):
x0 = cube_idx * (cube_w + cube_gap) x0 = cube_idx * (cube_w + cube_gap)
y0 = 0 y0 = 0
@@ -125,7 +133,8 @@ def draw_pe_layout(cfg: FullConfig, ax=None):
) )
ax.add_patch(rect) ax.add_patch(rect)
ax.text(x0 + cube_w / 2, y0 + cube_h + 0.1, ax.text(x0 + cube_w / 2, y0 + cube_h + 0.1,
f"Cube {cube_idx} (PEs {cube_idx*8}-{cube_idx*8+7})", f"Cube {cube_idx} (PEs {cube_idx*8}-{cube_idx*8+7})"
+ _cube_place_tag,
ha="center", va="bottom", fontsize=11, fontweight="bold") ha="center", va="bottom", fontsize=11, fontweight="bold")
pe_pad_x = 0.15 pe_pad_x = 0.15
@@ -136,6 +145,26 @@ def draw_pe_layout(cfg: FullConfig, ax=None):
pe_w = (inner_w - (PE_COLS - 1) * pe_gap) / PE_COLS pe_w = (inner_w - (PE_COLS - 1) * pe_gap) / PE_COLS
pe_h = (inner_h - (PE_ROWS - 1) * pe_gap) / PE_ROWS pe_h = (inner_h - (PE_ROWS - 1) * pe_gap) / PE_ROWS
# When cp_placement=pe, CP ranks are packed intra-cube along with
# TP ranks. Color-code by CP rank so it's obvious which PEs belong
# to which sequence-shard group. Palette wraps beyond 8 CP ranks.
_cp_place = cfg.topo.cp_placement
_cp_val = cfg.topo.cp
_cp_palette = [
"#f0f7ff", # baseline blue (also the fallback for cp_placement=cube)
"#e8f5e9", # light green
"#fff3e0", # light orange
"#f3e5f5", # light purple
"#ffebee", # light red
"#e0f7fa", # light cyan
"#fce4ec", # light pink
"#f9fbe7", # light lime
]
_cp_edge_palette = [
"#3a86ff", "#2e7d32", "#ef6c00", "#7b1fa2",
"#c62828", "#0097a7", "#c2185b", "#9e9d24",
]
for pr in range(PE_ROWS): for pr in range(PE_ROWS):
for pc in range(PE_COLS): for pc in range(PE_COLS):
pe_local = pr * PE_COLS + pc pe_local = pr * PE_COLS + pc
@@ -145,17 +174,43 @@ def draw_pe_layout(cfg: FullConfig, ax=None):
px = x0 + pe_pad_x + pc * (pe_w + pe_gap) px = x0 + pe_pad_x + pc * (pe_w + pe_gap)
py = y0 + pe_pad_y + (PE_ROWS - 1 - pr) * (pe_h + pe_gap) py = y0 + pe_pad_y + (PE_ROWS - 1 - pr) * (pe_h + pe_gap)
# Which (cp_rank, tp_rank) does this PE hold?
# When cp_placement=pe: cp_rank = pe_id // tp, tp_rank = pe_id % tp.
# When cp_placement=cube: every PE in this cube is one CP rank
# (drawn on its own tile), tp_rank = pe_id % tp.
if _cp_place == "pe" and _cp_val > 1:
_cp_rank = pe_id_in_group // tp
_tp_rank = pe_id_in_group % tp
_fc = _cp_palette[_cp_rank % len(_cp_palette)]
_ec = _cp_edge_palette[_cp_rank % len(_cp_edge_palette)]
_cp_label = f" | CP={_cp_rank}"
else:
_cp_rank = None
_tp_rank = pe_id_in_group % tp
_fc = "#f0f7ff"
_ec = "#3a86ff"
# cp_placement=="cube" with CP>1: the whole cube is
# one CP rank (rank 0 by convention — see cube title
# tag above). Add the locator so users know what
# rank this per-PE data belongs to.
if _cp_place == "cube" and _cp_val > 1:
_cp_label = " | CP=0"
else:
_cp_label = ""
pe_rect = patches.Rectangle( pe_rect = patches.Rectangle(
(px, py), pe_w, pe_h, (px, py), pe_w, pe_h,
facecolor="#f0f7ff", edgecolor="#3a86ff", linewidth=1.0, facecolor=_fc, edgecolor=_ec, linewidth=1.2,
) )
ax.add_patch(pe_rect) ax.add_patch(pe_rect)
q_heads = _q_heads_for_pe(pe_id_in_group, tp, cfg.model.h_q) # Head assignment uses TP rank (not raw pe_id) so that under
# cp_placement=pe, all CP ranks share the same head split.
q_heads = _q_heads_for_pe(_tp_rank, tp, cfg.model.h_q)
kv_heads, kv_note = _kv_heads_for_pe( kv_heads, kv_note = _kv_heads_for_pe(
pe_id_in_group, tp, cfg.model.h_kv, cfg.topo.kv_shard_mode, _tp_rank, tp, cfg.model.h_kv, cfg.topo.kv_shard_mode,
) )
bytes_ = _per_pe_bytes(cfg, pe_id_in_group) bytes_ = _per_pe_bytes(cfg, _tp_rank)
weights_gb = sum(v for k, v in bytes_.items() weights_gb = sum(v for k, v in bytes_.items()
if k not in ("KV cache", "Transient")) / 1e9 if k not in ("KV cache", "Transient")) / 1e9
kv_gb = bytes_["KV cache"] / 1e9 kv_gb = bytes_["KV cache"] / 1e9
@@ -172,7 +227,7 @@ def draw_pe_layout(cfg: FullConfig, ax=None):
ffn_mb = (bytes_["W_gate"] + bytes_["W_up"] ffn_mb = (bytes_["W_gate"] + bytes_["W_up"]
+ bytes_["W_down"]) / 1e6 + bytes_["W_down"]) / 1e6
# Header (PE id and heads) bigger, then per-tensor breakdown # Header (PE id and heads) bigger, then per-tensor breakdown
header = (f"PE {pe_id_in_group}\n" header = (f"PE {pe_id_in_group} TP={_tp_rank}{_cp_label}\n"
f"Q heads: {q_str}\n" f"Q heads: {q_str}\n"
f"KV head: {kv_str}") f"KV head: {kv_str}")
ax.text(px + 0.08, py + pe_h - 0.05, header, ax.text(px + 0.08, py + pe_h - 0.05, header,
@@ -211,9 +266,12 @@ def draw_pe_layout(cfg: FullConfig, ax=None):
ax.set_aspect("equal") ax.set_aspect("equal")
ax.axis("off") ax.axis("off")
_title_cp = f", CP={cfg.topo.cp}"
if cfg.topo.cp_placement == "cube" and cfg.topo.cp > 1:
_title_cp += " (showing 1 of CP groups; others identical)"
title = ( title = (
f"Per-PE layout of one CP group " f"Per-PE layout of one CP group "
f"(TP={tp}, H_q={cfg.model.h_q}, H_kv={cfg.model.h_kv})" f"(TP={tp}{_title_cp}, H_q={cfg.model.h_q}, H_kv={cfg.model.h_kv})"
f" | KV mode: {cfg.topo.kv_shard_mode}" f" | KV mode: {cfg.topo.kv_shard_mode}"
) )
ax.set_title(title, fontsize=11, fontweight="bold") ax.set_title(title, fontsize=11, fontweight="bold")
+144 -81
View File
@@ -50,19 +50,22 @@ def stage_rmsnorm(cfg: FullConfig) -> StageCost:
T_q = cfg.topo.T_q T_q = cfg.topo.T_q
d = cfg.model.hidden d = cfg.model.hidden
b = cfg.model.bytes_per_elem b = cfg.model.bytes_per_elem
bytes_ = T_q * d * b * 2 # load x + load weight B = max(1, cfg.topo.b)
flops = 4 * T_q * d # Activation memory (x) scales with B; weight (once) is fixed.
bytes_ = B * T_q * d * b + T_q * d * b
flops = 4 * B * T_q * d
mem_s = bytes_ / cfg.machine.bw_hbm mem_s = bytes_ / cfg.machine.bw_hbm
cmp_s = flops / (cfg.machine.peak_flops * cfg.machine.compute_util) cmp_s = flops / (cfg.machine.peak_flops * cfg.machine.compute_util)
vis, bnd = _visible(cmp_s, mem_s, 0) vis, bnd = _visible(cmp_s, mem_s, 0)
return StageCost( return StageCost(
name="S1 RMSNorm", name="S1 RMSNorm",
formula=f"bytes = {T_q}*{d}*{b}*2 = {bytes_} B / BW_HBM", formula=f"bytes = B*T_q*d*b + T_q*d*b = {bytes_} B / BW_HBM",
compute_s=cmp_s, memory_s=mem_s, comm_s=0.0, compute_s=cmp_s, memory_s=mem_s, comm_s=0.0,
bound=bnd, visible_s=vis, bound=bnd, visible_s=vis,
flops=flops, mem_bytes=bytes_, flops=flops, mem_bytes=bytes_,
flops_formula=f"4*T_q*d = 4*{T_q}*{d} = {flops}", flops_formula=f"4*B*T_q*d = 4*{B}*{T_q}*{d} = {flops}",
mem_formula=f"2*T_q*d*b = 2*{T_q}*{d}*{b} = {bytes_} B", mem_formula=f"B*T_q*d*b + T_q*d*b (weight) "
f"= {B}*{T_q}*{d}*{b} + {T_q*d*b} = {bytes_} B",
) )
@@ -76,21 +79,26 @@ def stage_wq(cfg: FullConfig) -> StageCost:
T_q = cfg.topo.T_q T_q = cfg.topo.T_q
d = cfg.model.hidden d = cfg.model.hidden
b = cfg.model.bytes_per_elem b = cfg.model.bytes_per_elem
B = max(1, cfg.topo.b)
hq_per_pe = cfg.h_q_per_pe hq_per_pe = cfg.h_q_per_pe
dh = cfg.model.d_head dh = cfg.model.d_head
flops = 2 * T_q * d * (hq_per_pe * dh) # FLOPs scale with batch; weight bytes fixed (shared across batch).
flops = 2 * B * T_q * d * (hq_per_pe * dh)
weight_B = d * (hq_per_pe * dh) * b weight_B = d * (hq_per_pe * dh) * b
cmp_s, mem_s = _gemm_time(flops, weight_B, cfg) cmp_s, mem_s = _gemm_time(flops, weight_B, cfg)
vis, bnd = _visible(cmp_s, mem_s, 0) vis, bnd = _visible(cmp_s, mem_s, 0)
return StageCost( return StageCost(
name="S2 W_Q GEMM", name="S2 W_Q GEMM",
formula=f"FLOPs = 2*{T_q}*{d}*{hq_per_pe*dh} = {flops:.2g}; " formula=f"FLOPs = 2*B*T_q*d*(H_q/TP*d_h) "
f"weight = {weight_B/1e6:.1f} MB", f"= 2*{B}*{T_q}*{d}*{hq_per_pe*dh} = {flops:.2g}; "
f"weight = {weight_B/1e6:.1f} MB (shared across batch)",
compute_s=cmp_s, memory_s=mem_s, comm_s=0.0, compute_s=cmp_s, memory_s=mem_s, comm_s=0.0,
bound=bnd, visible_s=vis, bound=bnd, visible_s=vis,
flops=int(flops), mem_bytes=int(weight_B), flops=int(flops), mem_bytes=int(weight_B),
flops_formula=f"2*T_q*d*(H_q/TP*d_h) = 2*{T_q}*{d}*{hq_per_pe*dh} = {flops:.3g}", flops_formula=f"2*B*T_q*d*(H_q/TP*d_h) "
mem_formula=f"d*(H_q/TP*d_h)*b = {d}*{hq_per_pe*dh}*{b} = {weight_B/1e6:.2f} MB", f"= 2*{B}*{T_q}*{d}*{hq_per_pe*dh} = {flops:.3g}",
mem_formula=f"d*(H_q/TP*d_h)*b (weight, B-invariant) "
f"= {d}*{hq_per_pe*dh}*{b} = {weight_B/1e6:.2f} MB",
) )
@@ -98,9 +106,10 @@ def stage_wkv(cfg: FullConfig) -> StageCost:
T_q = cfg.topo.T_q T_q = cfg.topo.T_q
d = cfg.model.hidden d = cfg.model.hidden
b = cfg.model.bytes_per_elem b = cfg.model.bytes_per_elem
B = max(1, cfg.topo.b)
hkv_per_pe = max(1, cfg.model.h_kv // cfg.topo.tp) hkv_per_pe = max(1, cfg.model.h_kv // cfg.topo.tp)
dh = cfg.model.d_head dh = cfg.model.d_head
flops_one = 2 * T_q * d * (hkv_per_pe * dh) flops_one = 2 * B * T_q * d * (hkv_per_pe * dh)
weight_B_one = d * (hkv_per_pe * dh) * b weight_B_one = d * (hkv_per_pe * dh) * b
flops = 2 * flops_one flops = 2 * flops_one
weight_B = 2 * weight_B_one weight_B = 2 * weight_B_one
@@ -108,30 +117,36 @@ def stage_wkv(cfg: FullConfig) -> StageCost:
vis, bnd = _visible(cmp_s, mem_s, 0) vis, bnd = _visible(cmp_s, mem_s, 0)
return StageCost( return StageCost(
name="S3 W_K + W_V GEMM", name="S3 W_K + W_V GEMM",
formula=f"FLOPs per proj = 2*{T_q}*{d}*{hkv_per_pe*dh} = {flops_one:.2g}; x2", formula=f"FLOPs per proj = 2*B*T_q*d*(H_kv/TP*d_h) "
f"= 2*{B}*{T_q}*{d}*{hkv_per_pe*dh} = {flops_one:.2g}; x2",
compute_s=cmp_s, memory_s=mem_s, comm_s=0.0, compute_s=cmp_s, memory_s=mem_s, comm_s=0.0,
bound=bnd, visible_s=vis, bound=bnd, visible_s=vis,
flops=int(flops), mem_bytes=int(weight_B), flops=int(flops), mem_bytes=int(weight_B),
flops_formula=f"2*(2*T_q*d*(H_kv/TP*d_h)) = 2*(2*{T_q}*{d}*{hkv_per_pe*dh}) = {flops:.3g}", flops_formula=f"2*(2*B*T_q*d*(H_kv/TP*d_h)) "
mem_formula=f"2*(d*(H_kv/TP*d_h)*b) = 2*({d}*{hkv_per_pe*dh}*{b}) = {weight_B/1e6:.2f} MB", f"= 2*(2*{B}*{T_q}*{d}*{hkv_per_pe*dh}) = {flops:.3g}",
mem_formula=f"2*(d*(H_kv/TP*d_h)*b) (weights, B-invariant) "
f"= 2*({d}*{hkv_per_pe*dh}*{b}) = {weight_B/1e6:.2f} MB",
) )
def stage_kv_append(cfg: FullConfig) -> StageCost: def stage_kv_append(cfg: FullConfig) -> StageCost:
T_q = cfg.topo.T_q T_q = cfg.topo.T_q
b = cfg.model.bytes_per_elem b = cfg.model.bytes_per_elem
B = max(1, cfg.topo.b)
hkv_per_pe = max(1, cfg.model.h_kv // cfg.topo.tp) hkv_per_pe = max(1, cfg.model.h_kv // cfg.topo.tp)
dh = cfg.model.d_head dh = cfg.model.d_head
bytes_ = 2 * T_q * hkv_per_pe * dh * b bytes_ = 2 * B * T_q * hkv_per_pe * dh * b
mem_s = bytes_ / cfg.machine.bw_hbm mem_s = bytes_ / cfg.machine.bw_hbm
return StageCost( return StageCost(
name="S4 KV cache append", name="S4 KV cache append",
formula=f"bytes = 2*{T_q}*{hkv_per_pe}*{dh}*{b} = {bytes_} B", formula=f"bytes = 2*B*T_q*(H_kv/TP)*d_h*b "
f"= 2*{B}*{T_q}*{hkv_per_pe}*{dh}*{b} = {bytes_} B",
compute_s=0, memory_s=mem_s, comm_s=0, compute_s=0, memory_s=mem_s, comm_s=0,
bound="memory", visible_s=mem_s, bound="memory", visible_s=mem_s,
flops=0, mem_bytes=bytes_, flops=0, mem_bytes=bytes_,
flops_formula="0 (no matmul, just cache write)", flops_formula="0 (no matmul, just cache write)",
mem_formula=f"2*T_q*(H_kv/TP)*d_h*b = 2*{T_q}*{hkv_per_pe}*{dh}*{b} = {bytes_} B", mem_formula=f"2*B*T_q*(H_kv/TP)*d_h*b "
f"= 2*{B}*{T_q}*{hkv_per_pe}*{dh}*{b} = {bytes_} B",
) )
@@ -141,7 +156,8 @@ def _per_hop_qkT_pv(cfg: FullConfig) -> tuple[float, str]:
S_local = cfg.topo.s_local S_local = cfg.topo.s_local
dh = cfg.model.d_head dh = cfg.model.d_head
hq_per_pe = cfg.h_q_per_pe hq_per_pe = cfg.h_q_per_pe
flops = 2 * T_q * S_local * dh * hq_per_pe B = max(1, cfg.topo.b)
flops = 2 * B * T_q * S_local * dh * hq_per_pe
cmp_s = flops / (cfg.machine.peak_flops * cfg.machine.compute_util) cmp_s = flops / (cfg.machine.peak_flops * cfg.machine.compute_util)
formula = f"2*{T_q}*{S_local}*{dh}*{hq_per_pe} = {flops:.2g} FLOPs/hop" formula = f"2*{T_q}*{S_local}*{dh}*{hq_per_pe} = {flops:.2g} FLOPs/hop"
return cmp_s, formula return cmp_s, formula
@@ -165,19 +181,23 @@ def stage_qkT(cfg: FullConfig) -> StageCost:
S_local = cfg.topo.s_local S_local = cfg.topo.s_local
dh = cfg.model.d_head dh = cfg.model.d_head
hq_per_pe = cfg.h_q_per_pe hq_per_pe = cfg.h_q_per_pe
flops_per_hop = 2 * T_q * S_local * dh * hq_per_pe B = max(1, cfg.topo.b)
flops_per_hop = 2 * B * T_q * S_local * dh * hq_per_pe
total_flops = flops_per_hop * passes total_flops = flops_per_hop * passes
_hop_word = "pass" if passes == 1 else "hops" _hop_word = "pass" if passes == 1 else "hops"
return StageCost( return StageCost(
name=f"S5 Q.K^T (x{passes} {_hop_word})", name=f"S5 Q.K^T (x{passes} {_hop_word})",
formula=f"2*{T_q}*{S_local}*{dh}*{hq_per_pe} = {flops_per_hop:.2g} FLOPs/hop", formula=f"2*B*T_q*S_local*d_h*(H_q/TP) = "
f"2*{B}*{T_q}*{S_local}*{dh}*{hq_per_pe} = "
f"{flops_per_hop:.2g} FLOPs/hop",
compute_s=total, memory_s=0, comm_s=0, compute_s=total, memory_s=0, comm_s=0,
bound="compute", visible_s=total, bound="compute", visible_s=total,
hop_multiplier=passes, hop_multiplier=passes,
flops=int(total_flops), mem_bytes=0, flops=int(total_flops), mem_bytes=0,
flops_formula=( flops_formula=(
f"{passes}*(2*T_q*S_local*d_h*(H_q/TP)) = " f"{passes}*(2*B*T_q*S_local*d_h*(H_q/TP)) = "
f"{passes}*(2*{T_q}*{S_local}*{dh}*{hq_per_pe}) = {total_flops:.3g}" f"{passes}*(2*{B}*{T_q}*{S_local}*{dh}*{hq_per_pe}) = "
f"{total_flops:.3g}"
), ),
mem_formula="0 (scores accumulated on-chip)", mem_formula="0 (scores accumulated on-chip)",
) )
@@ -188,7 +208,8 @@ def stage_softmax(cfg: FullConfig) -> StageCost:
S_local = cfg.topo.s_local S_local = cfg.topo.s_local
hq_per_pe = cfg.h_q_per_pe hq_per_pe = cfg.h_q_per_pe
b = cfg.model.bytes_per_elem b = cfg.model.bytes_per_elem
elems = hq_per_pe * T_q * S_local B = max(1, cfg.topo.b)
elems = B * hq_per_pe * T_q * S_local
bytes_ = elems * b * 2 bytes_ = elems * b * 2
mem_s_per_hop = bytes_ / cfg.machine.bw_hbm mem_s_per_hop = bytes_ / cfg.machine.bw_hbm
passes = _cp_compute_passes(cfg) passes = _cp_compute_passes(cfg)
@@ -197,15 +218,16 @@ def stage_softmax(cfg: FullConfig) -> StageCost:
_hop_word = "pass" if passes == 1 else "hops" _hop_word = "pass" if passes == 1 else "hops"
return StageCost( return StageCost(
name=f"S6 softmax (x{passes} {_hop_word})", name=f"S6 softmax (x{passes} {_hop_word})",
formula=f"elts/hop = {hq_per_pe}*{T_q}*{S_local} = {elems:.2g}", formula=f"elts/hop = B*(H_q/TP)*T_q*S_local "
f"= {B}*{hq_per_pe}*{T_q}*{S_local} = {elems:.2g}",
compute_s=0, memory_s=total, comm_s=0, compute_s=0, memory_s=total, comm_s=0,
bound="memory", visible_s=total, bound="memory", visible_s=total,
hop_multiplier=passes, hop_multiplier=passes,
flops=0, mem_bytes=int(total_bytes), flops=0, mem_bytes=int(total_bytes),
flops_formula="~O(elts) (negligible)", flops_formula="~O(elts) (negligible)",
mem_formula=( mem_formula=(
f"{passes}*2*b*(H_q/TP)*T_q*S_local = " f"{passes}*2*b*B*(H_q/TP)*T_q*S_local = "
f"{passes}*2*{b}*{hq_per_pe}*{T_q}*{S_local} = " f"{passes}*2*{b}*{B}*{hq_per_pe}*{T_q}*{S_local} = "
f"{total_bytes/1e6:.2f} MB" f"{total_bytes/1e6:.2f} MB"
), ),
) )
@@ -219,19 +241,23 @@ def stage_pv(cfg: FullConfig) -> StageCost:
S_local = cfg.topo.s_local S_local = cfg.topo.s_local
dh = cfg.model.d_head dh = cfg.model.d_head
hq_per_pe = cfg.h_q_per_pe hq_per_pe = cfg.h_q_per_pe
flops_per_hop = 2 * T_q * S_local * dh * hq_per_pe B = max(1, cfg.topo.b)
flops_per_hop = 2 * B * T_q * S_local * dh * hq_per_pe
total_flops = flops_per_hop * passes total_flops = flops_per_hop * passes
_hop_word = "pass" if passes == 1 else "hops" _hop_word = "pass" if passes == 1 else "hops"
return StageCost( return StageCost(
name=f"S7 P.V (x{passes} {_hop_word})", name=f"S7 P.V (x{passes} {_hop_word})",
formula=f"2*{T_q}*{S_local}*{dh}*{hq_per_pe} = {flops_per_hop:.2g} FLOPs/hop", formula=f"2*B*T_q*S_local*d_h*(H_q/TP) = "
f"2*{B}*{T_q}*{S_local}*{dh}*{hq_per_pe} = "
f"{flops_per_hop:.2g} FLOPs/hop",
compute_s=total, memory_s=0, comm_s=0, compute_s=total, memory_s=0, comm_s=0,
bound="compute", visible_s=total, bound="compute", visible_s=total,
hop_multiplier=passes, hop_multiplier=passes,
flops=int(total_flops), mem_bytes=0, flops=int(total_flops), mem_bytes=0,
flops_formula=( flops_formula=(
f"{passes}*(2*T_q*S_local*d_h*(H_q/TP)) = " f"{passes}*(2*B*T_q*S_local*d_h*(H_q/TP)) = "
f"{passes}*(2*{T_q}*{S_local}*{dh}*{hq_per_pe}) = {total_flops:.3g}" f"{passes}*(2*{B}*{T_q}*{S_local}*{dh}*{hq_per_pe}) = "
f"{total_flops:.3g}"
), ),
mem_formula="0 (accumulated on-chip)", mem_formula="0 (accumulated on-chip)",
) )
@@ -247,7 +273,8 @@ def stage_merge(cfg: FullConfig) -> StageCost:
hq_per_pe = cfg.h_q_per_pe hq_per_pe = cfg.h_q_per_pe
dh = cfg.model.d_head dh = cfg.model.d_head
b = cfg.model.bytes_per_elem b = cfg.model.bytes_per_elem
flops = 6 * T_q * hq_per_pe * dh * max(0, cfg.topo.cp - 1) B = max(1, cfg.topo.b)
flops = 6 * B * T_q * hq_per_pe * dh * max(0, cfg.topo.cp - 1)
cmp_s = flops / (cfg.machine.peak_flops * cfg.machine.compute_util) cmp_s = flops / (cfg.machine.peak_flops * cfg.machine.compute_util)
comm_s = 0.0 comm_s = 0.0
@@ -257,8 +284,8 @@ def stage_merge(cfg: FullConfig) -> StageCost:
if cfg.topo.mode == "decode" and cfg.topo.cp > 1: if cfg.topo.mode == "decode" and cfg.topo.cp > 1:
cp = cfg.topo.cp cp = cfg.topo.cp
# (O + m + l) bytes per rank # (O + m + l) bytes per rank — scales with B (per-request partials).
M = (T_q * hq_per_pe * dh * b) + (2 * T_q * hq_per_pe * b) M = B * ((T_q * hq_per_pe * dh * b) + (2 * T_q * hq_per_pe * b))
if cfg.topo.cp_placement == "pe": if cfg.topo.cp_placement == "pe":
bw, alpha, tier = cfg.machine.bw_intra, cfg.machine.alpha_intra, "intra-cube" bw, alpha, tier = cfg.machine.bw_intra, cfg.machine.alpha_intra, "intra-cube"
elif cfg.topo.sips_used > 1: elif cfg.topo.sips_used > 1:
@@ -293,13 +320,15 @@ def stage_merge(cfg: FullConfig) -> StageCost:
return StageCost( return StageCost(
name=f"S8 online-softmax merge (x{max(0, cfg.topo.cp-1)}){name_suffix}", name=f"S8 online-softmax merge (x{max(0, cfg.topo.cp-1)}){name_suffix}",
formula=f"~6*{T_q}*{hq_per_pe}*{dh}*(C-1) = {flops:.2g} FLOPs", formula=f"~6*B*T_q*(H_q/TP)*d_h*(C-1) "
f"= 6*{B}*{T_q}*{hq_per_pe}*{dh}*{max(0, cfg.topo.cp-1)} "
f"= {flops:.2g} FLOPs",
compute_s=cmp_s, memory_s=0, comm_s=comm_s, compute_s=cmp_s, memory_s=0, comm_s=comm_s,
bound=bound, visible_s=visible_s, bound=bound, visible_s=visible_s,
flops=int(flops), mem_bytes=0, comm_bytes=comm_bytes, flops=int(flops), mem_bytes=0, comm_bytes=comm_bytes,
flops_formula=( flops_formula=(
f"6*T_q*(H_q/TP)*d_h*(CP-1) = " f"6*B*T_q*(H_q/TP)*d_h*(CP-1) = "
f"6*{T_q}*{hq_per_pe}*{dh}*{max(0, cfg.topo.cp-1)} = {flops}" f"6*{B}*{T_q}*{hq_per_pe}*{dh}*{max(0, cfg.topo.cp-1)} = {flops}"
), ),
mem_formula="0 (in-register)", mem_formula="0 (in-register)",
comm_formula=comm_formula or "0", comm_formula=comm_formula or "0",
@@ -310,15 +339,18 @@ def stage_normalize(cfg: FullConfig) -> StageCost:
T_q = cfg.topo.T_q T_q = cfg.topo.T_q
hq_per_pe = cfg.h_q_per_pe hq_per_pe = cfg.h_q_per_pe
dh = cfg.model.d_head dh = cfg.model.d_head
flops = T_q * hq_per_pe * dh B = max(1, cfg.topo.b)
flops = B * T_q * hq_per_pe * dh
cmp_s = flops / (cfg.machine.peak_flops * cfg.machine.compute_util) cmp_s = flops / (cfg.machine.peak_flops * cfg.machine.compute_util)
return StageCost( return StageCost(
name="S9 normalize O/l", name="S9 normalize O/l",
formula=f"{T_q}*{hq_per_pe}*{dh} = {flops} divisions", formula=f"B*T_q*(H_q/TP)*d_h = {B}*{T_q}*{hq_per_pe}*{dh} "
f"= {flops} divisions",
compute_s=cmp_s, memory_s=0, comm_s=0, compute_s=cmp_s, memory_s=0, comm_s=0,
bound="trivial", visible_s=cmp_s, bound="trivial", visible_s=cmp_s,
flops=int(flops), mem_bytes=0, flops=int(flops), mem_bytes=0,
flops_formula=f"T_q*(H_q/TP)*d_h = {T_q}*{hq_per_pe}*{dh} = {flops}", flops_formula=f"B*T_q*(H_q/TP)*d_h "
f"= {B}*{T_q}*{hq_per_pe}*{dh} = {flops}",
mem_formula="0", mem_formula="0",
) )
@@ -327,21 +359,25 @@ def stage_wo(cfg: FullConfig) -> StageCost:
T_q = cfg.topo.T_q T_q = cfg.topo.T_q
d = cfg.model.hidden d = cfg.model.hidden
b = cfg.model.bytes_per_elem b = cfg.model.bytes_per_elem
B = max(1, cfg.topo.b)
hq_per_pe = cfg.h_q_per_pe hq_per_pe = cfg.h_q_per_pe
dh = cfg.model.d_head dh = cfg.model.d_head
flops = 2 * T_q * (hq_per_pe * dh) * d flops = 2 * B * T_q * (hq_per_pe * dh) * d
weight_B = (hq_per_pe * dh) * d * b weight_B = (hq_per_pe * dh) * d * b
cmp_s, mem_s = _gemm_time(flops, weight_B, cfg) cmp_s, mem_s = _gemm_time(flops, weight_B, cfg)
vis, bnd = _visible(cmp_s, mem_s, 0) vis, bnd = _visible(cmp_s, mem_s, 0)
return StageCost( return StageCost(
name="S10 W_O GEMM", name="S10 W_O GEMM",
formula=f"FLOPs = 2*{T_q}*{hq_per_pe*dh}*{d} = {flops:.2g}; " formula=f"FLOPs = 2*B*T_q*(H_q/TP*d_h)*d "
f"weight = {weight_B/1e6:.1f} MB", f"= 2*{B}*{T_q}*{hq_per_pe*dh}*{d} = {flops:.2g}; "
f"weight = {weight_B/1e6:.1f} MB (shared across batch)",
compute_s=cmp_s, memory_s=mem_s, comm_s=0.0, compute_s=cmp_s, memory_s=mem_s, comm_s=0.0,
bound=bnd, visible_s=vis, bound=bnd, visible_s=vis,
flops=int(flops), mem_bytes=int(weight_B), flops=int(flops), mem_bytes=int(weight_B),
flops_formula=f"2*T_q*(H_q/TP*d_h)*d = 2*{T_q}*{hq_per_pe*dh}*{d} = {flops:.3g}", flops_formula=f"2*B*T_q*(H_q/TP*d_h)*d "
mem_formula=f"(H_q/TP*d_h)*d*b = {hq_per_pe*dh}*{d}*{b} = {weight_B/1e6:.2f} MB", f"= 2*{B}*{T_q}*{hq_per_pe*dh}*{d} = {flops:.3g}",
mem_formula=f"(H_q/TP*d_h)*d*b (weight, B-invariant) "
f"= {hq_per_pe*dh}*{d}*{b} = {weight_B/1e6:.2f} MB",
) )
@@ -369,12 +405,13 @@ def comm_cp_ring(cfg: FullConfig) -> StageCost:
T_q = cfg.topo.T_q T_q = cfg.topo.T_q
dh = cfg.model.d_head dh = cfg.model.d_head
b = cfg.model.bytes_per_elem b = cfg.model.bytes_per_elem
B = max(1, cfg.topo.b)
# ── Decode: single O/m/l all-reduce after local compute ── # ── Decode: single O/m/l all-reduce after local compute ──
if cfg.topo.mode == "decode": if cfg.topo.mode == "decode":
cp = cfg.topo.cp cp = cfg.topo.cp
# per-rank O + m + l bytes # per-rank O + m + l bytes — scales with B (per-request partials).
M = (T_q * hq_per_pe * dh * b) + (2 * T_q * hq_per_pe * b) M = B * ((T_q * hq_per_pe * dh * b) + (2 * T_q * hq_per_pe * b))
if cfg.topo.cp_placement == "pe": if cfg.topo.cp_placement == "pe":
bw = cfg.machine.bw_intra bw = cfg.machine.bw_intra
alpha = cfg.machine.alpha_intra alpha = cfg.machine.alpha_intra
@@ -392,7 +429,7 @@ def comm_cp_ring(cfg: FullConfig) -> StageCost:
return StageCost( return StageCost(
name=f"C1 O/m/l all-reduce ({tier}, x{cp} ranks)", name=f"C1 O/m/l all-reduce ({tier}, x{cp} ranks)",
formula=(f"decode: gather partial (O,m,l) once at end; " formula=(f"decode: gather partial (O,m,l) once at end; "
f"M = T_q*(H_q/TP)*(d_h*b + 2*b) = {M} B per rank; " f"M = B*T_q*(H_q/TP)*(d_h*b + 2*b) = {M} B per rank; "
f"AR: 2*(CP-1)/CP*M / BW + 2*(CP-1)*alpha"), f"AR: 2*(CP-1)/CP*M / BW + 2*(CP-1)*alpha"),
compute_s=0, memory_s=0, comm_s=ar_time, compute_s=0, memory_s=0, comm_s=ar_time,
bound="comm", visible_s=ar_time, bound="comm", visible_s=ar_time,
@@ -417,19 +454,19 @@ def comm_cp_ring(cfg: FullConfig) -> StageCost:
# ── Prefill: per-hop ring (K/V or Q+O/m/l) concurrent with S5-S7 ── # ── Prefill: per-hop ring (K/V or Q+O/m/l) concurrent with S5-S7 ──
if cfg.topo.cp_ring_variant == "qoml": if cfg.topo.cp_ring_variant == "qoml":
M_KV = (2 * T_q * hq_per_pe * dh * b) + (2 * T_q * hq_per_pe * b) M_KV = B * ((2 * T_q * hq_per_pe * dh * b) + (2 * T_q * hq_per_pe * b))
variant_desc = "Q+O/m/l ring" variant_desc = "Q+O/m/l ring"
formula_bytes = ( formula_bytes = (
f"2*T_q*(H_q/TP)*d_h*b + 2*T_q*(H_q/TP)*b " f"B*(2*T_q*(H_q/TP)*d_h*b + 2*T_q*(H_q/TP)*b) "
f"= 2*{T_q}*{hq_per_pe}*{dh}*{b} + 2*{T_q}*{hq_per_pe}*{b} " f"= {B}*(2*{T_q}*{hq_per_pe}*{dh}*{b} + 2*{T_q}*{hq_per_pe}*{b}) "
f"= {M_KV} B/hop" f"= {M_KV} B/hop"
) )
else: else:
M_KV = 2 * S_local * hkv_per_pe * dh * b M_KV = 2 * B * S_local * hkv_per_pe * dh * b
variant_desc = "K/V ring" variant_desc = "K/V ring"
formula_bytes = ( formula_bytes = (
f"2*S_local*(H_kv/TP)*d_h*b " f"2*B*S_local*(H_kv/TP)*d_h*b "
f"= 2*{S_local}*{hkv_per_pe}*{dh}*{b} " f"= 2*{B}*{S_local}*{hkv_per_pe}*{dh}*{b} "
f"= {M_KV/1e6:.3f} MB/hop" f"= {M_KV/1e6:.3f} MB/hop"
) )
@@ -501,7 +538,8 @@ def comm_tp_allreduce(cfg: FullConfig) -> StageCost:
T_q = cfg.topo.T_q T_q = cfg.topo.T_q
d = cfg.model.hidden d = cfg.model.hidden
b = cfg.model.bytes_per_elem b = cfg.model.bytes_per_elem
bytes_ = T_q * d * b B = max(1, cfg.topo.b)
bytes_ = B * T_q * d * b
tp = cfg.topo.tp tp = cfg.topo.tp
tier = cfg.topo.tp_link_tier() # "intra" | "inter" | "intersip" tier = cfg.topo.tp_link_tier() # "intra" | "inter" | "intersip"
if tier == "intra": if tier == "intra":
@@ -514,7 +552,8 @@ def comm_tp_allreduce(cfg: FullConfig) -> StageCost:
total_comm_bytes = int(2 * (tp - 1) / tp * bytes_) total_comm_bytes = int(2 * (tp - 1) / tp * bytes_)
return StageCost( return StageCost(
name=f"C2 TP AllReduce W_O (TP={tp} ranks, {scope})", name=f"C2 TP AllReduce W_O (TP={tp} ranks, {scope})",
formula=f"2*(TP-1)/TP * {T_q}*{d}*{b} B / BW + 2(TP-1)*alpha [{scope}]", formula=f"2*(TP-1)/TP * B*T_q*d*b B / BW + 2(TP-1)*alpha "
f"[B={B}, {scope}]",
compute_s=0, memory_s=0, comm_s=comm_time, compute_s=0, memory_s=0, comm_s=comm_time,
bound="comm", visible_s=comm_time, bound="comm", visible_s=comm_time,
flops=0, mem_bytes=0, comm_bytes=total_comm_bytes, flops=0, mem_bytes=0, comm_bytes=total_comm_bytes,
@@ -528,7 +567,8 @@ def comm_tp_allreduce(cfg: FullConfig) -> StageCost:
f"every rank ends up with the full hidden vector for the next\n" f"every rank ends up with the full hidden vector for the next\n"
f"stage (RMSNorm -> FFN). Fires ONCE per layer over {scope} links.\n" f"stage (RMSNorm -> FFN). Fires ONCE per layer over {scope} links.\n"
f"---\n" f"---\n"
f"2*(TP-1)/TP * T_q*d*b = 2*({tp}-1)/{tp} * {T_q}*{d}*{b} " f"2*(TP-1)/TP * B*T_q*d*b "
f"= 2*({tp}-1)/{tp} * {B}*{T_q}*{d}*{b} "
f"= {total_comm_bytes/1e6:.2f} MB over {scope} at " f"= {total_comm_bytes/1e6:.2f} MB over {scope} at "
f"{bw/1e9:.0f} GB/s" f"{bw/1e9:.0f} GB/s"
), ),
@@ -552,8 +592,9 @@ def comm_kv_split_allreduce(cfg: FullConfig) -> StageCost:
S_local = cfg.topo.s_local S_local = cfg.topo.s_local
hq_per_pe = cfg.h_q_per_pe hq_per_pe = cfg.h_q_per_pe
b = cfg.model.bytes_per_elem b = cfg.model.bytes_per_elem
# score bytes per rank per hop B = max(1, cfg.topo.b)
bytes_per_hop = hq_per_pe * T_q * S_local * b # score bytes per rank per hop — scales with B (per-request scores).
bytes_per_hop = B * hq_per_pe * T_q * S_local * b
# split-group AllReduce (intra-cube assumed if group fits) # split-group AllReduce (intra-cube assumed if group fits)
bw = cfg.machine.bw_intra bw = cfg.machine.bw_intra
alpha = cfg.machine.alpha_intra alpha = cfg.machine.alpha_intra
@@ -562,7 +603,9 @@ def comm_kv_split_allreduce(cfg: FullConfig) -> StageCost:
total_comm_bytes = int(2 * (split - 1) / split * bytes_per_hop * cfg.topo.cp) total_comm_bytes = int(2 * (split - 1) / split * bytes_per_hop * cfg.topo.cp)
return StageCost( return StageCost(
name=f"C3 Score AllReduce ({split}-way, xCP hops)", name=f"C3 Score AllReduce ({split}-way, xCP hops)",
formula=f"2*({split}-1)/{split} * {hq_per_pe}*{T_q}*{S_local}*{b} / BW + latency", formula=f"2*({split}-1)/{split} * B*(H_q/TP)*T_q*S_local*b / BW "
f"= 2*({split}-1)/{split} * {B}*{hq_per_pe}*{T_q}*{S_local}*{b} "
f"/ BW + latency",
compute_s=0, memory_s=0, comm_s=total, compute_s=0, memory_s=0, comm_s=total,
bound="comm", visible_s=total, bound="comm", visible_s=total,
flops=0, mem_bytes=0, comm_bytes=total_comm_bytes, flops=0, mem_bytes=0, comm_bytes=total_comm_bytes,
@@ -626,17 +669,20 @@ def stage_ffn_rmsnorm(cfg: FullConfig) -> StageCost:
T_q = cfg.topo.T_q T_q = cfg.topo.T_q
d = cfg.model.hidden d = cfg.model.hidden
b = cfg.model.bytes_per_elem b = cfg.model.bytes_per_elem
bytes_ = T_q * d * b * 2 B = max(1, cfg.topo.b)
flops = 4 * T_q * d # Activation memory scales with B; weight (once) is fixed.
bytes_ = B * T_q * d * b + T_q * d * b
flops = 4 * B * T_q * d
mem_s = bytes_ / cfg.machine.bw_hbm mem_s = bytes_ / cfg.machine.bw_hbm
return StageCost( return StageCost(
name="F1 RMSNorm (pre-FFN)", name="F1 RMSNorm (pre-FFN)",
formula=f"{T_q}*{d}*{b}*2 = {bytes_} B / BW_HBM", formula=f"bytes = B*T_q*d*b + T_q*d*b = {bytes_} B / BW_HBM",
compute_s=0, memory_s=mem_s, comm_s=0, compute_s=0, memory_s=mem_s, comm_s=0,
bound="memory", visible_s=mem_s, bound="memory", visible_s=mem_s,
flops=flops, mem_bytes=bytes_, flops=flops, mem_bytes=bytes_,
flops_formula=f"4*T_q*d = 4*{T_q}*{d} = {flops}", flops_formula=f"4*B*T_q*d = 4*{B}*{T_q}*{d} = {flops}",
mem_formula=f"2*T_q*d*b = 2*{T_q}*{d}*{b} = {bytes_} B", mem_formula=f"B*T_q*d*b + T_q*d*b (weight) "
f"= {B}*{T_q}*{d}*{b} + {T_q*d*b} = {bytes_} B",
) )
@@ -644,18 +690,24 @@ def _ffn_gemm(cfg: FullConfig, name: str, ffn_per_pe: int) -> StageCost:
T_q = cfg.topo.T_q T_q = cfg.topo.T_q
d = cfg.model.hidden d = cfg.model.hidden
b = cfg.model.bytes_per_elem b = cfg.model.bytes_per_elem
flops = 2 * T_q * d * ffn_per_pe B = max(1, cfg.topo.b)
# FLOPs scale with batch; weight (shared) is fixed.
flops = 2 * B * T_q * d * ffn_per_pe
weight_B = d * ffn_per_pe * b weight_B = d * ffn_per_pe * b
cmp_s, mem_s = _gemm_time(flops, weight_B, cfg) cmp_s, mem_s = _gemm_time(flops, weight_B, cfg)
vis, bnd = _visible(cmp_s, mem_s, 0) vis, bnd = _visible(cmp_s, mem_s, 0)
return StageCost( return StageCost(
name=name, name=name,
formula=f"FLOPs = 2*{T_q}*{d}*{ffn_per_pe} = {flops:.2g}; weight = {weight_B/1e6:.1f} MB", formula=f"FLOPs = 2*B*T_q*d*(ffn/div) "
f"= 2*{B}*{T_q}*{d}*{ffn_per_pe} = {flops:.2g}; "
f"weight = {weight_B/1e6:.1f} MB (shared across batch)",
compute_s=cmp_s, memory_s=mem_s, comm_s=0, compute_s=cmp_s, memory_s=mem_s, comm_s=0,
bound=bnd, visible_s=vis, bound=bnd, visible_s=vis,
flops=int(flops), mem_bytes=int(weight_B), flops=int(flops), mem_bytes=int(weight_B),
flops_formula=f"2*T_q*d*(ffn/div) = 2*{T_q}*{d}*{ffn_per_pe} = {flops:.3g}", flops_formula=f"2*B*T_q*d*(ffn/div) "
mem_formula=f"d*(ffn/div)*b = {d}*{ffn_per_pe}*{b} = {weight_B/1e6:.2f} MB", f"= 2*{B}*{T_q}*{d}*{ffn_per_pe} = {flops:.3g}",
mem_formula=f"d*(ffn/div)*b (weight, B-invariant) "
f"= {d}*{ffn_per_pe}*{b} = {weight_B/1e6:.2f} MB",
) )
@@ -674,17 +726,20 @@ def stage_ffn_swiglu(cfg: FullConfig) -> StageCost:
T_q = cfg.topo.T_q T_q = cfg.topo.T_q
ffn_per_pe = max(1, cfg.model.ffn_dim // (cfg.ffn_shard_divisor * max(1, cfg.topo.ep))) ffn_per_pe = max(1, cfg.model.ffn_dim // (cfg.ffn_shard_divisor * max(1, cfg.topo.ep)))
b = cfg.model.bytes_per_elem b = cfg.model.bytes_per_elem
bytes_ = 3 * T_q * ffn_per_pe * b B = max(1, cfg.topo.b)
flops = 3 * T_q * ffn_per_pe # gate * silu(up) approximated as 3 flops/elt bytes_ = 3 * B * T_q * ffn_per_pe * b
flops = 3 * B * T_q * ffn_per_pe # gate * silu(up) approximated as 3 flops/elt
mem_s = bytes_ / cfg.machine.bw_hbm mem_s = bytes_ / cfg.machine.bw_hbm
return StageCost( return StageCost(
name="F4 SwiGLU act", name="F4 SwiGLU act",
formula=f"3*{T_q}*{ffn_per_pe}*{b} = {bytes_} B / BW_HBM", formula=f"3*B*T_q*(ffn/div)*b "
f"= 3*{B}*{T_q}*{ffn_per_pe}*{b} = {bytes_} B / BW_HBM",
compute_s=0, memory_s=mem_s, comm_s=0, compute_s=0, memory_s=mem_s, comm_s=0,
bound="memory", visible_s=mem_s, bound="memory", visible_s=mem_s,
flops=flops, mem_bytes=bytes_, flops=flops, mem_bytes=bytes_,
flops_formula=f"~3*T_q*(ffn/div) = 3*{T_q}*{ffn_per_pe} = {flops}", flops_formula=f"~3*B*T_q*(ffn/div) = 3*{B}*{T_q}*{ffn_per_pe} = {flops}",
mem_formula=f"3*T_q*(ffn/div)*b = 3*{T_q}*{ffn_per_pe}*{b} = {bytes_} B", mem_formula=f"3*B*T_q*(ffn/div)*b "
f"= 3*{B}*{T_q}*{ffn_per_pe}*{b} = {bytes_} B",
) )
@@ -693,19 +748,24 @@ def stage_ffn_down(cfg: FullConfig) -> StageCost:
T_q = cfg.topo.T_q T_q = cfg.topo.T_q
d = cfg.model.hidden d = cfg.model.hidden
b = cfg.model.bytes_per_elem b = cfg.model.bytes_per_elem
B = max(1, cfg.topo.b)
ffn_per_pe = max(1, cfg.model.ffn_dim // (cfg.ffn_shard_divisor * max(1, cfg.topo.ep))) ffn_per_pe = max(1, cfg.model.ffn_dim // (cfg.ffn_shard_divisor * max(1, cfg.topo.ep)))
flops = 2 * T_q * ffn_per_pe * d flops = 2 * B * T_q * ffn_per_pe * d
weight_B = ffn_per_pe * d * b weight_B = ffn_per_pe * d * b
cmp_s, mem_s = _gemm_time(flops, weight_B, cfg) cmp_s, mem_s = _gemm_time(flops, weight_B, cfg)
vis, bnd = _visible(cmp_s, mem_s, 0) vis, bnd = _visible(cmp_s, mem_s, 0)
return StageCost( return StageCost(
name="F5 W_down GEMM", name="F5 W_down GEMM",
formula=f"FLOPs = 2*{T_q}*{ffn_per_pe}*{d} = {flops:.2g}; weight = {weight_B/1e6:.1f} MB", formula=f"FLOPs = 2*B*T_q*(ffn/div)*d "
f"= 2*{B}*{T_q}*{ffn_per_pe}*{d} = {flops:.2g}; "
f"weight = {weight_B/1e6:.1f} MB (shared across batch)",
compute_s=cmp_s, memory_s=mem_s, comm_s=0, compute_s=cmp_s, memory_s=mem_s, comm_s=0,
bound=bnd, visible_s=vis, bound=bnd, visible_s=vis,
flops=int(flops), mem_bytes=int(weight_B), flops=int(flops), mem_bytes=int(weight_B),
flops_formula=f"2*T_q*(ffn/div)*d = 2*{T_q}*{ffn_per_pe}*{d} = {flops:.3g}", flops_formula=f"2*B*T_q*(ffn/div)*d "
mem_formula=f"(ffn/div)*d*b = {ffn_per_pe}*{d}*{b} = {weight_B/1e6:.2f} MB", f"= 2*{B}*{T_q}*{ffn_per_pe}*{d} = {flops:.3g}",
mem_formula=f"(ffn/div)*d*b (weight, B-invariant) "
f"= {ffn_per_pe}*{d}*{b} = {weight_B/1e6:.2f} MB",
) )
@@ -723,7 +783,9 @@ def comm_ffn_allreduce(cfg: FullConfig) -> StageCost:
T_q = cfg.topo.T_q T_q = cfg.topo.T_q
d = cfg.model.hidden d = cfg.model.hidden
b = cfg.model.bytes_per_elem b = cfg.model.bytes_per_elem
bytes_ = T_q * d * b B = max(1, cfg.topo.b)
# AR reduces the batched FFN output — bytes scale with B.
bytes_ = B * T_q * d * b
# Choose BW/alpha tier based on scope (rough): TP=intra-cube (or inter-cube), # Choose BW/alpha tier based on scope (rough): TP=intra-cube (or inter-cube),
# +CP=inter-SIP possible, +DP=inter-SIP always. # +CP=inter-SIP possible, +DP=inter-SIP always.
if "DP" in scope or "CP" in scope: if "DP" in scope or "CP" in scope:
@@ -736,7 +798,8 @@ def comm_ffn_allreduce(cfg: FullConfig) -> StageCost:
total_comm_bytes = int(2 * (divisor - 1) / divisor * bytes_) total_comm_bytes = int(2 * (divisor - 1) / divisor * bytes_)
return StageCost( return StageCost(
name=f"CF1 FFN AllReduce (scope={scope}, x{divisor})", name=f"CF1 FFN AllReduce (scope={scope}, x{divisor})",
formula=f"2*({divisor}-1)/{divisor} * {T_q}*{d}*{b} B / BW + latency", formula=f"2*({divisor}-1)/{divisor} * B*T_q*d*b B / BW + latency "
f"[B={B}]",
compute_s=0, memory_s=0, comm_s=comm_time, compute_s=0, memory_s=0, comm_s=comm_time,
bound="comm", visible_s=comm_time, bound="comm", visible_s=comm_time,
flops=0, mem_bytes=0, comm_bytes=total_comm_bytes, flops=0, mem_bytes=0, comm_bytes=total_comm_bytes,
@@ -749,8 +812,8 @@ def comm_ffn_allreduce(cfg: FullConfig) -> StageCost:
f"rank has the full FFN output for the next layer. Larger scope\n" f"rank has the full FFN output for the next layer. Larger scope\n"
f"= less FFN weight memory per PE but bigger AR bill.\n" f"= less FFN weight memory per PE but bigger AR bill.\n"
f"---\n" f"---\n"
f"2*(div-1)/div * T_q*d*b = 2*({divisor}-1)/{divisor} * " f"2*(div-1)/div * B*T_q*d*b = 2*({divisor}-1)/{divisor} * "
f"{T_q}*{d}*{b} = {total_comm_bytes/1e6:.2f} MB at " f"{B}*{T_q}*{d}*{b} = {total_comm_bytes/1e6:.2f} MB at "
f"{bw/1e9:.0f} GB/s (scope={scope})" f"{bw/1e9:.0f} GB/s (scope={scope})"
), ),
) )
@@ -0,0 +1,195 @@
"""Per-stage tensor shape rows for the analytical visualization.
Complements stage_latencies.py: while that module reports FLOPs/bytes/
time per stage, this one reports the INPUT / WEIGHT / OUTPUT tensor
shapes each stage operates on, per PE. Used for the 'per-stage shape'
tables in the Streamlit app.
Shape strings substitute the current cfg's numeric values so the table
reads like an inspection of the actual deployment (no symbolic-only
form).
"""
from __future__ import annotations
from .model_config import FullConfig
def _s(shape: tuple) -> str:
"""Render a tuple of dim values as '(a, b, c)'."""
return "(" + ", ".join(str(x) for x in shape) + ")"
def attn_stage_shape_rows(cfg: FullConfig) -> list[dict]:
"""Per-PE attention shapes, one row per stage.
Rows follow the same S1..S10, C1..C3 order the latency table uses,
with the same conditional insertions (C1 only in prefill+CP>1, C3
only when kv_shard_mode='split' and TP > H_kv, C2 only when TP>1,
S8 merge only when CP>1).
"""
m = cfg.model
t = cfg.topo
B = max(1, t.b)
T_q = t.T_q
S_local = t.s_local
d = m.hidden
dh = m.d_head
hq_pe = cfg.h_q_per_pe
hkv_pe = max(1, m.h_kv // t.tp)
rows: list[dict] = []
rows.append({
"Stage": "S1", "Op": "RMSNorm",
"Input (per PE)": _s((B, T_q, d)),
"Weight (per PE)": _s((d,)),
"Output (per PE)": _s((B, T_q, d)),
})
rows.append({
"Stage": "S2", "Op": "W_Q GEMM",
"Input (per PE)": _s((B, T_q, d)),
"Weight (per PE)": _s((d, hq_pe * dh)),
"Output (per PE)": _s((B, T_q, hq_pe * dh)),
})
rows.append({
"Stage": "S3", "Op": "W_K + W_V GEMM",
"Input (per PE)": _s((B, T_q, d)),
"Weight (per PE)": f"{_s((d, hkv_pe * dh))} x2 (K, V)",
"Output (per PE)": f"{_s((B, T_q, hkv_pe * dh))} x2 (K, V)",
})
rows.append({
"Stage": "S4", "Op": "KV cache append",
"Input (per PE)": f"{_s((B, T_q, hkv_pe * dh))} x2 (K, V)",
"Weight (per PE)": "-",
"Output (per PE)": (
f"KV cache: {_s((B, S_local, hkv_pe * dh))} x2 "
f"(extends by T_q={T_q} tokens)"
),
})
rows.append({
"Stage": "S5", "Op": "Q · K^T",
"Input (per PE)": (
f"Q={_s((B, hq_pe, T_q, dh))}, "
f"K^T={_s((B, hkv_pe, dh, S_local))}"
),
"Weight (per PE)": "-",
"Output (per PE)": _s((B, hq_pe, T_q, S_local)),
})
rows.append({
"Stage": "S6", "Op": "softmax",
"Input (per PE)": _s((B, hq_pe, T_q, S_local)),
"Weight (per PE)": "-",
"Output (per PE)": _s((B, hq_pe, T_q, S_local)),
})
rows.append({
"Stage": "S7", "Op": "P · V",
"Input (per PE)": (
f"P={_s((B, hq_pe, T_q, S_local))}, "
f"V={_s((B, hkv_pe, S_local, dh))}"
),
"Weight (per PE)": "-",
"Output (per PE)": _s((B, hq_pe, T_q, dh)),
})
if t.mode == "prefill" and t.cp > 1:
variant = t.cp_ring_variant
if variant == "kv":
payload = (
f"K,V shards rotate: {_s((B, S_local, hkv_pe, dh))} x2 per hop"
)
else:
payload = (
f"Q + running (O,m,l) rotate: {_s((B, hq_pe, T_q, dh))} per hop"
)
rows.append({
"Stage": "C1", "Op": f"CP ring ({variant})",
"Input (per PE)": payload,
"Weight (per PE)": "-",
"Output (per PE)": "(circulating; final owner reduces)",
})
if t.kv_shard_mode == "split" and t.tp > m.h_kv:
rows.append({
"Stage": "C3", "Op": "Score AllReduce (head-split)",
"Input (per PE)": _s((B, hq_pe, T_q, S_local)),
"Weight (per PE)": "-",
"Output (per PE)": _s((B, hq_pe, T_q, S_local)),
})
if t.cp > 1:
rows.append({
"Stage": "S8", "Op": "online-softmax merge",
"Input (per PE)": (
f"O={_s((B, hq_pe, T_q, dh))}, "
f"m,l={_s((B, hq_pe, T_q))} each"
),
"Weight (per PE)": "-",
"Output (per PE)": _s((B, hq_pe, T_q, dh)),
})
rows.append({
"Stage": "S9", "Op": "normalize O / l",
"Input (per PE)": _s((B, hq_pe, T_q, dh)),
"Weight (per PE)": "-",
"Output (per PE)": _s((B, hq_pe, T_q, dh)),
})
rows.append({
"Stage": "S10", "Op": "W_O GEMM",
"Input (per PE)": _s((B, T_q, hq_pe * dh)),
"Weight (per PE)": _s((hq_pe * dh, d)),
"Output (per PE)": _s((B, T_q, d)),
})
if t.tp > 1:
rows.append({
"Stage": "C2", "Op": f"TP AllReduce (W_O, TP={t.tp})",
"Input (per PE)": _s((B, T_q, d)),
"Weight (per PE)": "-",
"Output (per PE)": _s((B, T_q, d)),
})
return rows
def ffn_stage_shape_rows(cfg: FullConfig) -> list[dict]:
"""Per-PE FFN shapes, one row per stage."""
m = cfg.model
t = cfg.topo
B = max(1, t.b)
T_q = t.T_q
d = m.hidden
divisor = cfg.ffn_shard_divisor * max(1, t.ep)
ffn_pe = max(1, m.ffn_dim // divisor)
scope = t.ffn_shard_scope
rows: list[dict] = [
{"Stage": "F1", "Op": "RMSNorm (pre-FFN)",
"Input (per PE)": _s((B, T_q, d)),
"Weight (per PE)": _s((d,)),
"Output (per PE)": _s((B, T_q, d))},
{"Stage": "F2", "Op": "W_gate GEMM",
"Input (per PE)": _s((B, T_q, d)),
"Weight (per PE)": _s((d, ffn_pe)),
"Output (per PE)": _s((B, T_q, ffn_pe))},
{"Stage": "F3", "Op": "W_up GEMM",
"Input (per PE)": _s((B, T_q, d)),
"Weight (per PE)": _s((d, ffn_pe)),
"Output (per PE)": _s((B, T_q, ffn_pe))},
{"Stage": "F4", "Op": "SwiGLU (gate * silu(up))",
"Input (per PE)": f"gate, up = {_s((B, T_q, ffn_pe))} each",
"Weight (per PE)": "-",
"Output (per PE)": _s((B, T_q, ffn_pe))},
{"Stage": "F5", "Op": "W_down GEMM",
"Input (per PE)": _s((B, T_q, ffn_pe)),
"Weight (per PE)": _s((ffn_pe, d)),
"Output (per PE)": _s((B, T_q, d))},
]
if cfg.ffn_shard_divisor > 1:
rows.append({
"Stage": "CF1",
"Op": f"FFN AllReduce (scope={scope}, x{cfg.ffn_shard_divisor})",
"Input (per PE)": _s((B, T_q, d)),
"Weight (per PE)": "-",
"Output (per PE)": _s((B, T_q, d)),
})
return rows
@@ -279,12 +279,38 @@ def draw_tensor_sharding(cfg: FullConfig, ax=None, my_pe: int = 0,
row_label=f"rows split by {ffn_txt}", row_label=f"rows split by {ffn_txt}",
note="row-parallel") note="row-parallel")
# ── KV cache: (S_kv, H_kv*d_head), 2D shard ─ # ── KV cache: (B, S_kv, H_kv*d_head), 2D shard on S_kv x heads
# Batch (B) is a stacking dimension — each concurrent request holds
# its own KV slice per PE, so total KV per PE = B * (per-slice size).
kv_row_splits = cp kv_row_splits = cp
kv_col_splits = min(tp, m.h_kv) if m.h_kv >= tp else tp kv_col_splits = min(tp, m.h_kv) if m.h_kv >= tp else tp
_B = max(1, cfg.topo.b)
_batch_suffix = f", B={_B}" if _B > 1 else ""
_batch_note = (f"; batch B={_B} => {_B}x KV bytes per PE"
if _B > 1 else "")
# Draw B-1 offset "shadow" rectangles behind the KV cache to give
# a visual hint of the batch stacking dimension. Cap at 5 shadows
# so a large B doesn't overwhelm the diagram.
if _B > 1:
_kv_shadow_x = px(3)
_kv_shadow_y = py(0)
_n_shadows = min(_B - 1, 5)
_offset_step = 0.15
for _si in range(_n_shadows, 0, -1):
_dx = _si * _offset_step
_dy = _si * _offset_step
_shadow = patches.Rectangle(
(_kv_shadow_x + _dx, _kv_shadow_y + _dy),
cell_w, cell_h,
facecolor="#f5f5f5",
edgecolor="#adb5bd",
linewidth=0.8, linestyle="--", alpha=0.55,
)
ax.add_patch(_shadow)
_draw_tensor(ax, px(3), py(0), cell_w, cell_h, _draw_tensor(ax, px(3), py(0), cell_w, cell_h,
"KV cache (K and V)", "KV cache (K and V)",
f"({m.h_kv * m.d_head}, S_kv={cfg.topo.s_kv:,})", f"({m.h_kv * m.d_head}, S_kv={cfg.topo.s_kv:,}"
f"{_batch_suffix})",
row_splits=kv_row_splits, row_splits=kv_row_splits,
col_splits=kv_col_splits if show_physical else 1, col_splits=kv_col_splits if show_physical else 1,
my_row_shard=kv_row_shard, my_row_shard=kv_row_shard,
@@ -292,7 +318,8 @@ def draw_tensor_sharding(cfg: FullConfig, ax=None, my_pe: int = 0,
row_label=f"rows: S split by {cp_txt}", row_label=f"rows: S split by {cp_txt}",
col_label=f"cols: heads split by {tp_txt}", col_label=f"cols: heads split by {tp_txt}",
col_line_color=_TP_LINE_COLOR, col_line_color=_TP_LINE_COLOR,
note="2D shard: seq axis (CP) x head axis (TP)", note=f"2D shard: seq axis (CP) x head axis (TP)"
f"{_batch_note}",
cell_annot=_kv_ann) cell_annot=_kv_ann)
# In non-physical mode, KV was drawn with col_splits=1; overlay TP col # In non-physical mode, KV was drawn with col_splits=1; overlay TP col
# splits as dotted lines to hint at the 2D nature. In physical mode # splits as dotted lines to hint at the 2D nature. In physical mode
@@ -311,8 +338,11 @@ def draw_tensor_sharding(cfg: FullConfig, ax=None, my_pe: int = 0,
ax.set_aspect("auto") ax.set_aspect("auto")
ax.axis("off") ax.axis("off")
_B_title = max(1, cfg.topo.b)
_b_title_suffix = f", B={_B_title}" if _B_title > 1 else ""
ax.set_title( ax.set_title(
f"Tensor sharding view (PE {my_pe}) | TP={tp}, CP={cp}, " f"Tensor sharding view (PE {my_pe}) | TP={tp}, CP={cp}"
f"{_b_title_suffix}, "
f"FFN scope={cfg.topo.ffn_shard_scope} | " f"FFN scope={cfg.topo.ffn_shard_scope} | "
f"blue = this PE's shard", f"blue = this PE's shard",
fontsize=11, fontweight="bold", fontsize=11, fontweight="bold",
@@ -19,8 +19,9 @@ from tests.analytical_visualization.auto_explore import (
run_auto_explore, run_auto_explore,
score_config, score_config,
) )
from tests.analytical_visualization.autosuggest import auto_suggest
from tests.analytical_visualization.model_config import ( from tests.analytical_visualization.model_config import (
FullConfig, MachineParams, FullConfig, MachineParams, TopologyConfig,
) )
from tests.analytical_visualization.model_presets import PRESETS from tests.analytical_visualization.model_presets import PRESETS
@@ -208,6 +209,39 @@ def test_ffn_only_latency_less_than_full_and_different_from_attn():
) )
def test_auto_suggest_cubes_match_default_topology():
"""A TopologyConfig built from auto_suggest's returned (cp, tp, pp,
cp_placement) with all other fields left at their dataclass defaults
must produce the same cubes_used the Suggestion reports. Guards the
sidebar apply: any session_state key auto_suggest doesn't explore
(tp_placement, kv_shard_mode, cp_ring_variant, ep, ffn_shard_scope)
must be reset to the default before rerun, else the applied topology
won't match what the memory-min search actually scored.
"""
machine = MachineParams()
matrix = [
("Llama 3 70B", 8192),
("Llama 3 70B", 32768),
("Llama 3 8B", 131072),
("Qwen 3 32B", 32768),
]
for name, skv in matrix:
m = PRESETS[name].model
for ia, ff in [(True, False), (False, True), (True, True)]:
sug = auto_suggest(m, machine, s_kv=skv, mode="decode",
include_attention=ia, include_ffn=ff)
topo = TopologyConfig(
cp=sug.cp, tp=sug.tp, pp=sug.pp,
s_kv=skv, mode="decode", b=1,
cp_placement=sug.cp_placement,
)
assert topo.cubes_used == sug.cubes_used, (
f"{name} skv={skv} scope=(ia={ia},ff={ff}): "
f"topo.cubes_used={topo.cubes_used} != "
f"sug.cubes_used={sug.cubes_used}"
)
def test_parallelism_sensitivity_includes_baseline_value(): def test_parallelism_sensitivity_includes_baseline_value():
"""Each row's values contain the baseline_value.""" """Each row's values contain the baseline_value."""
from tests.analytical_visualization.auto_explore import ( from tests.analytical_visualization.auto_explore import (
@@ -0,0 +1,519 @@
"""Tests for chip_roofline: AI, B*, L*, per-token latency curves."""
from __future__ import annotations
import math
import pytest
from tests.analytical_visualization.chip_roofline import (
ai_sensitivity_curve,
arithmetic_intensity,
balance_context,
bound_regime,
critical_batch,
good_batch,
good_context,
knee_batch,
kv_bytes_per_token,
max_batch_within_slo,
memory_budget_curve_vs_batch,
memory_budget_curve_vs_skv,
per_token_latency_curve,
size_deployment,
step_latency_curve,
t_com,
t_mem_long,
t_mem_short,
total_active_params,
utilization_at,
)
from tests.analytical_visualization.model_config import (
FullConfig, MachineParams, ModelConfig, TopologyConfig,
)
from tests.analytical_visualization.model_presets import PRESETS
# ── Arithmetic intensity ────────────────────────────────────────────
def test_arithmetic_intensity_matches_ratio():
"""AI = peak_flops / bw_hbm — pure ratio, no util factor."""
m = MachineParams(peak_tflops_f16=8.0, bw_hbm_gbs=256.0)
assert arithmetic_intensity(m) == pytest.approx(8e12 / 256e9)
def test_ai_scales_with_flops_and_bandwidth():
m1 = MachineParams(peak_tflops_f16=8.0, bw_hbm_gbs=256.0)
m2 = MachineParams(peak_tflops_f16=16.0, bw_hbm_gbs=256.0)
m3 = MachineParams(peak_tflops_f16=8.0, bw_hbm_gbs=128.0)
assert arithmetic_intensity(m2) == pytest.approx(2 * arithmetic_intensity(m1))
assert arithmetic_intensity(m3) == pytest.approx(2 * arithmetic_intensity(m1))
# ── Critical batch B* ───────────────────────────────────────────────
def test_critical_batch_h100_reference():
"""H100-class: 989 TFLOPs BF16, 3.35 TB/s HBM3 → B* ≈ 295."""
m = MachineParams(peak_tflops_f16=989.0, bw_hbm_gbs=3350.0)
model = PRESETS["Llama 3 8B"].model # bf16 (b=2)
b_star = critical_batch(m, model)
assert b_star == pytest.approx(295, rel=0.05), b_star
def test_critical_batch_scales_with_sparsity():
"""MoE 8× sparsity gives 8× B*."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
b_dense = critical_batch(m, model, sparsity=1)
b_moe8 = critical_batch(m, model, sparsity=8)
assert b_moe8 == pytest.approx(8 * b_dense)
def test_critical_batch_bf16_formula():
"""For BF16 (b=2), B* = AI in flops-per-byte units → numerically."""
m = MachineParams(peak_tflops_f16=8.0, bw_hbm_gbs=256.0)
model = ModelConfig(bytes_per_elem=2)
ai = arithmetic_intensity(m)
assert critical_batch(m, model) == pytest.approx(ai), (
critical_batch(m, model), ai,
)
# ── Balance context L* ──────────────────────────────────────────────
def test_balance_context_positive_finite():
"""L* for a real model on real machine is a positive finite number."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
l_star = balance_context(m, model)
assert 0 < l_star < 1e9, l_star
def test_balance_context_scales_with_bandwidth():
"""L* = 2N*W/(C*kv_bpt): doubling HBM BW doubles L*.
Slower memory hits the KV wall at a shorter context. Machine-only
change so N_active and kv_bpt stay fixed."""
fast = MachineParams(bw_hbm_gbs=512.0)
slow = MachineParams(bw_hbm_gbs=256.0)
model = PRESETS["Llama 3 8B"].model
assert (balance_context(fast, model)
== pytest.approx(2 * balance_context(slow, model)))
# ── Knee ────────────────────────────────────────────────────────────
def test_knee_equals_b_star_at_short_context():
"""S_kv → 0: B_knee → B*."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
b_star = critical_batch(m, model)
assert knee_batch(m, model, s_kv=1) == pytest.approx(b_star, rel=1e-3)
def test_knee_diverges_at_balance_context():
"""S_kv = L*: knee is infinite; S_kv > L*: no knee (None)."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
l_star = balance_context(m, model)
assert knee_batch(m, model, s_kv=int(2 * l_star)) is None
# At 0.9 * L*, knee ~= 10 * B*; assert at least 5x to survive rounding.
below = knee_batch(m, model, s_kv=int(0.9 * l_star))
assert below is not None and below > 5 * critical_batch(m, model)
def test_knee_slides_right_with_context():
"""S_kv = L*/2 → B_knee = 2 * B*."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
b_star = critical_batch(m, model)
l_star = balance_context(m, model)
got = knee_batch(m, model, s_kv=int(l_star / 2))
assert got == pytest.approx(2 * b_star, rel=0.01), (got, 2 * b_star)
# ── Per-token latency curve ─────────────────────────────────────────
def test_latency_curve_monotonically_decreasing():
"""total_s must strictly decrease as batch increases (weight/B shrinks)."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
pts = per_token_latency_curve(m, model, [1, 2, 4, 8, 16, 32, 64],
s_kv=1024)
totals = [p.total_s for p in pts]
for a, b in zip(totals, totals[1:]):
assert b < a, (a, b)
def test_latency_curve_asymptotes_to_compute_plus_kv():
"""At very large B, total → compute + KV (weight/B → 0)."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
pts = per_token_latency_curve(m, model, [10_000_000], s_kv=1024)
p = pts[0]
assert p.total_s == pytest.approx(p.compute_s + p.kv_s, rel=1e-3)
def test_latency_at_b_star_is_roughly_2x_floor():
"""At B*, weight = compute (dense, S_kv small), so total ≈ 2 * compute."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
b_star = int(round(critical_batch(m, model)))
pts = per_token_latency_curve(m, model, [b_star], s_kv=1)
p = pts[0]
# weight_s ≈ compute_s at B*
assert p.weight_s == pytest.approx(p.compute_s, rel=0.05), (
p.weight_s, p.compute_s,
)
# ── Regime classification ───────────────────────────────────────────
def test_regime_memory_bound_at_b1():
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
assert bound_regime(m, model, batch=1, s_kv=1024) == "memory-bound"
def test_regime_kv_bound_past_balance_context():
"""S_kv > L* with reasonable batch → KV dominates."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
l_star = balance_context(m, model)
b_star = int(round(critical_batch(m, model)))
assert bound_regime(m, model, batch=b_star * 4,
s_kv=int(3 * l_star)) == "kv-bound"
# ── Component sanity ────────────────────────────────────────────────
def test_total_active_params_llama3_8b_ballpark():
"""Llama 3 8B — attn+FFN alone (no embeddings/LM head) is ~6.9B,
HF-reported total 8.03B. Guard the ballpark."""
n = total_active_params(PRESETS["Llama 3 8B"].model)
assert 6.5e9 < n < 8e9, n
def test_kv_bytes_per_token_llama3_8b():
"""Llama 3 8B: 2*8*128*2 bytes/layer/token × 32 layers = 131072 bytes."""
kv_bpt = kv_bytes_per_token(PRESETS["Llama 3 8B"].model)
assert kv_bpt == 2 * 8 * 128 * 2 * 32
# ── Regime terms + good-batch / good-context recommendations ──────
def test_t_mem_short_shrinks_with_batch():
"""t_mem_short = N·b/(W·B) — doubling B halves it."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
assert t_mem_short(m, model, 2) == pytest.approx(
t_mem_short(m, model, 1) / 2
)
def test_t_mem_long_equals_t_com_at_l_star():
"""L* is defined by t_mem_long(L*) == t_com. Cross-check."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
l_star = balance_context(m, model)
assert t_mem_long(m, model, int(round(l_star))) == pytest.approx(
t_com(m, model), rel=0.01,
)
def test_t_com_is_batch_independent():
"""t_com = 2·N/C. Constant — doesn't depend on B or S_kv."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
tc = t_com(m, model)
assert tc == pytest.approx(2 * total_active_params(model) / m.peak_flops)
def test_good_batch_is_two_b_star():
"""Recommendation is 2 × B* (Pope's rule)."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
rec = good_batch(m, model)
assert rec.effective == pytest.approx(2 * critical_batch(m, model))
assert rec.b_star == pytest.approx(critical_batch(m, model))
def test_good_batch_moe_scales_with_sparsity():
"""MoE sparsity=8 → recommended B is 8× dense recommendation."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
r_dense = good_batch(m, model, sparsity=1.0)
r_moe = good_batch(m, model, sparsity=8.0)
assert r_moe.effective == pytest.approx(8 * r_dense.effective)
def test_good_context_returns_l_star_as_ceiling():
"""Recommendation is L*; utilization drops past it."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
rec = good_context(m, model, s_kv=100)
assert rec.l_star == pytest.approx(balance_context(m, model))
assert rec.max_efficient == pytest.approx(rec.l_star)
def test_utilization_at_l_star_is_half():
"""At S_kv = L*, compute and KV read take equal time → 50% util."""
l_star = 3000.0
assert utilization_at(int(l_star), l_star) == pytest.approx(0.5)
def test_utilization_at_2_l_star_is_one_third():
"""At S_kv = 2·L*, util = 1/(1+2) = 33.3%."""
l_star = 3000.0
assert utilization_at(int(2 * l_star), l_star) == pytest.approx(1 / 3, rel=1e-3)
def test_utilization_at_zero_context_is_100_percent():
"""Zero context, no KV to read → all time is compute."""
assert utilization_at(0, 3000.0) == pytest.approx(1.0)
def test_utilization_drops_monotonically_with_context():
"""util(S_kv) is strictly decreasing."""
l_star = 3000.0
vals = [utilization_at(s, l_star) for s in [100, 1000, 3000, 6000, 30000]]
for a, b in zip(vals, vals[1:]):
assert b < a
# ── Step latency (undivided by B) ──────────────────────────────────
def test_step_weight_is_batch_invariant():
"""step_weight = N·b/W — same for every B (loaded once per step)."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
pts = step_latency_curve(m, model, [1, 8, 64, 512], s_kv=1024)
ws = [p.weight_s for p in pts]
assert all(w == pytest.approx(ws[0]) for w in ws), ws
def test_step_compute_linear_in_batch():
"""step_compute = 2·N·B/C — doubling B doubles compute."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
pts = step_latency_curve(m, model, [1, 2, 8], s_kv=1024)
assert pts[1].compute_s == pytest.approx(2 * pts[0].compute_s)
assert pts[2].compute_s == pytest.approx(8 * pts[0].compute_s)
def test_step_kv_linear_in_batch():
"""step_kv = B · S_kv · kv_bpt / W — linear in B."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
pts = step_latency_curve(m, model, [1, 4], s_kv=1024)
assert pts[1].kv_s == pytest.approx(4 * pts[0].kv_s)
def test_step_total_equals_per_token_times_batch():
"""The step and per-token views are just ÷ B / × B of each other."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
B_range = [1, 4, 16, 64]
s_kv = 1024
step_pts = step_latency_curve(m, model, B_range, s_kv=s_kv)
tok_pts = per_token_latency_curve(m, model, B_range, s_kv=s_kv)
for sp, tp in zip(step_pts, tok_pts):
assert sp.total_s == pytest.approx(tp.total_s * sp.batch)
def test_step_and_per_token_identical_at_b1():
"""At B=1, step latency == per-token cost (dividing by 1)."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
sp = step_latency_curve(m, model, [1], s_kv=1024)[0]
tp = per_token_latency_curve(m, model, [1], s_kv=1024)[0]
assert sp.total_s == pytest.approx(tp.total_s)
assert sp.weight_s == pytest.approx(tp.weight_s)
assert sp.compute_s == pytest.approx(tp.compute_s)
assert sp.kv_s == pytest.approx(tp.kv_s)
# ── PE memory budget sweeps ────────────────────────────────────────
def _budget_cfg():
"""Helper: real cfg with enough sharding to leave headroom."""
return FullConfig(
model=PRESETS["Llama 3 8B"].model,
topo=TopologyConfig(cp=2, tp=8, pp=1, b=1, s_kv=8192),
machine=MachineParams(),
)
def test_kv_grows_linear_with_skv():
cfg = _budget_cfg()
pts = memory_budget_curve_vs_skv(cfg, s_kv_range=[1024, 2048, 8192],
batch=1)
assert pts[1].kv_gb == pytest.approx(2 * pts[0].kv_gb, rel=1e-3)
assert pts[2].kv_gb == pytest.approx(8 * pts[0].kv_gb, rel=1e-3)
def test_kv_grows_linear_with_batch():
cfg = _budget_cfg()
pts = memory_budget_curve_vs_batch(cfg, b_range=[1, 2, 8],
s_kv=8192)
assert pts[1].kv_gb == pytest.approx(2 * pts[0].kv_gb, rel=1e-3)
assert pts[2].kv_gb == pytest.approx(8 * pts[0].kv_gb, rel=1e-3)
def test_weights_flat_across_skv_sweep():
"""Weight bytes are batch/S_kv-invariant — sharding fixed."""
cfg = _budget_cfg()
pts = memory_budget_curve_vs_skv(cfg, s_kv_range=[128, 8192, 1_000_000],
batch=1)
ws = [p.weights_gb for p in pts]
assert all(w == pytest.approx(ws[0]) for w in ws), ws
def test_over_budget_flag_trips_past_hbm():
"""Push S_kv until KV blows the HBM budget; over_budget must flip."""
cfg = _budget_cfg()
pts = memory_budget_curve_vs_skv(
cfg, s_kv_range=[1024, 10_000_000], batch=64,
)
# 10M tokens × B=64 KV should overflow the 6 GB budget.
assert not pts[0].over_budget, pts[0]
assert pts[-1].over_budget, pts[-1]
def test_free_gb_never_negative():
cfg = _budget_cfg()
pts = memory_budget_curve_vs_skv(
cfg, s_kv_range=[128, 10_000_000], batch=32,
)
for p in pts:
assert p.free_gb >= 0, p
# ── AI sensitivity to FLOPs / BW ──────────────────────────────────
def test_ai_scales_linearly_with_flops():
"""Doubling FLOPs doubles AI."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
pts = ai_sensitivity_curve(m, model, [1.0, 2.0, 4.0], axis="flops")
assert pts[1].ai == pytest.approx(2 * pts[0].ai)
assert pts[2].ai == pytest.approx(4 * pts[0].ai)
def test_ai_scales_inversely_with_bw():
"""Doubling BW halves AI."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
pts = ai_sensitivity_curve(m, model, [1.0, 2.0], axis="bw")
assert pts[1].ai == pytest.approx(pts[0].ai / 2)
def test_ai_sensitivity_b_star_tracks_ai_for_bf16():
"""For BF16, B* == AI. Curve should mirror."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model # BF16
pts = ai_sensitivity_curve(m, model, [0.5, 1.0, 4.0], axis="flops")
for p in pts:
assert p.b_star == pytest.approx(p.ai)
def test_ai_sensitivity_bad_axis_raises():
with pytest.raises(ValueError):
ai_sensitivity_curve(MachineParams(), PRESETS["Llama 3 8B"].model,
[1.0], axis="bogus")
# ── GPU sizing (three-axis) ────────────────────────────────────────
def test_capacity_floor_scales_with_model_size():
"""A model with 2× params needs ~2× PEs for weights alone (axis A)."""
m = MachineParams()
small = PRESETS["Llama 3 8B"].model
big = PRESETS["Llama 3 70B"].model
r_small = size_deployment(m, small, n_users=1, avg_ctx=1024,
tpot_slo_s=1.0)
r_big = size_deployment(m, big, n_users=1, avg_ctx=1024,
tpot_slo_s=1.0)
ratio = r_big.pes_axis_a_capacity / r_small.pes_axis_a_capacity
# 70B / 8B ≈ 8.75× params — allow generous tolerance
assert 5 < ratio < 12, (ratio, r_small, r_big)
def test_kv_headroom_scales_with_users_and_context():
"""Doubling n_users OR avg_ctx grows KV load roughly linearly."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
base = size_deployment(m, model, n_users=64, avg_ctx=4096,
tpot_slo_s=1.0)
twice_users = size_deployment(m, model, n_users=128, avg_ctx=4096,
tpot_slo_s=1.0)
twice_ctx = size_deployment(m, model, n_users=64, avg_ctx=8192,
tpot_slo_s=1.0)
# KV bytes doubled either way → axis-B PE count must strictly grow.
assert twice_users.pes_axis_b_kv > base.pes_axis_b_kv
assert twice_ctx.pes_axis_b_kv > base.pes_axis_b_kv
def test_binding_axis_flips_with_workload():
"""Huge N_users + short ctx → throughput binds.
Huge ctx + few users → kv (or capacity) binds."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
big_users = size_deployment(m, model, n_users=10_000, avg_ctx=1024,
tpot_slo_s=0.05)
long_ctx = size_deployment(m, model, n_users=1, avg_ctx=500_000,
tpot_slo_s=1.0)
assert big_users.binding_axis == "throughput", big_users
assert long_ctx.binding_axis in ("kv", "capacity"), long_ctx
def test_max_batch_shrinks_with_shorter_slo():
"""Tighter SLO → smaller B fits."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
loose = max_batch_within_slo(m, model, s_kv=1024, tpot_slo_s=1.0)
tight = max_batch_within_slo(m, model, s_kv=1024, tpot_slo_s=0.05)
assert tight < loose, (tight, loose)
def test_max_batch_returns_zero_when_weight_time_exceeds_slo():
"""If weight-fetch alone > SLO, no batch fits."""
m = MachineParams()
model = PRESETS["Llama 3 70B"].model
# 70B in BF16 = 140 GB. Reading at 256 GB/s takes ~547 ms.
# SLO of 100 ms cannot be met.
assert max_batch_within_slo(m, model, s_kv=100,
tpot_slo_s=0.1) == 0
def test_total_pes_equals_per_replica_times_replicas():
"""Sanity: total_pes = pes_per_replica × n_replicas."""
m = MachineParams()
model = PRESETS["Llama 3 8B"].model
r = size_deployment(m, model, n_users=500, avg_ctx=4096,
tpot_slo_s=0.1)
assert r.total_pes == r.pes_per_replica * r.n_replicas
# ── App wiring: tab exists on the Streamlit app ────────────────────
def test_roofline_tab_registered_in_app():
"""The new 'Chip roofline & B*' tab is listed in st.tabs and gets a
corresponding `with tab_roofline:` block."""
from pathlib import Path
src = (Path(__file__).parent / "app.py").resolve().read_text(
encoding="utf-8"
)
assert src.count('"Chip roofline & B*"') == 1
assert "with tab_roofline:" in src
@@ -0,0 +1,101 @@
"""Tests for draw_pe_layout — CP rank locator visibility across TP tiers.
The PE-level view has always color-labeled CP ranks when they're packed
intra-cube (`cp_placement="pe"`). When TP >= 8, TP fills the cube and CP
gets pushed to cube-level, so the intra-cube color branch is skipped —
which used to silently drop the CP rank locator entirely. These tests
guard the fix: a `[CP rank 0 of N]` cube tag + `CP=0` per-PE label are
always present when CP > 1, regardless of placement.
"""
from __future__ import annotations
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from tests.analytical_visualization.model_config import (
FullConfig, MachineParams, TopologyConfig,
)
from tests.analytical_visualization.model_presets import PRESETS
from tests.analytical_visualization.pe_weight_layout import draw_pe_layout
def _cfg(cp: int, tp: int, cp_placement: str | None = None):
model = PRESETS["Llama 3 8B"].model
kwargs = dict(cp=cp, tp=tp, pp=1, s_kv=8192, mode="decode")
if cp_placement is not None:
kwargs["cp_placement"] = cp_placement
return FullConfig(
model=model, topo=TopologyConfig(**kwargs), machine=MachineParams(),
)
def _all_text(fig) -> str:
"""Concatenate all text artists (labels + title) into one blob."""
ax = fig.gca()
parts = [t.get_text() for t in ax.texts]
parts.append(ax.get_title())
return "\n".join(parts)
def test_high_tp_cp_gt_1_shows_rank_locator():
"""TP=8, CP=4 -> cp_placement forced to 'cube'; CP rank locator
must still appear in cube tag + per-PE header + figure title."""
cfg = _cfg(cp=4, tp=8)
assert cfg.topo.cp_placement == "cube" # sanity
fig = draw_pe_layout(cfg)
try:
text = _all_text(fig)
assert "CP rank 0 of 4" in text, text
assert "CP=0" in text, text
assert "CP=4" in text, text # figure title dim summary
finally:
plt.close(fig)
def test_intra_cube_cp_placement_still_shows_per_rank_labels():
"""cp_placement='pe' (small TP, CP>1) — the old branch already
colors + labels each CP rank. Regression check: the new tag does
NOT replace those, and each CP=0..CP-1 label is still emitted."""
cfg = _cfg(cp=4, tp=2) # 4*2=8 fits intra-cube
assert cfg.topo.cp_placement == "cube" # default, but auto/manual can differ
# Force pe placement to exercise the intra-cube branch.
cfg.topo.cp_placement = "pe"
fig = draw_pe_layout(cfg)
try:
text = _all_text(fig)
for r in range(4):
assert f"CP={r}" in text, (r, text)
# The 'CP rank 0 of N' tag is only for the cube-placement fallback.
assert "CP rank 0 of" not in text, text
finally:
plt.close(fig)
def test_cp_1_no_locator_added():
"""CP=1: nothing to locate; no CP tag or label should appear."""
cfg = _cfg(cp=1, tp=8)
fig = draw_pe_layout(cfg)
try:
text = _all_text(fig)
assert "CP rank" not in text, text
# The header 'CP=0' should not be added when CP=1.
# (Figure title mentions 'CP=1' — that's an OK dim summary,
# not a locator per-PE.)
assert " | CP=0" not in text, text
finally:
plt.close(fig)
def test_tp_16_still_shows_cp_rank_across_spilled_cubes():
"""TP=16 (spills to 2 cubes per CP group), CP=2 -> both cubes
for CP rank 0 must carry the '[CP rank 0 of 2]' tag."""
cfg = _cfg(cp=2, tp=16)
assert cfg.topo.cp_placement == "cube"
fig = draw_pe_layout(cfg)
try:
text = _all_text(fig)
assert text.count("CP rank 0 of 2") >= 2, text
assert "CP=0" in text, text
finally:
plt.close(fig)
@@ -0,0 +1,187 @@
"""Tests for stage_shapes: per-PE shape rows for attention + FFN stages."""
from __future__ import annotations
from tests.analytical_visualization.model_config import (
FullConfig, MachineParams, TopologyConfig,
)
from tests.analytical_visualization.model_presets import PRESETS
from tests.analytical_visualization.stage_shapes import (
attn_stage_shape_rows,
ffn_stage_shape_rows,
)
def _cfg(cp=2, tp=8, pp=1, b=1, mode="decode", s_kv=8192):
model = PRESETS["Llama 3 8B"].model
topo = TopologyConfig(cp=cp, tp=tp, pp=pp, b=b, s_kv=s_kv, mode=mode)
return FullConfig(model=model, topo=topo, machine=MachineParams())
# ── Attention shapes ────────────────────────────────────────────────
def test_attn_rows_have_required_columns():
"""Every attention row must define Stage, Op, Input, Weight, Output."""
rows = attn_stage_shape_rows(_cfg())
required = {"Stage", "Op", "Input (per PE)",
"Weight (per PE)", "Output (per PE)"}
assert rows, "empty attention shape rows"
for r in rows:
missing = required - r.keys()
assert not missing, f"row {r.get('Stage')} missing {missing}"
for k in required:
assert r[k], f"row {r['Stage']} col {k} is empty"
def test_attn_row_prefixes_present_in_order():
"""S1..S10 always appear; C1 only when prefill+CP>1; C2 when TP>1;
C3 when kv_shard_mode=split and TP>H_kv; S8 only when CP>1."""
cfg = _cfg(cp=2, tp=8, mode="decode")
prefixes = [r["Stage"] for r in attn_stage_shape_rows(cfg)]
# decode with CP=2, TP=8 (h_kv=8 for Llama 3 8B) → no C1, no C3; C2 yes; S8 yes.
assert "C1" not in prefixes, prefixes
assert "C3" not in prefixes, prefixes
assert "S8" in prefixes, prefixes
assert "C2" in prefixes, prefixes
# Ensure S1..S10 sequence appears in relative order.
s_order = [p for p in prefixes if p.startswith("S")]
assert s_order == sorted(s_order, key=lambda s: int(s[1:])), s_order
def test_attn_cp1_omits_s8_merge():
"""CP=1 → no online-softmax merge stage."""
cfg = _cfg(cp=1, tp=8, mode="decode")
prefixes = [r["Stage"] for r in attn_stage_shape_rows(cfg)]
assert "S8" not in prefixes
def test_attn_prefill_cp_gt_1_inserts_c1():
"""Prefill mode with CP>1 shows the CP-ring stage."""
cfg = _cfg(cp=4, tp=2, mode="prefill", s_kv=8192)
prefixes = [r["Stage"] for r in attn_stage_shape_rows(cfg)]
assert "C1" in prefixes
def test_attn_shape_reflects_batch():
"""B appears as the leading dim in each activation shape."""
for b in (1, 4, 32):
rows = attn_stage_shape_rows(_cfg(b=b))
s1 = next(r for r in rows if r["Stage"] == "S1")
assert s1["Input (per PE)"].startswith(f"({b},"), s1
assert s1["Output (per PE)"].startswith(f"({b},"), s1
def test_attn_s5_shape_uses_tq_and_slocal():
"""S5 (Q·K^T) shape must reference both T_q and S_local values."""
cfg = _cfg(cp=2, tp=8, mode="decode", s_kv=8192)
T_q = cfg.topo.T_q
S_local = cfg.topo.s_local
rows = attn_stage_shape_rows(cfg)
s5 = next(r for r in rows if r["Stage"] == "S5")
# Output shape (B, H_q/TP, T_q, S_local)
assert str(T_q) in s5["Output (per PE)"], s5
assert str(S_local) in s5["Output (per PE)"], s5
# ── FFN shapes ──────────────────────────────────────────────────────
def test_ffn_rows_have_required_columns():
"""Every FFN row must define Stage, Op, Input, Weight, Output."""
rows = ffn_stage_shape_rows(_cfg())
required = {"Stage", "Op", "Input (per PE)",
"Weight (per PE)", "Output (per PE)"}
assert rows, "empty ffn shape rows"
for r in rows:
missing = required - r.keys()
assert not missing, f"row {r.get('Stage')} missing {missing}"
def test_ffn_row_prefixes_f1_to_f5_always_present():
"""F1..F5 always emitted; CF1 only when FFN divisor > 1."""
rows = ffn_stage_shape_rows(_cfg(cp=2, tp=8))
prefixes = [r["Stage"] for r in rows]
for p in ("F1", "F2", "F3", "F4", "F5"):
assert p in prefixes, prefixes
def test_ffn_gate_up_output_matches_ffn_per_pe():
"""F2/F3 output must have ffn_per_pe as the last dim."""
cfg = _cfg(cp=2, tp=8)
ffn_pe = max(1, cfg.model.ffn_dim
// (cfg.ffn_shard_divisor * max(1, cfg.topo.ep)))
rows = ffn_stage_shape_rows(cfg)
f2 = next(r for r in rows if r["Stage"] == "F2")
assert str(ffn_pe) in f2["Output (per PE)"], (ffn_pe, f2)
# ── App wiring: consolidated shapes section lives on layout tab ───
def test_shapes_section_is_on_physical_layout_tab():
"""The 'All tensor shapes (per PE)' expander must live inside
tab_layout (Physical layout), not tab_stages. Guards the move so
the section doesn't accidentally end up back on the latency tab.
"""
from pathlib import Path
app_path = (
Path(__file__).parent / "app.py"
).resolve()
src = app_path.read_text(encoding="utf-8")
# Section appears exactly once.
assert src.count('"All tensor shapes (per PE)"') == 1, src.count(
'"All tensor shapes (per PE)"'
)
# And it sits inside tab_layout, before tab_memory begins.
section_pos = src.index('"All tensor shapes (per PE)"')
tab_memory_pos = src.index("with tab_memory:")
tab_stages_pos = src.index("with tab_stages:")
assert section_pos < tab_memory_pos, (
"shapes section must be inside tab_layout (before tab_memory)"
)
assert section_pos < tab_stages_pos, (
"shapes section must be inside tab_layout (before tab_stages)"
)
# The old per-stage shape rendering under tab_stages must be gone —
# no attn_stage_shape_rows / ffn_stage_shape_rows call may sit
# between 'with tab_stages:' and the next 'with tab_' block.
stages_block = src[tab_stages_pos:src.index("# ── TAB", tab_stages_pos + 1)]
assert "attn_stage_shape_rows(cfg)" not in stages_block, (
"per-stage shape table must not remain on Per-stage latency tab"
)
assert "ffn_stage_shape_rows(cfg)" not in stages_block, (
"per-stage FFN shape table must not remain on Per-stage latency tab"
)
def test_ttft_tpot_section_wired_on_layout_tab():
"""The 'Full-model latency (TTFT & TPOT)' subheader must live on
tab_layout, before tab_memory. Guard the section presence + placement."""
from pathlib import Path
src = (
Path(__file__).parent / "app.py"
).resolve().read_text(encoding="utf-8")
assert src.count('"Full-model latency (TTFT & TPOT)"') == 1
pos = src.index('"Full-model latency (TTFT & TPOT)"')
tab_memory_pos = src.index("with tab_memory:")
assert pos < tab_memory_pos, "TTFT section must live inside tab_layout"
def test_parallelism_rules_tab_registered():
"""The 'Parallelism rules' tab is wired into st.tabs and has a
corresponding `with tab_prules:` block. Guards the new tab existing."""
from pathlib import Path
src = (
Path(__file__).parent / "app.py"
).resolve().read_text(encoding="utf-8")
assert src.count('"Parallelism rules"') == 1
assert "with tab_prules:" in src
def test_tp_vs_cp_section_present():
"""Section 11 'TP vs CP: the shared-budget tradeoff' must appear."""
from pathlib import Path
src = (
Path(__file__).parent / "app.py"
).resolve().read_text(encoding="utf-8")
assert src.count("TP vs CP: the shared-budget tradeoff") == 1
+16 -1
View File
@@ -258,12 +258,27 @@ def _draw_one_sip(ax, cfg: FullConfig, sip_idx: int,
pe_w = (inner_w - (PE_COLS - 1) * pe_gap) / PE_COLS pe_w = (inner_w - (PE_COLS - 1) * pe_gap) / PE_COLS
pe_h = (inner_h - (PE_ROWS - 1) * pe_gap) / PE_ROWS pe_h = (inner_h - (PE_ROWS - 1) * pe_gap) / PE_ROWS
pes_used = cfg.topo.pes_per_cube_used if is_used else 0 pes_used = cfg.topo.pes_per_cube_used if is_used else 0
# When cp_placement=pe, multiple CP ranks live inside this cube's PEs
# (cp_rank = pe_id // tp). Color each PE by its cp_rank so all four
# groups are visible; otherwise fall back to the whole-cube pe_fill.
_cp_packed = is_used and cfg.topo.cp_placement == "pe" and cfg.topo.cp > 1
for pr in range(PE_ROWS): for pr in range(PE_ROWS):
for pc in range(PE_COLS): for pc in range(PE_COLS):
pe_id = pr * PE_COLS + pc pe_id = pr * PE_COLS + pc
px = x + pe_pad + pc * (pe_w + pe_gap) px = x + pe_pad + pc * (pe_w + pe_gap)
py = y + pe_pad + (PE_ROWS - 1 - pr) * (pe_h + pe_gap) py = y + pe_pad + (PE_ROWS - 1 - pr) * (pe_h + pe_gap)
fill = pe_fill if pe_id < pes_used else _INACTIVE_PE if pe_id >= pes_used:
fill = _INACTIVE_PE
elif _cp_packed:
_pe_cp_rank = pe_id // cfg.topo.tp
_pe_color, _pe_pe_fill = _cp_color(
info[0] if is_used else 0,
_pe_cp_rank,
cfg.topo.cp,
)
fill = _pe_pe_fill
else:
fill = pe_fill
pe_rect = patches.Rectangle( pe_rect = patches.Rectangle(
(px, py), pe_w, pe_h, (px, py), pe_w, pe_h,
facecolor=fill, edgecolor="#666", linewidth=0.3, facecolor=fill, edgecolor="#666", linewidth=0.3,