Files
kernbench2/tests/analytical_visualization/auto_explore.py
T
mukesh 91b63eb9f6 analytical-viz: parallelism sensitivity chart in Auto Explore tab
Adds compute_parallelism_sensitivity() + ParallelismSensitivityRow to
auto_explore.py. For a baseline ConfigScore (picked from the Pareto set),
sweeps each parallelism knob (CP, TP, PP, DP, EP) individually while
holding others fixed. Reports latency + memory-fit per value.

Sweep values start at 1 and step by 2 (multiples of 2 rather than only
powers of 2), giving finer granularity for the visual than the enumerator's
sparser set. CP goes up to 256 (per real-deployment scale), TP to 64,
PP to 32.

New UI panel in Auto Explore tab: 5 subplots (log/log), one per knob:
  - Solid line = latency where the config fits memory
  - Red X markers = infeasible (out of budget)
  - Dotted vertical line = baseline value
User picks which Pareto row is the "baseline" via a number input; the
sensitivity chart re-computes around it.

Behaviour caveat noted during verification: for large PP the FFN AR can
cross a SIP boundary (uses stage_latencies.py:730 sips_used tier
selection), producing a step-up in latency. This is the existing model's
choice, honestly reflected in the chart. Whether the FFN AR should span
only one PP stage's ranks (thus not cross SIPs) is a separate discussion
about stage_latencies.py.

Verified:
- 11 pytest tests pass (added 2 new for parallelism sensitivity)
- Smoke: at Llama 70B decode 128K with baseline CP=8/TP=16/PP=1/DP=1:
  * CP sweep: 4 fits, 8 = baseline optimum, 16+ overshoots memory
  * TP sweep: 8/16 fit, 16 = baseline optimum, 32 slower (spans SIPs)
  * PP sweep: 1 = baseline, 2+ slower due to sips_used tier drop for FFN AR
  * DP sweep: same shape as PP
  * EP sweep: monotone decreasing (bigger EP = smaller per-PE FFN)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 13:39:51 -07:00

418 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Auto-explore parallelism configuration space and rank by Pareto frontier.
Extends the existing ``autosuggest`` (memory-only, single winner) to the
full 9-knob search (CP, TP, PP, DP, kv_shard_mode, ffn_shard_scope,
tp_placement, cp_placement, cp_ring_variant) with four objectives:
- min total_latency_ns (single-request decode/prefill step)
- max throughput_tok_s (naive tokens/sec = 1 / latency for single-req)
- max efficiency_score (geo-mean of compute + BW utilization)
- min pes_used
Reuses ``stage_latencies`` and ``memory_layout`` as the physics; adds
enumeration + 4D Pareto sort on top.
Analytical model runs in ~microseconds per config, so the full sweep
(~5-10k feasible configs after pruning) completes in a few seconds.
"""
from __future__ import annotations
import math
from collections.abc import Iterator
from dataclasses import dataclass, field, replace
from .memory_layout import compute_memory
from .model_config import FullConfig, MachineParams, ModelConfig, TopologyConfig
from .stage_latencies import all_ffn_stages, all_stages
# ── Parallelism sensitivity sweep values (see compute_parallelism_sensitivity)
# Multiples of 2 (finer grid than powers of 2 alone), capped at values that
# are physically meaningful for the modelled topology.
_PARALLELISM_SWEEP_VALUES = {
"cp": (1, 2, 4, 6, 8, 10, 12, 14, 16, 24, 32, 48, 64, 96, 128, 192, 256),
"tp": (1, 2, 4, 6, 8, 10, 12, 14, 16, 24, 32, 48, 64),
"pp": (1, 2, 4, 6, 8, 10, 12, 14, 16, 20, 24, 32),
"dp": (1, 2, 4, 6, 8, 12, 16),
"ep": (1, 2, 4, 6, 8, 12, 16, 32),
}
# ── Search space ─────────────────────────────────────────────────────
_CP_OPTIONS = (1, 2, 4, 8, 16, 32, 64, 96)
_TP_OPTIONS = (1, 2, 4, 8, 16, 32)
_PP_OPTIONS = (1, 2, 4, 8, 16)
_DP_OPTIONS = (1, 2, 4)
_KV_SHARD_MODES = ("split", "replicate")
_FFN_SHARD_SCOPES = ("TP", "TP+CP", "TP+CP+DP")
_TP_PLACEMENTS = ("pe", "cube")
_CP_PLACEMENTS = ("cube", "pe")
_CP_RING_VARIANTS = ("kv", "qoml")
# ── Result types ─────────────────────────────────────────────────────
@dataclass
class ConfigScore:
"""A single (config, computed-metrics) tuple. All floats in SI units
(seconds, tokens/sec, dimensionless) unless suffixed otherwise."""
# ── Config (the 9 knobs) ─────
cp: int
tp: int
pp: int
dp: int
kv_shard_mode: str
ffn_shard_scope: str
tp_placement: str
cp_placement: str
cp_ring_variant: str
# ── Objectives ─────
total_latency_ns: float # ↓ minimize
throughput_tok_s: float # ↑ maximize
efficiency_score: float # ↑ maximize (0..1)
pes_used: int # ↓ minimize
# ── Info-only (not ranked on) ─────
hbm_utilization: float # bytes_used / budget (0..1+; may exceed 1 if over-budget)
weights_gb: float
kv_gb: float
transient_gb: float
sips_used: int
fits_memory: bool
placement_valid: bool
reason: str = ""
@property
def latency_us(self) -> float:
return self.total_latency_ns / 1e3
@property
def latency_ms(self) -> float:
return self.total_latency_ns / 1e6
def as_topology(self, s_kv: int, mode: str) -> TopologyConfig:
"""Reconstruct the TopologyConfig this score was computed for."""
return TopologyConfig(
cp=self.cp, tp=self.tp, pp=self.pp, dp=self.dp,
s_kv=s_kv, mode=mode,
kv_shard_mode=self.kv_shard_mode,
ffn_shard_scope=self.ffn_shard_scope,
tp_placement=self.tp_placement,
cp_placement=self.cp_placement,
cp_ring_variant=self.cp_ring_variant,
)
@dataclass
class AutoExploreResult:
model_name: str
s_kv: int
mode: str
total_enumerated: int # after basic domain pruning
total_feasible: int # after memory + placement checks
all_scores: list[ConfigScore] = field(default_factory=list) # every feasible config
pareto_scores: list[ConfigScore] = field(default_factory=list) # non-dominated set
# ── Enumeration + pruning ────────────────────────────────────────────
def enumerate_configs(
model: ModelConfig,
s_kv: int,
mode: str,
) -> Iterator[TopologyConfig]:
"""Yield every domain-valid TopologyConfig.
Domain rules (fast pruning; no memory check yet):
- PP ≤ model.layers (can't have more stages than layers)
- TP ≤ 4 × model.h_q (unrealistic head-dim splits above this)
- Skip ffn_shard_scope containing 'DP' when DP=1 (redundant with plain 'TP+CP')
- Skip cp_ring_variant='qoml' when CP=1 (no ring, variant is a no-op)
"""
for cp in _CP_OPTIONS:
for tp in _TP_OPTIONS:
if tp > 4 * model.h_q:
continue
for pp in _PP_OPTIONS:
if pp > model.layers:
continue
for dp in _DP_OPTIONS:
for kv_mode in _KV_SHARD_MODES:
for ffn_scope in _FFN_SHARD_SCOPES:
if "DP" in ffn_scope and dp == 1:
continue
for tp_place in _TP_PLACEMENTS:
for cp_place in _CP_PLACEMENTS:
for cp_ring in _CP_RING_VARIANTS:
if cp == 1 and cp_ring == "qoml":
continue
yield TopologyConfig(
cp=cp, tp=tp, pp=pp, dp=dp,
s_kv=s_kv, mode=mode,
kv_shard_mode=kv_mode,
ffn_shard_scope=ffn_scope,
tp_placement=tp_place,
cp_placement=cp_place,
cp_ring_variant=cp_ring,
)
# ── Scoring ──────────────────────────────────────────────────────────
def _sum_visible_latency(cfg: FullConfig) -> float:
"""Total single-request latency (seconds) across all model layers.
A single request traverses every layer sequentially, whether the layers
sit on one PP stage or are spread across many:
- PP=1 : one rank holds all L layers, latency = L × per_layer
- PP=K : K ranks each hold L/K layers, but request crosses all K
stages sequentially → still L × per_layer
PP therefore does NOT reduce single-request latency; it only improves
throughput under batching. This function is the single-request cost, so
we multiply by full model.layers regardless of PP.
"""
attn = sum(s.visible_s for s in all_stages(cfg))
ffn = sum(s.visible_s for s in all_ffn_stages(cfg))
per_layer = attn + ffn
return per_layer * cfg.model.layers
def _efficiency(cfg: FullConfig, latency_s: float) -> float:
"""Geo-mean of compute-util and BW-util. Range ~ (0, 1].
- compute_util = achieved_flops / (peak_flops × pes × latency)
- bw_util = achieved_bytes / (peak_bw × pes × latency)
"""
if latency_s <= 0:
return 0.0
attn = all_stages(cfg)
ffn = all_ffn_stages(cfg)
layers = math.ceil(cfg.model.layers / cfg.topo.pp)
total_flops = layers * sum(s.flops for s in attn + ffn)
total_bytes = layers * sum(s.mem_bytes for s in attn + ffn)
pes = cfg.topo.total_pes
peak_flops = cfg.machine.peak_flops * pes
peak_bw = cfg.machine.bw_hbm * pes
compute_util = total_flops / (peak_flops * latency_s) if peak_flops > 0 else 0.0
bw_util = total_bytes / (peak_bw * latency_s) if peak_bw > 0 else 0.0
compute_util = min(1.0, max(0.0, compute_util))
bw_util = min(1.0, max(0.0, bw_util))
# Geo-mean; if either is 0 the score is 0 (avoids overrewarding lopsided configs).
return math.sqrt(compute_util * bw_util)
def score_config(cfg: FullConfig) -> ConfigScore:
"""Compute all 4 objectives + info fields for one config.
Feasibility (memory + placement) is stored but does NOT gate scoring —
infeasible configs get returned with fits_memory=False so callers can
filter or display them.
"""
mem = compute_memory(cfg)
placement_ok = cfg.topo.placement_valid
latency_s = _sum_visible_latency(cfg)
throughput = 1.0 / latency_s if latency_s > 0 else 0.0
efficiency = _efficiency(cfg, latency_s) if latency_s > 0 else 0.0
fits = not mem.over_budget
reason = ""
if not fits:
reason = (f"weights+KV+transient ({mem.used_bytes/1e9:.2f} GB) "
f"exceeds per-PE budget ({mem.budget_bytes/1e9:.2f} GB)")
elif not placement_ok:
reason = (f"intra-cube demand ({cfg.topo.intra_cube_dims}) "
f"exceeds PEs/cube ({cfg.topo.pes_per_cube_hw})")
return ConfigScore(
cp=cfg.topo.cp, tp=cfg.topo.tp, pp=cfg.topo.pp, dp=cfg.topo.dp,
kv_shard_mode=cfg.topo.kv_shard_mode,
ffn_shard_scope=cfg.topo.ffn_shard_scope,
tp_placement=cfg.topo.tp_placement,
cp_placement=cfg.topo.cp_placement,
cp_ring_variant=cfg.topo.cp_ring_variant,
total_latency_ns=latency_s * 1e9,
throughput_tok_s=throughput,
efficiency_score=efficiency,
pes_used=cfg.topo.total_pes,
hbm_utilization=mem.used_bytes / mem.budget_bytes if mem.budget_bytes > 0 else 0.0,
weights_gb=mem.weights_bytes / 1e9,
kv_gb=mem.kv_cache_bytes / 1e9,
transient_gb=mem.transient_bytes / 1e9,
sips_used=cfg.topo.sips_used,
fits_memory=fits,
placement_valid=placement_ok,
reason=reason,
)
# ── Pareto sort ──────────────────────────────────────────────────────
def _dominates(a: ConfigScore, b: ConfigScore) -> bool:
"""True iff a is no worse than b on every axis AND strictly better on ≥ 1.
Axes (with direction) — 3D Pareto:
total_latency_ns ↓ (a ≤ b)
pes_used ↓ (a ≤ b) — proxy for cost / deployment size
efficiency_score ↑ (a ≥ b) — geo-mean of compute+BW utilization
Note: ``throughput_tok_s`` is deliberately NOT a Pareto axis. For a
single-request analysis, throughput = 1 / latency, so it's collinear
with latency and would collapse the frontier. It stays on ConfigScore
for display but doesn't participate in domination.
"""
no_worse = (
a.total_latency_ns <= b.total_latency_ns
and a.pes_used <= b.pes_used
and a.efficiency_score >= b.efficiency_score
)
if not no_worse:
return False
strictly_better = (
a.total_latency_ns < b.total_latency_ns
or a.pes_used < b.pes_used
or a.efficiency_score > b.efficiency_score
)
return strictly_better
def pareto_frontier(scores: list[ConfigScore]) -> list[ConfigScore]:
"""Extract non-dominated set on (latency↓, pe↓, throughput↑, efficiency↑).
Only feasible configs (fits_memory AND placement_valid) participate; the
non-feasible are excluded from the frontier (they can still be returned in
``all_scores`` for informational display).
O(N²) — fine for N up to ~50k with early exits.
"""
feasible = [s for s in scores if s.fits_memory and s.placement_valid]
frontier: list[ConfigScore] = []
for i, a in enumerate(feasible):
dominated = False
for j, b in enumerate(feasible):
if i == j:
continue
if _dominates(b, a):
dominated = True
break
if not dominated:
frontier.append(a)
return frontier
# ── Parallelism sensitivity ─────────────────────────────────────────
@dataclass
class ParallelismSensitivityRow:
"""One knob's sweep result: latency + fit at every tested value.
All *other* parallelism knobs (including HW) are held at their baseline
values, so this isolates the effect of just this one knob.
"""
knob: str # "cp" / "tp" / "pp" / "dp" / "ep"
values: list[int] # tested values (from _PARALLELISM_SWEEP_VALUES)
latencies_ns: list[float] # one per value; NaN when infeasible
fits_flags: list[bool] # per-value memory+placement feasibility
baseline_value: int # what the baseline config uses for this knob
baseline_latency_ns: float
def compute_parallelism_sensitivity(
baseline: ConfigScore,
model: ModelConfig,
machine: MachineParams,
s_kv: int,
mode: str,
) -> list[ParallelismSensitivityRow]:
"""For each parallelism knob (CP, TP, PP, DP, EP), sweep its values
holding the OTHER knobs fixed at the baseline. Reports latency + memory
fit per value.
Answers: 'if I only change this one knob, how does latency and memory
fit change?' — the complement to auto_hardware.compute_sensitivity
which does the same for hardware knobs.
"""
baseline_topo = baseline.as_topology(s_kv, mode)
rows: list[ParallelismSensitivityRow] = []
for knob, values in _PARALLELISM_SWEEP_VALUES.items():
baseline_val = getattr(baseline_topo, knob, 1)
# EP isn't stored on ConfigScore's as_topology output today (dp
# attribute exists but ep does not always). Fall back to 1.
latencies: list[float] = []
fits: list[bool] = []
for v in values:
# Skip nonsensical values that would violate hard bounds.
if knob == "pp" and v > model.layers:
latencies.append(float("nan"))
fits.append(False)
continue
if knob == "tp" and v > 4 * model.h_q:
latencies.append(float("nan"))
fits.append(False)
continue
# Build a variant TopologyConfig with just this one knob changed.
trial = replace(baseline_topo, **{knob: v})
cfg = FullConfig(model=model, topo=trial, machine=machine)
score = score_config(cfg)
latencies.append(score.total_latency_ns)
fits.append(score.fits_memory and score.placement_valid)
rows.append(ParallelismSensitivityRow(
knob=knob,
values=list(values),
latencies_ns=latencies,
fits_flags=fits,
baseline_value=int(baseline_val),
baseline_latency_ns=baseline.total_latency_ns,
))
return rows
# ── Top-level driver ─────────────────────────────────────────────────
def run_auto_explore(
model: ModelConfig,
machine: MachineParams,
s_kv: int,
mode: str = "decode",
) -> AutoExploreResult:
"""Enumerate all configs, score each, extract Pareto frontier.
Returns both the full ``all_scores`` list (for the table view) and the
``pareto_scores`` subset (for the scatter/highlight view). Both are sorted
by total_latency_ns ascending.
"""
all_scores: list[ConfigScore] = []
total_enumerated = 0
for topo in enumerate_configs(model, s_kv, mode):
total_enumerated += 1
cfg = FullConfig(model=model, topo=topo, machine=machine)
all_scores.append(score_config(cfg))
all_scores.sort(key=lambda s: s.total_latency_ns)
pareto = pareto_frontier(all_scores)
pareto.sort(key=lambda s: s.total_latency_ns)
feasible_count = sum(1 for s in all_scores if s.fits_memory and s.placement_valid)
return AutoExploreResult(
model_name=model.name,
s_kv=s_kv,
mode=mode,
total_enumerated=total_enumerated,
total_feasible=feasible_count,
all_scores=all_scores,
pareto_scores=pareto,
)