From f7f3c8b7460d75130c510949c6660edb8b013dc2 Mon Sep 17 00:00:00 2001 From: yassinsolim <108886216+yassinsolim@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:33:20 -0600 Subject: [PATCH 1/9] fix(hipdnn): mine_shapes excludes out-of-family dtypes and carries graph features An fp32 SDPA graph aborted the whole --graphs source with 'unknown dtype spelling'. Recognised out-of-family dtypes (fp32, fp64) are now recorded as excluded with a reason in .excluded.json and counted; an unrecognised spelling still fails. Operand UIDs are read from the node's inputs/outputs as hipDNN graphs bind them, so a bound sink is mined as use_sinks=true. Each graph shape carries _graph_features: the operand layouts (BSHD/BHSD from strides) and every bound feature the request has no field for (varlen seq_len, paging, attn_mask, sinks, dropout, stats). Features take part in shape identity, so a BHSD or varlen graph no longer merges into its dense twin. --- .../tests/test_mine_shapes.py | 170 +++++++++++++++ .../IngestorGenerator/tools/mine_shapes.py | 204 ++++++++++++++++-- 2 files changed, 354 insertions(+), 20 deletions(-) diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_mine_shapes.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_mine_shapes.py index 9808cd9543ef..28be6b1289cc 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_mine_shapes.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_mine_shapes.py @@ -725,3 +725,173 @@ def test_provenance_alone_never_makes_two_shapes_distinct(self, tmp_path): assert "1 duplicate shape(s) merged" in output suites = {p["suite"] for p in shapes[0]["_provenance_occurrences"]} assert suites == {"suite_a", "suite_b"}, "a merged duplicate lost its vote" + + +def _hipdnn_graph( + path: Path, + *, + dtype: str = "bfloat16", + layout: str = "BSHD", + inputs: dict | None = None, + attrs: dict | None = None, +) -> None: + """A graph in the shape dnn-benchmarking ships: operand UIDs under the node's + `inputs`/`outputs`, every tensor with strides, and the full attribute set.""" + b, s = 1, 512 + + def tensor(uid, name, heads): + dims = [b, heads, s, 128] + strides = ( + [s * heads * 128, 128, heads * 128, 1] + if layout == "BSHD" + else [heads * s * 128, s * 128, 128, 1] + ) + return { + "uid": uid, + "name": name, + "dims": dims, + "strides": strides, + "data_type": dtype, + } + + tensors = [ + tensor(1, "Q", 8), + tensor(2, "K", 2), + tensor(3, "V", 2), + tensor(4, "O", 8), + ] + node_inputs = { + "q_tensor_uid": 1, + "k_tensor_uid": 2, + "v_tensor_uid": 3, + "sink_token_tensor_uid": None, + "seq_len_q_tensor_uid": None, + "seq_len_kv_tensor_uid": None, + } + for key, uid in (inputs or {}).items(): + node_inputs[key] = uid + tensors.append({"uid": uid, "name": key, "dims": [1], "strides": [1]}) + node_attrs = { + "causal_mask": False, + "left_bound": -1, + "right_bound": 0, + "diagonal_alignment": "TOP_LEFT", + "dropout_probability": None, + "max_seq_len_kv": None, + "generate_stats": None, + "attn_scale_value": 0.088, + **(attrs or {}), + } + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "name": path.stem, + "tensors": tensors, + "nodes": [ + { + "type": "SdpaAttributes", + "inputs": node_inputs, + "outputs": {"o_tensor_uid": 4}, + "attributes": node_attrs, + } + ], + } + ) + ) + + +def _mine_graphs(tmp_path: Path) -> tuple[int, str, list, list]: + out = tmp_path / "shapes.json" + result = subprocess.run( + [ + sys.executable, + str(_MINE), + "--graphs", + str(tmp_path / "graphs"), + "--out", + str(out), + ], + capture_output=True, + text=True, + ) + shapes = json.loads(out.read_text()) if out.exists() else [] + excluded_path = tmp_path / "shapes.excluded.json" + excluded = json.loads(excluded_path.read_text()) if excluded_path.exists() else [] + return result.returncode, result.stdout + result.stderr, shapes, excluded + + +class TestOutOfFamilyDtypesAreExcludedNotFatal: + """dnn-benchmarking corpora routinely carry fp32 SDPA graphs. One of them must + not abort the whole source; it is recorded as excluded with its reason.""" + + def test_an_fp32_graph_is_excluded_and_the_rest_is_mined(self, tmp_path): + _hipdnn_graph(tmp_path / "graphs" / "bf16.json") + _hipdnn_graph(tmp_path / "graphs" / "fp32.json", dtype="float") + rc, output, shapes, excluded = _mine_graphs(tmp_path) + assert rc == 0, output + assert len(shapes) == 1 and shapes[0]["dtype"] == "bf16" + assert len(excluded) == 1, excluded + assert excluded[0]["path"].endswith("fp32.json") + assert "fp32" in excluded[0]["reason"] + assert "excluded : 1" in output + + def test_a_source_of_only_excluded_graphs_is_an_empty_corpus_not_a_failure( + self, tmp_path + ): + _hipdnn_graph(tmp_path / "graphs" / "fp32.json", dtype="float32") + rc, output, shapes, excluded = _mine_graphs(tmp_path) + assert rc == 0, output + assert shapes == [] and len(excluded) == 1 + + def test_an_unrecognised_dtype_is_still_refused(self, tmp_path): + """Exclusion is for recognised dtypes only; a spelling nobody mapped is + still malformed input.""" + _hipdnn_graph(tmp_path / "graphs" / "g.json", dtype="cheesecake") + rc, output, _, _ = _mine_graphs(tmp_path) + assert rc != 0 + assert "unknown dtype spelling" in output + + +class TestGraphFeaturesTheRequestCannotCarry: + """Sinks, varlen and layout decide servability. A mined shape that drops them + asks the dispatcher about a different, easier problem.""" + + def test_a_sink_bound_in_node_inputs_is_mined_as_use_sinks(self, tmp_path): + _hipdnn_graph( + tmp_path / "graphs" / "sink.json", inputs={"sink_token_tensor_uid": 9} + ) + rc, output, shapes, _ = _mine_graphs(tmp_path) + assert rc == 0, output + assert shapes[0]["use_sinks"] is True + assert shapes[0]["_graph_features"]["features"] == ["sink_token"] + + def test_varlen_seq_len_uids_are_carried_as_features(self, tmp_path): + _hipdnn_graph( + tmp_path / "graphs" / "varlen.json", + inputs={"seq_len_q_tensor_uid": 10, "seq_len_kv_tensor_uid": 11}, + attrs={"max_seq_len_kv": 512}, + ) + rc, output, shapes, _ = _mine_graphs(tmp_path) + assert rc == 0, output + assert shapes[0]["_graph_features"]["features"] == [ + "seq_len_kv", + "seq_len_q", + "max_seq_len_kv", + ] + + def test_a_dense_graph_carries_its_layout_and_no_features(self, tmp_path): + _hipdnn_graph(tmp_path / "graphs" / "dense.json") + rc, output, shapes, _ = _mine_graphs(tmp_path) + assert rc == 0, output + assert shapes[0]["_graph_features"] == {"layouts": ["BSHD"], "features": []} + + def test_a_bhsd_graph_does_not_merge_with_its_bshd_twin(self, tmp_path): + _hipdnn_graph(tmp_path / "graphs" / "bshd.json") + _hipdnn_graph(tmp_path / "graphs" / "bhsd.json", layout="BHSD") + rc, output, shapes, _ = _mine_graphs(tmp_path) + assert rc == 0, output + assert sorted(s["_graph_features"]["layouts"][0] for s in shapes) == [ + "BHSD", + "BSHD", + ] diff --git a/projects/hipdnn/tools/IngestorGenerator/tools/mine_shapes.py b/projects/hipdnn/tools/IngestorGenerator/tools/mine_shapes.py index 342fb7abef23..b2b02c565172 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tools/mine_shapes.py +++ b/projects/hipdnn/tools/IngestorGenerator/tools/mine_shapes.py @@ -49,7 +49,9 @@ } -def from_published_csv(path: Path, arch: str, include_windowed: bool) -> list[dict]: +def from_published_csv( + path: Path, arch: str, include_windowed: bool, excluded: list | None = None +) -> list[dict]: """Shapes from the kernel team's results CSV: the shape list already resolved, naming which kernel each published number refers to and carrying `priority`/`ticket_group`, a signal available nowhere else.""" @@ -77,6 +79,11 @@ def from_published_csv(path: Path, arch: str, include_windowed: bool) -> list[di f"FAIL: {path}: windowed CSV row requires a positive window_size width" ) window = int(raw_window) + try: + dtype = _normalise_dtype(row.get("dtype"), path, "bf16") + except OutOfFamilyDtype as exc: + _exclude(excluded, path, str(exc), shape_idx=row.get("shape_idx") or "") + continue shapes.append( { "batch": int(row["batch"]), @@ -86,7 +93,7 @@ def from_published_csv(path: Path, arch: str, include_windowed: bool) -> list[di "seqlen_k": int(row["seq_kv"]), "hdim_q": head_dim, "hdim_v": head_dim, - "dtype": _normalise_dtype(row.get("dtype"), path, "bf16"), + "dtype": dtype, "mask_type": mask_type, "sliding_window": window, "use_sinks": False, @@ -154,31 +161,131 @@ def _mask_from_attributes( "torch.float16": "fp16", } +#: Recognised spellings of dtypes outside the bf16/fp16 family this miner +#: emits requests for. A graph in one of them is a real request nobody here can +#: serve, so it is recorded as excluded with its reason and counted rather than +#: aborting the source; an unrecognised spelling is still refused. +_OUT_OF_FAMILY_DTYPES = { + "float": "fp32", + "float32": "fp32", + "fp32": "fp32", + "torch.float32": "fp32", + "double": "fp64", + "float64": "fp64", + "fp64": "fp64", + "torch.float64": "fp64", +} + + +class OutOfFamilyDtype(Exception): + """A recognised dtype this miner's request family does not cover.""" + + def __init__(self, dtype: str): + super().__init__(f"dtype {dtype} is outside the bf16/fp16 request family") + self.dtype = dtype + def _normalise_dtype(raw, path: Path, fallback: str) -> str: """One spelling for a dtype, or a refusal naming the source. - An absent dtype falls back; an unrecognised one is a mapping this table - owes, since a guessed dtype builds a different binary and still validates. + An absent dtype falls back; a recognised out-of-family one raises + `OutOfFamilyDtype` for the reader to record as an exclusion; an + unrecognised one is a mapping this table owes, since a guessed dtype builds + a different binary and still validates. """ if raw is None or str(raw).strip() == "": return fallback spelling = str(raw).strip().lower() resolved = _DTYPE_SPELLINGS.get(spelling) if resolved is None: + if spelling in _OUT_OF_FAMILY_DTYPES: + raise OutOfFamilyDtype(_OUT_OF_FAMILY_DTYPES[spelling]) raise SystemExit( f"FAIL: unknown dtype spelling {raw!r} in {path}. Add it to " - f"_DTYPE_SPELLINGS rather than defaulting -- a guessed dtype builds the " - f"wrong binary and still validates." + f"_DTYPE_SPELLINGS (or _OUT_OF_FAMILY_DTYPES) rather than defaulting " + f"-- a guessed dtype builds the wrong binary and still validates." ) return resolved -def from_graph_corpus(root: Path) -> list[dict]: +def _exclude(excluded: list | None, path: Path, reason: str, **extra) -> None: + """Record one excluded input with its reason; the caller skips it.""" + if excluded is not None: + excluded.append({"path": str(path), "reason": reason, **extra}) + + +#: Operand bindings that ARE the problem: Q/K/V/O. Every other bound tensor +#: (varlen seq_len, page tables, attn_mask, dropout, fp8 scales, stats +#: outputs, and the sink tensor) is a graph feature listed in +#: `_graph_features`. The sink is also carried as `use_sinks`, a request field; +#: listing it here too lets an engine whose graph_match declines sinks say so +#: in its graph contract even when the dispatcher would serve them. +_REQUEST_BINDINGS = frozenset(("q", "k", "v", "o")) + +#: Attribute-level features no request field carries, each with the predicate +#: that makes it active. +_ATTRIBUTE_FEATURES = { + "dropout_probability": lambda v: v is not None, + "max_seq_len_kv": lambda v: v is not None, + "generate_stats": lambda v: v is True, + "alibi_mask": lambda v: v is True, + "padding_mask": lambda v: v is True, +} + +#: Logical BHSD dims -> expected strides per memory layout. +_LAYOUT_STRIDES = { + "BSHD": lambda b, h, s, d: [s * h * d, d, h * d, 1], + "BHSD": lambda b, h, s, d: [h * s * d, s * d, d, 1], +} + + +def _layouts(operands: list[dict]) -> list[str] | None: + """Every named layout ALL operands are dense in, or None when a tensor + carries no strides. A unit-extent axis matches any stride (its index is + always 0), the rule the native matchers use, so a single-head tensor is + both BSHD and BHSD. An empty list means strided in no named layout.""" + matched = set(_LAYOUT_STRIDES) + for tensor in operands: + dims, strides = tensor.get("dims"), tensor.get("strides") + if not isinstance(strides, list) or len(strides) != 4: + return None + for name, expected in _LAYOUT_STRIDES.items(): + want = expected(*dims) + if any(dims[i] != 1 and strides[i] != want[i] for i in range(4)): + matched.discard(name) + return sorted(matched) + + +def _graph_features(bindings: dict, attrs: dict, operands: list[dict]) -> dict: + """What decides servability but has no request field: the layout the + operands are strided in, and every bound feature beyond the request.""" + features = sorted( + key[: -len("_tensor_uid")] + for key, uid in bindings.items() + if key.endswith("_tensor_uid") + and uid is not None + and key[: -len("_tensor_uid")] not in _REQUEST_BINDINGS + ) + features += sorted( + name for name, active in _ATTRIBUTE_FEATURES.items() if active(attrs.get(name)) + ) + layouts = _layouts(operands) + out = {"layouts": layouts, "features": features} + if layouts == []: + # Strided in no named layout: the strides themselves are the identity. + out["strides"] = [t.get("strides") for t in operands] + return out + + +def from_graph_corpus(root: Path, excluded: list | None = None) -> list[dict]: """Shapes from a dnn-benchmarking graph tree, one JSON per graph. The suite name is kept because it is the axis a result must be split along: a single geomean over model traces and parameter sweeps reports the synthetic - population's win as everyone's.""" + population's win as everyone's. + + Each shape also carries `_graph_features` (layout, varlen, paging and every + other bound feature AttentionRequest has no field for), so a dispatcher + answer about the bare request is not read as the graph being servable.""" shapes = [] for path in sorted(root.rglob("*.json")): try: @@ -201,6 +308,7 @@ def from_graph_corpus(root: Path) -> list[dict]: n for n in graph.get("nodes", []) if n.get("type") == "SdpaAttributes" + or "q_tensor_uid" in (n.get("inputs") or {}) or "q_tensor_uid" in (n.get("attributes") or {}) ] if len(sdpa) > 1: @@ -219,13 +327,22 @@ def from_graph_corpus(root: Path) -> list[dict]: {}, ) ) + # hipDNN graphs bind operands under the node's `inputs`/`outputs`; an + # older spelling put the UIDs among the attributes. Read both, so a + # sink bound in `inputs` is not mined as use_sinks=False. + node = sdpa[0] if sdpa else {} + bindings = { + **attrs, + **(node.get("inputs") or {}), + **(node.get("outputs") or {}), + } by_uid = {t["uid"]: t for t in graph.get("tensors", []) if "uid" in t} selected = [] for short, long in (("q", "query"), ("k", "key"), ("v", "value")): uid_key = f"{short}_tensor_uid" tensor = ( - by_uid.get(attrs[uid_key]) - if uid_key in attrs + by_uid.get(bindings[uid_key]) + if bindings.get(uid_key) is not None else (tensors.get(long) or tensors.get(short)) ) selected.append(tensor) @@ -249,13 +366,21 @@ def from_graph_corpus(root: Path) -> list[dict]: raise SystemExit( f"FAIL: {path}: incompatible independent Q/K/V dimensions {dimensions}" ) - dtypes = [_normalise_dtype(t.get("data_type"), path, "bf16") for t in selected] + try: + dtypes = [ + _normalise_dtype(t.get("data_type"), path, "bf16") for t in selected + ] + except OutOfFamilyDtype as exc: + _exclude(excluded, path, str(exc), graph=graph.get("name", path.stem)) + continue if len(set(dtypes)) != 1: raise SystemExit( f"FAIL: {path}: mixed Q/K/V dtypes cannot form one request" ) mask = _mask_from_attributes(attrs, path, qdims[2], kdims[2]) - sink_uid = attrs.get("sink_token_tensor_uid") + sink_uid = bindings.get("sink_token_tensor_uid") + output = by_uid.get(bindings.get("o_tensor_uid")) + operands = selected + ([output] if output else []) if sink_uid is not None and sink_uid not in by_uid: raise SystemExit( f"FAIL: {path}: sink_token_tensor_uid names a missing tensor" @@ -272,6 +397,7 @@ def from_graph_corpus(root: Path) -> list[dict]: "dtype": dtypes[0], **mask, "use_sinks": sink_uid is not None, + "_graph_features": _graph_features(bindings, attrs, operands), "_provenance": { "source": "graphs", "suite": str(path.parent.name), @@ -298,7 +424,9 @@ def _bench_graph_name(shape: dict) -> str: return "rocke_bench__" + hashlib.sha256(_shape_key(shape).encode()).hexdigest() -def from_rocke_bench(root: Path, dtype_default: str) -> list[dict]: +def from_rocke_bench( + root: Path, dtype_default: str, excluded: list | None = None +) -> list[dict]: """Shapes from rocKE's own benchmark tree; for an arch with no published CSV it is the only source saying what the kernel team measures. @@ -370,7 +498,11 @@ def from_rocke_bench(root: Path, dtype_default: str) -> list[dict]: continue # `q_dtype` is a torch spelling ("torch.bfloat16"), normalised # through the same table the graph corpus uses. - dtype = _normalise_dtype(record.get("q_dtype"), path, dtype_default) + try: + dtype = _normalise_dtype(record.get("q_dtype"), path, dtype_default) + except OutOfFamilyDtype as exc: + _exclude(excluded, path, str(exc), trace=path.stem) + continue shapes.append( { "batch": int(record.get("num_seqs") or 1), @@ -413,11 +545,15 @@ def from_rocke_bench(root: Path, dtype_default: str) -> list[dict]: def _shape_key(shape: dict) -> str: - """All request semantics participate; provenance never does.""" + """All request semantics participate; provenance never does. Graph + features do: a BHSD or varlen graph with a BSHD dense twin is a different + servability question, and merging them keeps only the first answer.""" fields = {"sliding_window": 0, "use_sinks": False} fields.update( {key: value for key, value in shape.items() if not key.startswith("_")} ) + if "_graph_features" in shape: + fields["_graph_features"] = shape["_graph_features"] return json.dumps(fields, sort_keys=True, separators=(",", ":"), allow_nan=False) @@ -465,6 +601,11 @@ def main(argv=None) -> int: "but they are excluded LOUDLY here rather than folded onto causal.", ) parser.add_argument("--out", required=True, help="Write the shape corpus here.") + parser.add_argument( + "--excluded-out", + help="Write the excluded inputs (out-of-family dtypes), one record each " + "with its path and reason. Default: .excluded.json next to --out.", + ) args = parser.parse_args(argv) if not args.published and not args.graphs and not args.rocke_bench: @@ -476,26 +617,49 @@ def main(argv=None) -> int: ) shapes: list[dict] = [] + excluded: list[dict] = [] if args.published: found = from_published_csv( - Path(args.published), args.arch, args.include_windowed + Path(args.published), args.arch, args.include_windowed, excluded ) print(f" published CSV : {len(found):5d} rows for {args.arch}") shapes += found if args.graphs: - found = from_graph_corpus(Path(args.graphs)) + found = from_graph_corpus(Path(args.graphs), excluded) print(f" graph corpus : {len(found):5d} forward graphs") shapes += found if args.rocke_bench: - found = from_rocke_bench(Path(args.rocke_bench), "bf16") + found = from_rocke_bench(Path(args.rocke_bench), "bf16", excluded) print(f" rocKE bench : {len(found):5d} trace records") shapes += found + out = Path(args.out) + excluded_out = ( + Path(args.excluded_out) + if args.excluded_out + else out.with_name(f"{out.stem}.excluded.json") + ) + reasons: dict = {} + for record in excluded: + reasons[record["reason"]] = reasons.get(record["reason"], 0) + 1 + print(f" excluded : {len(excluded):5d} {reasons or ''}") + excluded_out.write_text(json.dumps(excluded, indent=2)) + unique, duplicates = deduplicate(shapes) print( f" distinct : {len(unique):5d} ({duplicates} duplicate shape(s) merged)" ) + if not unique and excluded: + # Everything was excluded with a reason: an accounted-for empty source, + # which downstream tools take as such, not a mining failure. + out.write_text("[]") + print( + f"\n WARNING: every input was excluded; wrote an empty {out} and " + f"{excluded_out}", + file=sys.stderr, + ) + return 0 if not unique: print( "\nFAIL: no shapes mined; nothing downstream can use this.", file=sys.stderr @@ -508,8 +672,8 @@ def main(argv=None) -> int: by_source[shape["_provenance"]["source"]] += 1 print(f" by source : {by_source}") - Path(args.out).write_text(json.dumps(unique, indent=2)) - print(f"\n wrote {args.out}") + out.write_text(json.dumps(unique, indent=2)) + print(f"\n wrote {args.out} (exclusions: {excluded_out})") print( " Provenance is carried on every shape. Split every reported result by it: a " "geomean over a mixed corpus reports the synthetic population's win as if it " From b7d43b34afa2a64b1018605cdafbb2c3f4d04ade Mon Sep 17 00:00:00 2001 From: yassinsolim <108886216+yassinsolim@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:33:21 -0600 Subject: [PATCH 2/9] fix(hipdnn): dispatch_parity and reconcile follow the dispatcher's decline boundary A ValueError from the spec factory is the dispatcher's own decline: the gfx942 and gfx950 dense candidates' support() return it as (False, msg). dispatch_parity now reports it as [refused] under --report-gaps instead of aborting the corpus; request construction failures and any other factory exception stay operational (exit 2). Mined graphs outside the profile's new graph_contract (layouts, admitted features) are reported as [out_of_contract], since the request cannot carry them. reconcile_applicability skips them in the comparison, accepts a merged multi-source corpus (a reused graph name only fails when a --declines key uses it; provenance paths are accepted as keys) and warns on an empty source instead of failing. dispatch_parity canonicalises runtime-shape fields by default when the profile maps runtime_param_fields to canonical values: fields the spec's own runtime_param_fields lists are set to those values and identical specs collapse into one catalog entry. --per-shape keeps the old one-kernel-per-shape output. The gfx950 profile now uses the mapping form (B1/Sq512/Skv512) and declares graph_contract {layouts: [BSHD], features: []}. --- .../gfx950_attention_dense.profile.yaml | 26 +- .../tests/test_dispatch_parity.py | 182 ++++++++++++- .../tests/test_reconcile_applicability.py | 99 ++++++- .../tools/dispatch_parity.py | 246 ++++++++++++++++-- .../tools/reconcile_applicability.py | 81 ++++-- 5 files changed, 570 insertions(+), 64 deletions(-) diff --git a/projects/hipdnn/tools/IngestorGenerator/configs/gfx950_attention_dense.profile.yaml b/projects/hipdnn/tools/IngestorGenerator/configs/gfx950_attention_dense.profile.yaml index 87a30fc75782..7bcc0c0459a2 100644 --- a/projects/hipdnn/tools/IngestorGenerator/configs/gfx950_attention_dense.profile.yaml +++ b/projects/hipdnn/tools/IngestorGenerator/configs/gfx950_attention_dense.profile.yaml @@ -204,14 +204,26 @@ sweep: Moves with block_m; same tile constraints as block_m. BN256 is legal only with block_m=256 at D=64. -# Fields the aligned kernel reads only from kernel arguments: every entry is -# built at the canonical B1/Sq512/Skv512 and reused for any runtime shape its -# tile divides. Documentation only: no generator tool reads this block, and -# the rocKE spec's own runtime_param_fields decides per mode. +# Fields the aligned kernel reads only from kernel arguments, mapped to the +# canonical value every catalog entry is built at (B1/Sq512/Skv512); one entry +# is reused for any runtime shape its tile divides. dispatch_parity sets each +# field the resolved spec's OWN runtime_param_fields lists to this value and +# collapses identical specs into one entry (--per-shape turns this off). The +# spec decides per mode: ragged, windowed and persistent specs declare none, +# so they keep their real values. runtime_param_fields: - - batch - - seqlen_q - - seqlen_kv + batch: 1 + seqlen_q: 512 + seqlen_kv: 512 + +# Graph features the engine's graph_match admits beyond the request fields +# (Gfx950AttentionDenseNative.cpp): dense BSHD operands only, and none of the +# optional bindings (varlen seq_len, paging, attn_mask, dropout, sinks, fp8 +# scales, stats). dispatch_parity reports a mined graph outside this as +# [out_of_contract] instead of asking the dispatcher about the bare request. +graph_contract: + layouts: [BSHD] + features: [] graph_match: shape: shared_shape diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py index d3592230a6c8..1ecdde04e2ce 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py @@ -66,9 +66,19 @@ def resolve_but_raise(request): """A dispatcher that fails operationally once the per-shape loop calls it. The message names the request it was handed, so a test can tell "the factory - ran and threw" apart from "the factory was never reached". + ran and threw" apart from "the factory was never reached". Not a ValueError: + that is the dispatcher's shape-refusal path, which is a decline. """ - raise ValueError(f"dispatcher exploded on seqlen_q {request.seqlen_q}") + raise RuntimeError(f"dispatcher exploded on seqlen_q {request.seqlen_q}") + + +def resolve_refusing(request): + """A spec factory that refuses one shape the way rocKE specs do: ValueError + from construction, which the dispatcher's own candidate reports as a + decline.""" + if request.head_size == 192: + raise ValueError(f"head_size must be 64 or 128, got {request.head_size}") + return resolve(request) def supports(spec, arch=None): @@ -104,7 +114,8 @@ def parity(tmp_path, monkeypatch): profile_path = tmp_path / "profile.json" profile_path.write_text(json.dumps(profile)) - def argv(shapes: list, *extra: str) -> list: + def argv(shapes: list, *extra: str, **profile_overrides) -> list: + profile_path.write_text(json.dumps({**profile, **profile_overrides})) shapes_path = tmp_path / "shapes.json" shapes_path.write_text(json.dumps(shapes)) return [ @@ -180,10 +191,9 @@ def test_an_unhydratable_shape_exits_2_naming_the_failure(self, parity, capsys): ) def test_a_dispatcher_that_raises_also_exits_2(self, parity, capsys, monkeypatch): - """The factory is inside the same try as the request constructor, so a - dispatcher that raises is operational, never a decline. The dispatcher's own - message is asserted so an exit 2 raised while resolving the symbol does not - pass.""" + """A non-ValueError from the factory is operational, never a decline. The + dispatcher's own message is asserted so an exit 2 raised while resolving the + symbol does not pass.""" shapes = [{"seqlen_q": 256}] argv = parity(shapes) real_resolve_shapes = dispatch_parity.resolve_shapes @@ -215,6 +225,79 @@ def test_a_predicate_decline_is_not_promoted_to_an_abort(self, parity, capsys): assert "no shape resolved" in capsys.readouterr().err +class TestASpecFactoryRefusalIsADecline: + """rocKE specs refuse unsupported shapes by raising ValueError, and the + dispatcher's own candidate `support()` reports that as a decline. One such + shape in a real corpus must not abort the whole run.""" + + _REFUSING = { + "dispatch": {"module": "stub_provider", "function": "resolve_refusing"} + } + + def test_a_refused_shape_is_reported_and_the_rest_resolves(self, parity, capsys): + shapes = [{"seqlen_q": 256}, {"seqlen_q": 256, "head_size": 192}] + argv = parity(shapes, "--report-gaps", **self._REFUSING) + assert dispatch_parity.main(argv) == 0, capsys.readouterr().err + out = capsys.readouterr().out + assert "servable 1" in out + assert "refused 1" in out + assert "[refused]" in out + assert "head_size must be 64 or 128, got 192" in out + + def test_a_corpus_of_only_refusals_exits_1_not_2(self, parity, capsys): + """Every shape refused is a corpus nothing serves, not an operational + failure.""" + argv = parity([{"seqlen_q": 256, "head_size": 192}], **self._REFUSING) + assert dispatch_parity.main(argv) == 1 + assert "no shape resolved" in capsys.readouterr().err + + +class TestGraphFeaturesOutsideTheContractAreNotServed: + """A mined graph's varlen/layout features have no request field, so the + dispatcher's yes for the bare request does not make the graph servable.""" + + def test_a_bound_feature_the_contract_does_not_admit_is_out_of_contract( + self, parity, capsys + ): + shapes = [ + {"seqlen_q": 256, "_graph_features": {"layouts": ["BSHD"], "features": []}}, + { + "seqlen_q": 256, + "_graph_features": { + "layouts": ["BSHD"], + "features": ["seq_len_kv", "seq_len_q"], + }, + }, + ] + assert dispatch_parity.main(parity(shapes, "--report-gaps")) == 0 + out = capsys.readouterr().out + assert "servable 1" in out + assert "out of contract 1" in out + assert "[out_of_contract]" in out and "seq_len_kv, seq_len_q" in out + + def test_a_layout_outside_the_contract_is_out_of_contract(self, parity, capsys): + shapes = [ + {"seqlen_q": 256, "_graph_features": {"layouts": ["BHSD"], "features": []}}, + { + "seqlen_q": 512, + "_graph_features": {"layouts": ["BHSD", "BSHD"], "features": []}, + }, + ] + argv = parity(shapes, "--report-gaps", graph_contract={"layouts": ["BSHD"]}) + assert dispatch_parity.main(argv) == 0 + out = capsys.readouterr().out + assert "servable 1" in out, "a single-head tensor is also BSHD" + assert "operand layout BHSD is not in graph_contract.layouts" in out + + def test_a_contract_feature_admits_the_graph(self, parity, capsys): + shapes = [ + {"seqlen_q": 256, "_graph_features": {"layouts": [], "features": ["x"]}} + ] + argv = parity(shapes, graph_contract={"features": ["x"]}) + assert dispatch_parity.main(argv) == 0 + assert "servable 1" in capsys.readouterr().out + + #: The relative root the shipped profile names. Spelled out so the decoy below can #: reproduce it exactly: under a cwd-relative resolution the decoy is what binds. _REPO_RELATIVE_ROOT = "dnn-providers/hip-kernel-provider" @@ -440,3 +523,88 @@ def test_the_unpinned_control_does_resolve_the_persistent_arm(self, monkeypatch) monkeypatch, dense_persistent="auto" ) assert spec.persistent is True, spec + + +def _real_dispatcher_or_skip(monkeypatch) -> dict: + """The shipped gfx950 profile, bound, or a skip when rocKE cannot import.""" + import importlib + + profile = dispatch_parity._load_profile(str(_SHIPPED_PROFILE)) + monkeypatch.setattr(sys, "path", list(sys.path)) + dispatch_parity._bind_provider(profile["provider_root"]) + try: + importlib.import_module(profile["dispatch"]["module"]) + except ImportError as exc: + pytest.skip(f"the rocKE library cannot be imported here ({exc})") + return profile + + +_D128 = dict(nhead_q=8, nhead_k=8, hdim_q=128, hdim_v=128, dtype="bf16", mask_type=1) + + +class TestTheRealDispatcherBoundary: + """On the shipped gfx950 profile: the spec's own ValueError refusal is a decline + (what `_make_gfx950_attention_dense_candidate().support` returns), and the + runtime-shape fields collapse onto the canonical B1/Sq512/Skv512 entry.""" + + def _run(self, monkeypatch, tmp_path, shapes, *extra): + _real_dispatcher_or_skip(monkeypatch) + path = tmp_path / "shapes.json" + path.write_text(json.dumps(shapes)) + argv = ["--profile", str(_SHIPPED_PROFILE), "--shapes", str(path), *extra] + return dispatch_parity.main(argv) + + def test_head_size_192_is_a_refusal_not_an_abort( + self, monkeypatch, tmp_path, capsys + ): + shapes = [ + {**_D128, "batch": 1, "seqlen_q": 512, "seqlen_k": 512}, + { + **_D128, + "batch": 1, + "seqlen_q": 512, + "seqlen_k": 512, + "hdim_q": 192, + "hdim_v": 192, + }, + ] + assert self._run(monkeypatch, tmp_path, shapes, "--report-gaps") == 0 + out = capsys.readouterr().out + assert "[refused]" in out and "head_size must be 64 or 128, got 192" in out + + _RUNTIME_ONLY = [ + {**_D128, "batch": 1, "seqlen_q": 512, "seqlen_k": 512}, + {**_D128, "batch": 2, "seqlen_q": 1024, "seqlen_k": 1024}, + {**_D128, "batch": 4, "seqlen_q": 4096, "seqlen_k": 4096}, + # Ragged self-attention bakes its length: kept at its own values. + {**_D128, "batch": 1, "seqlen_q": 300, "seqlen_k": 300}, + ] + + def test_shapes_differing_only_in_runtime_fields_are_one_entry( + self, monkeypatch, tmp_path, capsys + ): + out = tmp_path / "config.yaml" + rc = self._run(monkeypatch, tmp_path, self._RUNTIME_ONLY, "--out", str(out)) + assert rc == 0 + printed = capsys.readouterr().out + assert "2 kernels for 4 servable shapes" in printed, printed + assert "spec bakes its shape" in printed + + profile = dispatch_parity._load_profile(str(_SHIPPED_PROFILE)) + catalog, _ = dispatch_parity.canonicalise( + dispatch_parity.resolve_shapes(self._RUNTIME_ONLY, profile), profile + ) + kernels = dispatch_parity.build_config(catalog, profile)["packs"][0]["kernels"] + shapes = sorted( + (k["metadata"]["batch"], k["metadata"]["seqlen_q"], k["metadata"]["ragged"]) + for k in kernels + ) + assert shapes == [(1, 300, 1), (1, 512, 0)], shapes + + def test_per_shape_keeps_one_kernel_per_shape(self, monkeypatch, tmp_path, capsys): + out = tmp_path / "config.yaml" + rc = self._run( + monkeypatch, tmp_path, self._RUNTIME_ONLY, "--out", str(out), "--per-shape" + ) + assert rc == 0 + assert "4 kernels for 4 servable shapes" in capsys.readouterr().out diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_reconcile_applicability.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_reconcile_applicability.py index a54c57a99035..bee6820ddb0a 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_reconcile_applicability.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_reconcile_applicability.py @@ -79,6 +79,9 @@ def _supports(self, req): capability block forbids.""" if self._opt_in and getattr(req, "algorithm", None) != self.algorithm: return False, f"{self.algorithm} is opt-in" + if int(req.seqlen_q) == 300: + # The candidate's support() catching kernel_spec's ValueError. + return False, "seqlen_q must be a multiple of block_m=256, got 300" if int(req.seqlen_q) < self._min: return False, f"seqlen_q must be at least {self._min}" return True, "" @@ -101,7 +104,10 @@ def candidates(): def kernel_spec(req): - """Construct a spec; support is decided by the explicit predicate.""" + """Construct a spec; support is decided by the explicit predicate. Like rocKE + specs, construction refuses a shape it cannot build with ValueError.""" + if int(req.seqlen_q) == 300: + raise ValueError("seqlen_q must be a multiple of block_m=256, got 300") return Spec(batch=int(req.batch), seqlen_q=int(req.seqlen_q), head_size=int(req.head_size)) @@ -675,9 +681,6 @@ def test_a_broken_candidate_registry_is_operational_under_every_flag( @pytest.mark.parametrize( "raised", [ - pytest.param( - "ValueError('seqlen_q must be a multiple of 256')", id="ValueError" - ), pytest.param( "TypeError('spec factory got an unexpected field')", id="TypeError" ), @@ -687,8 +690,10 @@ def test_a_broken_candidate_registry_is_operational_under_every_flag( def test_a_failing_spec_factory_is_operational_under_every_flag( env, tmp_path, flags, raised ): - """A factory that RAISES has failed to answer, not declined. The ValueError row - matters most: a validation error reads exactly like a support decision.""" + """A factory that RAISES anything but ValueError has failed to answer, not + declined. ValueError is the dispatcher's own shape-refusal path (its + candidate `support()` returns it as a decline), so it is reconciled like any + decline: see TestARealCorpusReconcilesInOneRun.""" (tmp_path / "rocke" / "library" / "badfab.py").write_text( "import stublib\n" f"def kernel_spec(req): raise {raised}\n" @@ -750,3 +755,85 @@ def test_a_broken_reference_translator_is_operational_under_every_flag( result = env.run(path, env.shapes(_LONG), *flags) assert result.returncode == 2, result.stdout + result.stderr assert expected_absent not in result.stdout + + +_RAGGED = {"batch": 1, "seqlen_q": 300, "head_size": 128} + + +def _named(shape: dict, graph: str, path: str) -> dict: + provenance = {"source": "graphs", "graph": graph, "path": path} + return {**shape, "_provenance": provenance, "_provenance_occurrences": [provenance]} + + +class TestARealCorpusReconcilesInOneRun: + """942:S2-7: a factory refusal aborted the run, a merged multi-source corpus + was refused for reusing a graph name, and an empty source was an error.""" + + def test_a_spec_factory_refusal_is_a_decline_both_sides_share(self, env): + result = env.run(env.profile(), env.shapes(_LONG, _RAGGED)) + assert result.returncode == 0, result.stdout + result.stderr + assert "both serve 1" in result.stdout + assert "both decline 1" in result.stdout + assert "multiple of block_m=256, got 300" in result.stdout + + def test_a_graph_name_reused_across_sources_is_not_an_error(self, env): + shapes = env.shapes( + _named(_LONG, "gqa_case", "corpus/aiter/gqa_case.json"), + _named({**_LONG, "batch": 2}, "gqa_case", "corpus/aotriton/gqa_case.json"), + ) + result = env.run(env.profile(), shapes) + assert result.returncode == 0, result.stdout + result.stderr + assert "both serve 2" in result.stdout + + def test_a_declines_key_on_a_reused_name_is_refused_but_its_path_works( + self, env, tmp_path + ): + shapes = env.shapes( + _named(_LONG, "gqa_case", "corpus/aiter/gqa_case.json"), + _named({**_LONG, "batch": 2}, "gqa_case", "corpus/aotriton/gqa_case.json"), + ) + declines = tmp_path / "declines.json" + declines.write_text(json.dumps({"gqa_case": "no match"})) + result = env.run(env.profile(), shapes, "--declines", str(declines)) + assert result.returncode == 2 + assert "provenance path" in result.stderr + + declines.write_text(json.dumps({"corpus/aiter/gqa_case.json": "no match"})) + result = env.run(env.profile(), shapes, "--declines", str(declines)) + assert "ONLY THE REFERENCE 1" in result.stdout, result.stderr + + def test_an_empty_source_warns_and_exits_0(self, env): + result = env.run(env.profile(), env.shapes()) + assert result.returncode == 0, result.stderr + assert "WARNING" in result.stderr and "no shapes" in result.stderr + + def test_a_graph_outside_the_contract_is_not_compared(self, env): + varlen = { + **_LONG, + "_graph_features": {"layouts": ["BSHD"], "features": ["seq_len_q"]}, + } + result = env.run(env.profile(), env.shapes(_LONG, varlen)) + assert result.returncode == 0, result.stdout + result.stderr + assert "both serve 1" in result.stdout + assert "outside graph contract 1" in result.stdout + + def test_a_refusal_the_reference_does_not_share_is_unreconciled( + self, env, tmp_path + ): + """Treating ValueError as a decline does not hide a wrong refusal: when the + reference serves the shape, it is an unreconciled decline (exit 1).""" + (tmp_path / "rocke" / "library" / "badfab.py").write_text( + "import stublib\n" + "def kernel_spec(req): raise ValueError('seqlen_q must be a multiple of 8192')\n" + ) + path = env.profile() + path.write_text( + path.read_text().replace( + "dispatch: {module: stublib, function: kernel_spec}", + "dispatch: {module: badfab, function: kernel_spec}", + ) + ) + result = env.run(path, env.shapes(_LONG)) + assert result.returncode == 1, result.stdout + result.stderr + assert "ONLY THE REFERENCE 1" in result.stdout + assert "seqlen_q must be a multiple of 8192" in result.stdout diff --git a/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py b/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py index a8d4192ace9a..56b506281d72 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py +++ b/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py @@ -6,17 +6,23 @@ the library ships. Calling the factory applies rules the dispatcher derives from the request (``persistent = work >= num_persistent``). -Emits one variant per servable shape, at the dispatcher's resolved spec, as a -generator config ready for ``generate.py``. Not a cross-product: one spec per -shape. +Emits one variant per distinct servable spec, as a generator config ready for +``generate.py``. Not a cross-product. When the profile maps +``runtime_param_fields`` to canonical values, each spec is built at those +values for the fields the spec itself reads at runtime, so shapes differing +only in batch/seqlen share one catalog entry (``--per-shape`` keeps one kernel +per shape at its own values). dispatch_parity.py --profile --shapes \\ --out configs/_A.yaml A request-construction failure means the corpus and the request class disagree about what a shape is, so it aborts (``ParityError``, ``FAIL`` on stderr, exit -2). A decline -- the eligibility predicate returning false with a reason -- is -a per-shape outcome, counted and printed by ``--report-gaps``. +2), as does any non-ValueError exception from the spec factory. Per-shape +outcomes, counted and printed by ``--report-gaps``: a decline (the eligibility +predicate returning false with a reason), a refusal (the spec factory raising +ValueError, which the dispatcher's own candidate ``support()`` turns into a +decline) and a graph outside the profile's ``graph_contract``. This tool does not sweep. ``--report-knobs`` partitions the spec fields into those that vary across dispatch decisions and those the library ships (see @@ -150,8 +156,11 @@ class Resolution: shape: dict spec: object | None = None reason: str | None = None - #: "constructed" (spec built and predicate accepted) or "declined" - #: (the predicate returned a validated false result). + #: "constructed" (spec built and predicate accepted), "declined" (the + #: predicate returned a validated false result), "refused" (the spec + #: factory raised ValueError, which the dispatcher's own candidate turns + #: into a decline) or "out_of_contract" (the graph needs a feature the + #: request cannot carry, see `contract_refusal`). kind: str = "constructed" @@ -169,8 +178,59 @@ def _required(decl: dict, scope: str, *keys: str) -> list: return [decl[k] for k in keys] +def contract_refusal(shape: dict, profile: dict) -> str | None: + """Why the graph behind `shape` is outside the profile's graph contract, or + None. Only graph-corpus shapes carry `_graph_features` (see mine_shapes). + + The request class has no field for varlen, paging, dropout or layout, so + the dispatcher's answer for such a graph is about a different problem. + `graph_contract.features` lists the bound features the engine admits + (default none) and `graph_contract.layouts` the operand layouts (default + unchecked). + """ + features = shape.get("_graph_features") + if features is None: + return None + contract = profile.get("graph_contract") or {} + if not isinstance(contract, dict) or any( + not isinstance(contract.get(k, []), list) for k in ("features", "layouts") + ): + raise ParityError( + "graph_contract must be a mapping with optional 'features' and " + "'layouts' lists" + ) + unsupported = [ + f + for f in features.get("features") or [] + if f not in contract.get("features", []) + ] + if unsupported: + return ( + f"graph binds {', '.join(unsupported)}, which the request cannot carry " + f"and the profile's graph_contract.features does not admit" + ) + layouts = contract.get("layouts") + if layouts: + have = features.get("layouts") + if have is None: + return f"graph tensors carry no strides; graph_contract needs {layouts}" + if not set(have) & set(layouts): + return ( + f"operand layout {'/'.join(have) or 'strided'} is not in " + f"graph_contract.layouts {layouts}" + ) + return None + + def resolve_shapes(shapes: list[dict], profile: dict) -> list[Resolution]: - """Ask the dispatcher for every shape; API errors are operational failures.""" + """Ask the dispatcher for every shape; API errors are operational failures. + + The boundary mirrors the dispatcher's own candidate `support()` + (dispatch/attention/gfx942.py and gfx950.py): a request the request class + cannot construct is operational, the spec factory raising ValueError is + that shape's decline (rocKE specs refuse unsupported shapes in + `__post_init__`), and any other exception from the factory is operational. + """ dispatch = profile.get("dispatch") or {} request_decl = profile.get("request") or {} predicate_decl = profile.get("predicate") or {} @@ -198,7 +258,17 @@ def resolve_shapes(shapes: list[dict], profile: dict) -> list[Resolution]: fields["arch"] = arch try: request = request_cls(**fields) + except Exception as exc: + raise ParityError(f"request/spec construction failed: {exc}") from exc + outside = contract_refusal(shape, profile) + if outside: + out.append(Resolution(shape, reason=outside, kind="out_of_contract")) + continue + try: spec = factory(request) + except ValueError as exc: + out.append(Resolution(shape, reason=str(exc), kind="refused")) + continue except Exception as exc: raise ParityError(f"request/spec construction failed: {exc}") from exc if predicate is not None: @@ -212,6 +282,83 @@ def resolve_shapes(shapes: list[dict], profile: dict) -> list[Resolution]: return out +def _canonical_values(profile: dict) -> dict: + """The profile's `runtime_param_fields` mapping: spec field -> the value a + catalog entry is built at. The list form names the fields without values, + so it cannot say what to canonicalise to.""" + declared = profile.get("runtime_param_fields") + if not declared: + return {} + if not isinstance(declared, dict) or any( + type(v) is not int for v in declared.values() + ): + raise ParityError( + "runtime_param_fields must map each spec field to its canonical " + "integer value (e.g. {batch: 1, seqlen_q: 512, seqlen_kv: 512}) for " + "dispatch_parity to canonicalise; pass --per-shape to keep one " + "kernel per shape at its own values instead." + ) + return declared + + +def canonicalise( + resolutions: list[Resolution], profile: dict +) -> tuple[list[Resolution], list[str]]: + """Rebuild each served spec at the profile's canonical runtime values. + + Only fields that BOTH the profile maps AND the spec itself lists in its + `runtime_param_fields` are replaced: the spec decides per mode (persistent, + ragged, windowed and moving-diagonal specs bake the shape and declare + none), so those keep their real values. A canonical spec the spec class or + the predicate refuses also keeps its real values. Applicability is never + re-asked here; it was answered at the real shape. + + Returns the catalog resolutions and one note per spec kept per-shape. + """ + canonical = _canonical_values(profile) + if not canonical: + return resolutions, [] + predicate_decl = profile.get("predicate") or {} + predicate = ( + _import(*_required(predicate_decl, "predicate", "module", "function")) + if predicate_decl + else None + ) + arch = profile.get("arch") + out, kept = [], [] + for resolution in resolutions: + spec = resolution.spec + if spec is None: + out.append(resolution) + continue + runtime = set(getattr(spec, "runtime_param_fields", ()) or ()) + fields = {f: v for f, v in canonical.items() if f in runtime} + if not fields: + kept.append(f"{resolution.shape}: spec bakes its shape (no runtime fields)") + out.append(resolution) + continue + try: + rebuilt = dataclasses.replace(spec, **fields) + except ValueError as exc: + kept.append(f"{resolution.shape}: canonical values refused: {exc}") + out.append(resolution) + continue + if not set(fields) <= set(getattr(rebuilt, "runtime_param_fields", ())): + kept.append(f"{resolution.shape}: canonical spec bakes {sorted(fields)}") + out.append(resolution) + continue + if predicate is not None: + ok, why = _predicate_result( + predicate, rebuilt, **({"arch": arch} if arch else {}) + ) + if not ok: + kept.append(f"{resolution.shape}: canonical spec declined: {why}") + out.append(resolution) + continue + out.append(dataclasses.replace(resolution, spec=rebuilt)) + return out, kept + + def knob_partition(resolutions: list[Resolution]) -> tuple[list[str], list[str]]: """(varies, constant) across the dispatcher's own decisions. @@ -372,6 +519,10 @@ def build_config( f"wrong, or the field is not a build-time knob of this kernel." ) kernels = [] + # Shapes that resolve to one identical spec (always the case for shapes + # differing only in canonicalised runtime fields) are one compiled kernel, + # so one catalog entry. + emitted: set = set() for index, resolution in enumerate(resolutions): if resolution.spec is None: continue @@ -379,6 +530,10 @@ def build_config( f.name: getattr(resolution.spec, f.name) for f in dataclasses.fields(resolution.spec) } + identity = repr(sorted(spec.items())) + if identity in emitted: + continue + emitted.add(identity) metadata = {} for name in metadata_fields: if name in resolvers and spec.get(name) is None: @@ -504,7 +659,17 @@ def main(argv=None) -> int: "--report-gaps", action="store_true", help="Print every shape the dispatcher would not serve, with " - "its reason and which layer refused.", + "its reason and which layer refused: [declined] the predicate, [refused] " + "the spec factory (ValueError, the dispatcher's own decline), " + "[out_of_contract] a graph feature the request cannot carry.", + ) + parser.add_argument( + "--per-shape", + action="store_true", + help="Emit one kernel per servable shape at its own batch/seqlen. By " + "default, when the profile maps runtime_param_fields to canonical values, " + "every spec field the spec itself reads at runtime is set to its " + "canonical value and identical specs collapse into one catalog entry.", ) parser.add_argument( "--knobs", @@ -527,19 +692,52 @@ def main(argv=None) -> int: return 2 served = [r for r in resolutions if r.spec is not None] - declined = [r for r in resolutions if r.kind == "declined"] + unserved = [r for r in resolutions if r.spec is None] + count_of = { + kind: sum(r.kind == kind for r in unserved) + for kind in ("declined", "refused", "out_of_contract") + } print("dispatcher parity") print(f" shapes in {len(resolutions)}") print(f" servable {len(served)}") - print(f" declined {len(declined)} (predicate said no)") + print(f" declined {count_of['declined']} (predicate said no)") + print( + f" refused {count_of['refused']} (spec factory raised " + f"ValueError: the dispatcher's decline)" + ) + print( + f" out of contract {count_of['out_of_contract']} (graph feature the " + f"request cannot carry)" + ) if args.report_gaps: - for resolution in declined: + for resolution in unserved: print(f" [{resolution.kind}] {resolution.shape} -- {resolution.reason}") + if not served: + print( + "\nFAIL: no shape resolved; there is nothing to generate.", file=sys.stderr + ) + return 1 + + catalog = resolutions + if not args.per_shape and (args.out or args.report_knobs): + try: + catalog, kept = canonicalise(resolutions, profile) + except ParityError as exc: + print(f"FAIL: {exc}", file=sys.stderr) + return 2 + if catalog is not resolutions: + print( + f" canonical runtime values {_canonical_values(profile)}; " + f"{len(kept)} servable spec(s) kept at their own values" + ) + for note in kept: + print(f" [per-shape] {note}") + if args.report_knobs: - varies, constant = knob_partition(resolutions) + varies, constant = knob_partition(catalog) print("\n VARIES across dispatch decisions -- the tuning surface:") print(f" {', '.join(varies) or '(none)'}") print("\n CONSTANT -- shipped values, NOT tuning axes:") @@ -549,12 +747,6 @@ def main(argv=None) -> int: " configuration rocKE would never resolve to." ) - if not served: - print( - "\nFAIL: no shape resolved; there is nothing to generate.", file=sys.stderr - ) - return 1 - if args.out: try: knobs = json.loads(args.knobs) if args.knobs else {} @@ -563,7 +755,7 @@ def main(argv=None) -> int: f"--knobs must be a JSON mapping of knob name to a list of " f"values, got {type(knobs).__name__}." ) - config = build_config(resolutions, profile, knobs) + config = build_config(catalog, profile, knobs) # Emit the compact form; build_config stays the source of truth and # `_compact` refuses anything that does not re-expand # kernel-for-kernel. The knob axes are exactly --knobs, since the @@ -581,8 +773,8 @@ def main(argv=None) -> int: arms = math.prod(len(v) for v in knobs.values()) print( f"\n wrote {args.out}: {count} kernels " - f"= {len(served)} servable shapes x {arms} surviving knob " - f"combination(s) ({', '.join(sorted(knobs))})" + f"= {count // arms} catalog entries ({len(served)} servable shapes) " + f"x {arms} surviving knob combination(s) ({', '.join(sorted(knobs))})" ) # The cap the runbook states, enforced where the number is known: # past the low thousands the pack time, the archive and the catalog @@ -595,12 +787,14 @@ def main(argv=None) -> int: file=sys.stderr, ) else: - print(f"\n wrote {args.out}: {count} kernels, one per servable shape") + print( + f"\n wrote {args.out}: {count} kernels for {len(served)} servable " + f"shapes" + ) if count != len(served): print( - f" NOTE: {len(served)} shapes resolved but {count} kernels " - f"emitted -- distinct shapes sharing one resolved spec are one " - f"variant." + f" NOTE: distinct shapes sharing one resolved spec are one " + f"variant ({len(served) - count} merged)." ) return 0 diff --git a/projects/hipdnn/tools/IngestorGenerator/tools/reconcile_applicability.py b/projects/hipdnn/tools/IngestorGenerator/tools/reconcile_applicability.py index fefc4a176fa7..b50624a3e69d 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tools/reconcile_applicability.py +++ b/projects/hipdnn/tools/IngestorGenerator/tools/reconcile_applicability.py @@ -35,7 +35,9 @@ ) -def reference_serves(shapes: list[dict], profile: dict) -> dict: +def reference_serves( + shapes: list[dict], profile: dict, skip: set[int] | None = None +) -> dict: """For each shape: does the reference kernel FAMILY serve it, and which candidate? Request construction and reference API failures are operational errors; only a @@ -110,6 +112,10 @@ def reference_serves(shapes: list[dict], profile: dict) -> dict: out = {} for index, shape in enumerate(shapes): + # A graph outside the profile's contract has no request the reference + # could be asked about honestly (see dispatch_parity.contract_refusal). + if skip is not None and index in skip: + continue fields = { **defaults, **{k: v for k, v in shape.items() if not k.startswith("_")}, @@ -197,15 +203,22 @@ def main(argv=None) -> int: profile = _load_profile(args.profile) _bind_provider(profile.get("provider_root")) shapes = json.loads(Path(args.shapes).read_text()) - if ( - not isinstance(shapes, list) - or not shapes - or any(not isinstance(s, dict) for s in shapes) - ): - raise ParityError( - "--shapes must contain a nonempty list of request mappings" + if not isinstance(shapes, list) or any(not isinstance(s, dict) for s in shapes): + raise ParityError("--shapes must contain a list of request mappings") + if not shapes: + # A source with nothing mined (every graph excluded, say) is an + # accounted-for empty comparison, not a failed one. + print( + f"WARNING: {args.shapes} holds no shapes; nothing to reconcile.", + file=sys.stderr, ) - graph_owners = {} + print("applicability reconciliation: 0 shapes") + return 0 + # Runtime --declines keys: the shape index, a graph name, or the graph's + # provenance path. A merged multi-source corpus may reuse one graph name + # in several sources; such a name is only an error if a --declines key + # actually uses it, and the path disambiguates it. + owners: dict = {} shape_names = [] for index, shape in enumerate(shapes): provenance = shape.get("_provenance") or {} @@ -214,18 +227,22 @@ def main(argv=None) -> int: not isinstance(p, dict) for p in occurrences ): raise ParityError("provenance occurrences must be a list of mappings") - names = {p["graph"] for p in occurrences if p.get("graph")} + names = { + str(p[key]) + for p in occurrences + for key in ("graph", "path") + if p.get(key) + } for name in names: - if ( - not isinstance(name, str) - or (name in graph_owners and graph_owners[name] != index) - or (name.isdigit() and int(name) != index) - ): - raise ParityError("ambiguous graph name in shape corpus") - graph_owners[name] = index + owners.setdefault(name, set()).add(index) + if name.isdigit() and int(name) != index: + # Would collide with that index's own key. + owners[name].add(int(name)) shape_names.append(names) + ambiguous = {name: idx for name, idx in owners.items() if len(idx) > 1} ours = resolve_shapes(shapes, profile) - theirs = reference_serves(shapes, profile) + outside = {i for i, r in enumerate(ours) if r.kind == "out_of_contract"} + theirs = reference_serves(shapes, profile, skip=outside) # Every failure above is operational -- the comparison did not happen -- so # it exits 2 whatever it was raised as; an enumerated exception list would # let an unexpected type escape as exit 1, an unreconciled-decline result. @@ -254,8 +271,19 @@ def main(argv=None) -> int: ): print("FAIL: every decline must carry a nonempty reason", file=sys.stderr) return 2 + used = sorted(set(declines) & set(ambiguous)) + if used: + print( + f"FAIL: --declines key(s) {', '.join(used[:8])} name graphs in more " + f"than one shape of this merged corpus (shape indices " + f"{[sorted(ambiguous[k]) for k in used[:8]]}). Key them by the " + f"graph's provenance path or the shape index instead.", + file=sys.stderr, + ) + return 2 both_serve, both_decline, only_reference, only_ours = [], [], [], [] + out_of_contract = [] matched_keys = set() for index, resolution in enumerate(ours): we_serve = resolution.spec is not None @@ -269,6 +297,9 @@ def main(argv=None) -> int: matched_keys.add(candidate_key) runtime_reasons.append(declines[candidate_key]) we_serve = False + if index in outside: + out_of_contract.append((index, resolution.reason)) + continue they_serve, why = theirs[index] if we_serve and they_serve: both_serve.append(index) @@ -316,6 +347,11 @@ def main(argv=None) -> int: print(f" ONLY THE REFERENCE {len(only_reference)}") if only_ours: print(f" only this integration {len(only_ours)}") + if out_of_contract: + print( + f" outside graph contract {len(out_of_contract)} (not compared: the " + f"request cannot carry the graph's features)" + ) # The signature of a misconfigured scope, and both conditions are required: # a corpus where nothing is served can be legitimate, so an empty serve @@ -347,6 +383,15 @@ def main(argv=None) -> int: if len(both_decline) > 10: print(f" ... and {len(both_decline) - 10} more") + if out_of_contract: + print( + "\n Outside the graph contract (this engine's graph_match declines them):" + ) + for index, why in out_of_contract[:10]: + print(f" [{index}] {why[:96]}") + if len(out_of_contract) > 10: + print(f" ... and {len(out_of_contract) - 10} more") + if only_ours: print( "\n THIS INTEGRATION SERVES WHAT THE REFERENCE DECLINES. Not a coverage\n" From 81ac38460d7c8765af133f9941bc0ff1b069743d Mon Sep 17 00:00:00 2001 From: yassinsolim <108886216+yassinsolim@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:33:57 -0600 Subject: [PATCH 3/9] test(hipdnn): describe the dispatch_parity outcome kinds in the test docstrings --- .../tests/test_dispatch_parity.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py index 1ecdde04e2ce..d70e713f3ca8 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py @@ -3,11 +3,15 @@ """What `dispatch_parity.py` reports, and what it refuses to report. -A shape that is not served has exactly ONE per-shape explanation: the eligibility -predicate ran, returned false, and gave a reason. Spec construction failing aborts the -command instead, because a corpus the request class cannot hydrate makes every -remaining count untrustworthy, and a `rejected` bucket that can only print 0 claims a -failure was checked for. The dispatcher, request class and predicate are stubs. +A shape that is not served has one per-shape explanation from the layer that said no: +the eligibility predicate (declined), the spec factory's ValueError, which the +dispatcher's own candidate treats as a decline (refused), or a graph feature outside +the profile's graph contract (out_of_contract). Request construction failing, or the +factory raising anything else, aborts the command instead, because a corpus the +request class cannot hydrate makes every remaining count untrustworthy, and a +`rejected` bucket that can only print 0 claims a failure was checked for. The +dispatcher, request class and predicate are stubs, except in the classes that bind +the shipped profile. Also what the tool BINDS before it can report anything: where a profile's ``provider_root`` resolves from, and which dispatch arm the shipped profile pins. @@ -134,9 +138,8 @@ def argv(shapes: list, *extra: str, **profile_overrides) -> list: class TestTheReportCarriesNoUnpopulatableBucket: def test_the_summary_names_no_rejected_bucket(self, parity, capsys): - """Only two `kind` values can exist: the dataclass default "constructed" and the - "declined" the predicate path sets, since a construction failure returns 2 long - before the summary prints.""" + """A request-construction failure returns 2 long before the summary prints, + so no `rejected` bucket exists to print.""" assert dispatch_parity.main(parity(_SERVED_AND_DECLINED)) == 0 out = capsys.readouterr().out assert "rejected" not in out, ( From be2dbb24d13ae204ebfa2c5ffd3bb223f677cba4 Mon Sep 17 00:00:00 2001 From: yassinsolim <108886216+yassinsolim@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:42:04 -0600 Subject: [PATCH 4/9] fix(hipdnn): gfx950 dispatch_parity asks the full dispatcher, not one variant's factory dense_spec_for_request pins the auto variant (256/64 tile), so a shape only the bm128 sibling variant serves (Sq=128, Skv=256, non-causal) was refused, and reconcile reported 12 such corpus shapes as unreconciled although the library's dispatcher and the shipped bm128 catalog tiles serve them. dispatch_parity gains dispatch.spec_attribute, naming the attribute of a dispatcher result that holds the spec, and the gfx950 profile now uses dispatch.attention.dispatch_attention. On the real corpus the other 785 servable shapes resolve to the identical spec; the 12 become servable. --- .../gfx950_attention_dense.profile.yaml | 10 ++++++-- .../tests/test_dispatch_parity.py | 23 ++++++++++++++++++- .../tools/dispatch_parity.py | 19 ++++++++++++++- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/projects/hipdnn/tools/IngestorGenerator/configs/gfx950_attention_dense.profile.yaml b/projects/hipdnn/tools/IngestorGenerator/configs/gfx950_attention_dense.profile.yaml index 7bcc0c0459a2..f1fecb2c2334 100644 --- a/projects/hipdnn/tools/IngestorGenerator/configs/gfx950_attention_dense.profile.yaml +++ b/projects/hipdnn/tools/IngestorGenerator/configs/gfx950_attention_dense.profile.yaml @@ -20,9 +20,15 @@ reference_candidates: source: kernels/gfx950/attention_dense.py builder: build_attention_dense +# The library's full dispatcher, not the per-arch factory: dense_spec_for_request +# pins the auto variant (256/64 tile) and refuses e.g. Sq=128, which the +# bm128 sibling variant serves and the catalog ships bm128 tiles for. The +# ranker returns the auto variant whenever it admits the request, so every +# other shape resolves to the same spec as before. dispatch: - module: dispatch.attention.gfx950 - function: dense_spec_for_request + module: dispatch.attention + function: dispatch_attention + spec_attribute: spec request: module: dispatch.attention.common diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py index d70e713f3ca8..d8eabff7c14e 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py @@ -505,7 +505,9 @@ def _resolve_with_the_real_dispatcher(monkeypatch, **overrides): "mask_type": 1, **overrides, } - return factory(request_cls(**fields)) + result = factory(request_cls(**fields)) + attribute = dispatch.get("spec_attribute") + return getattr(result, attribute) if attribute else result def test_the_real_dispatcher_resolves_the_arm_the_catalog_ships(self, monkeypatch): """Checking the YAML value is only half of it: this is what the dispatcher does @@ -557,6 +559,25 @@ def _run(self, monkeypatch, tmp_path, shapes, *extra): argv = ["--profile", str(_SHIPPED_PROFILE), "--shapes", str(path), *extra] return dispatch_parity.main(argv) + def test_a_shape_only_the_bm128_variant_serves_is_servable( + self, monkeypatch, tmp_path, capsys + ): + """Sq=128 does not divide the auto variant's block_m=256, but the bm128 + sibling variant serves it and the catalog ships bm128 tiles. Asking only + the auto variant's factory reported it as a gap the engine does not have.""" + shapes = [ + {**_D128, "batch": 1, "seqlen_q": 128, "seqlen_k": 256, "mask_type": 0} + ] + out = tmp_path / "config.yaml" + rc = self._run(monkeypatch, tmp_path, shapes, "--out", str(out)) + printed = capsys.readouterr() + assert rc == 0, printed.err + assert "servable 1" in printed.out + + profile = dispatch_parity._load_profile(str(_SHIPPED_PROFILE)) + [resolution] = dispatch_parity.resolve_shapes(shapes, profile) + assert resolution.spec.block_m == 128, resolution.spec + def test_head_size_192_is_a_refusal_not_an_abort( self, monkeypatch, tmp_path, capsys ): diff --git a/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py b/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py index 56b506281d72..0f3dae590373 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py +++ b/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py @@ -229,13 +229,22 @@ def resolve_shapes(shapes: list[dict], profile: dict) -> list[Resolution]: (dispatch/attention/gfx942.py and gfx950.py): a request the request class cannot construct is operational, the spec factory raising ValueError is that shape's decline (rocKE specs refuse unsupported shapes in - `__post_init__`), and any other exception from the factory is operational. + `__post_init__`; the registry's `select` raises it when no candidate + admits the request), and any other exception from the factory is + operational. + + `dispatch.function` may be a spec factory or the library's full dispatcher; + for the latter, `dispatch.spec_attribute` names the attribute of its result + that holds the selected spec (``dispatch_attention(req).spec``). The full + dispatcher is the faithful answer: a per-arch factory pinned to one variant + refuses shapes a sibling variant of the same kernel serves. """ dispatch = profile.get("dispatch") or {} request_decl = profile.get("request") or {} predicate_decl = profile.get("predicate") or {} factory = _import(*_required(dispatch, "dispatch", "module", "function")) + spec_attribute = dispatch.get("spec_attribute") request_cls = _import(*_required(request_decl, "request", "module", "class")) predicate = ( _import(*_required(predicate_decl, "predicate", "module", "function")) @@ -271,6 +280,14 @@ def resolve_shapes(shapes: list[dict], profile: dict) -> list[Resolution]: continue except Exception as exc: raise ParityError(f"request/spec construction failed: {exc}") from exc + if spec_attribute: + try: + spec = getattr(spec, spec_attribute) + except AttributeError as exc: + raise ParityError( + f"dispatch.spec_attribute {spec_attribute!r}: the dispatcher " + f"result has no such attribute ({exc})" + ) from exc if predicate is not None: supported, why = _predicate_result( predicate, spec, **({"arch": arch} if arch else {}) From 07bdf891f5f385207783d42622b48de7498f2281 Mon Sep 17 00:00:00 2001 From: yassinsolim <108886216+yassinsolim@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:49:18 -0600 Subject: [PATCH 5/9] fix(hipdnn): dispatch_parity names kernels by their catalog key, not shape position Kernel names encoded every scalar spec field plus a running index _vN, so re-mining the corpus in another order renamed kernels and churned the descriptors and the kpack. The compact form then needed one opaque tag knob set per kernel (141 on the gfx942 run). Names are now the slug plus prefix{value} for each of the profile's metadata fields (the catalog key), and the kernel list is sorted by name, so a reordered corpus emits an identical config. Kernels the key cannot tell apart get a digest of their spec, with a warning: same metadata is the same catalog key to the engine. On the real corpus the gfx942 parity config compacts to one variant group with one knob set. --- .../tests/test_dispatch_parity.py | 43 ++++++++++++ .../tools/dispatch_parity.py | 70 +++++++++++++------ 2 files changed, 91 insertions(+), 22 deletions(-) diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py index d8eabff7c14e..22018411275d 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py @@ -632,3 +632,46 @@ def test_per_shape_keeps_one_kernel_per_shape(self, monkeypatch, tmp_path, capsy ) assert rc == 0 assert "4 kernels for 4 servable shapes" in capsys.readouterr().out + + +class TestEmittedNamesDoNotDependOnShapeOrder: + """Names land in the descriptors and the kpack. A name carrying the shape's + position in the corpus churns both whenever the corpus is re-mined in another + order (942:S3-1).""" + + _SHAPES = [ + {**_D128, "batch": 1, "seqlen_q": 512, "seqlen_k": 512}, + {**_D128, "batch": 2, "seqlen_q": 1024, "seqlen_k": 1024, "mask_type": 0}, + {**_D128, "batch": 1, "seqlen_q": 4096, "seqlen_k": 4096, "nhead_k": 2}, + {**_D128, "batch": 1, "seqlen_q": 300, "seqlen_k": 300}, + {**_D128, "batch": 1, "seqlen_q": 128, "seqlen_k": 256, "mask_type": 0}, + ] + + def _emit(self, monkeypatch, tmp_path, shapes, *extra) -> str: + _real_dispatcher_or_skip(monkeypatch) + path = tmp_path / "shapes.json" + path.write_text(json.dumps(shapes)) + out = tmp_path / "config.yaml" + argv = ["--profile", str(_SHIPPED_PROFILE), "--shapes", str(path)] + assert dispatch_parity.main([*argv, "--out", str(out), *extra]) == 0 + return out.read_text() + + @pytest.mark.parametrize("extra", [(), ("--per-shape",)]) + def test_a_reordered_corpus_emits_the_identical_config( + self, monkeypatch, tmp_path, extra + ): + forward = self._emit(monkeypatch, tmp_path, self._SHAPES, *extra) + backward = self._emit(monkeypatch, tmp_path, self._SHAPES[::-1], *extra) + assert forward == backward + + def test_names_are_the_catalog_key_not_a_position(self, monkeypatch, tmp_path): + _real_dispatcher_or_skip(monkeypatch) + profile = dispatch_parity._load_profile(str(_SHIPPED_PROFILE)) + config = dispatch_parity.build_config( + dispatch_parity.resolve_shapes(self._SHAPES[:1], profile), profile + ) + [kernel] = config["packs"][0]["kernels"] + assert kernel["name"] == ( + "gfx950_attention_dense_dtBF16_hs128_nqh8_nkh8_ca1_ra0_sw0_ba1_sq512_sk512" + "_bm256_bn64" + ) diff --git a/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py b/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py index 0f3dae590373..219cadcd39d1 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py +++ b/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py @@ -34,11 +34,13 @@ import argparse import copy import dataclasses +import hashlib import inspect import itertools import json import math import os +import re import sys from pathlib import Path @@ -393,30 +395,35 @@ def knob_partition(resolutions: list[Resolution]) -> tuple[list[str], list[str]] return varies, constant -def _kernel_name(slug: str, spec, index: int) -> str: - """A kernel name derived from the spec's own fields, whatever op this is. +def _kernel_name(slug: str, spec: dict, metadata: dict, metadata_fields: list) -> str: + """A kernel name derived from what the kernel IS, never from where its shape + sat in the corpus: names reach the descriptors and the kpack, so a name that + moves when the shape list is reordered churns both. - Hand-listing one op's field names collapses other ops' variants onto the - same string, and nothing downstream catches it. Scalars only, with the - index appended unconditionally. + The catalog key (the profile's metadata fields, in profile order) names the + kernel, `prefix{value}` per field, which is also what `factorise_config` + binds name tokens to. A profile with no metadata fields falls back to the + spec's scalar fields. Two kernels the fields cannot tell apart are + disambiguated in `build_config` by a digest of their spec. """ + if metadata_fields: + items = [(name, metadata.get(name)) for name in metadata_fields] + else: + items = list(spec.items()) parts = [slug] - try: - fields = [f.name for f in dataclasses.fields(spec)] - except TypeError: # not a dataclass; fall back to the index alone - fields = [] - for name in fields: - value = getattr(spec, name, None) + for name, value in items: if value is None or isinstance(value, (list, tuple, dict, set)): continue if isinstance(value, bool): - # A bare 0/1 reads as a magnitude; the field name alone reads as a flag. - if value: - parts.append(_abbrev(name)) - continue - parts.append(f"{_abbrev(name)}{value}") - parts.append(f"v{index}") - return "_".join(str(p) for p in parts) + value = int(value) + rendered = re.sub(r"[^0-9A-Za-z]", "", str(value)) + parts.append(f"{_abbrev(name)}{rendered}") + return "_".join(parts) + + +def _spec_digest(spec: dict) -> str: + text = json.dumps(spec, sort_keys=True, default=str) + return hashlib.sha256(text.encode()).hexdigest()[:8] def _abbrev(field: str) -> str: @@ -540,7 +547,7 @@ def build_config( # differing only in canonicalised runtime fields) are one compiled kernel, # so one catalog entry. emitted: set = set() - for index, resolution in enumerate(resolutions): + for resolution in resolutions: if resolution.spec is None: continue spec = { @@ -595,9 +602,7 @@ def build_config( # from the shared spec, so a `knob in variant_spec` guard would # skip exactly the knobs worth sweeping. variant_spec[knob] = value - name = _kernel_name(slug, resolution.spec, index) - if pinned: - name += "." + "_".join(f"{k}{pinned[k]}" for k in axis_names) + name = _kernel_name(slug, variant_spec, variant_metadata, metadata_fields) kernels.append( { "name": name, @@ -610,6 +615,27 @@ def build_config( "metadata": variant_metadata, } ) + # Kernels the name fields cannot tell apart get a digest of their spec, and + # the list is ordered by name, so neither names nor descriptor order depend + # on the order of the shape corpus. + seen: dict = {} + for kernel in kernels: + seen[kernel["name"]] = seen.get(kernel["name"], 0) + 1 + clashes = sorted(name for name, count in seen.items() if count > 1) + for kernel in kernels: + if seen[kernel["name"]] > 1: + kernel["name"] += "_h" + _spec_digest(kernel["kernel_source"]["spec"]) + if clashes and metadata_fields: + # Same metadata is the same catalog key: the engine cannot tell these + # binaries apart, so a spec field that matters is missing from + # metadata_fields (or the shape should not be served). + print( + f" WARNING: {len(clashes)} catalog key(s) are shared by kernels with " + f"different specs (names suffixed _h): {', '.join(clashes[:4])}" + + (" ..." if len(clashes) > 4 else ""), + file=sys.stderr, + ) + kernels.sort(key=lambda kernel: kernel["name"]) # `dialect: packaged` is stated rather than guessed: a rocKE builder can # only be authored packaged, and the loader rejects any other pairing. return { From 9ecdda933a4a5ec0694d969c66daa6e408ada9f4 Mon Sep 17 00:00:00 2001 From: yassinsolim <108886216+yassinsolim@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:54:45 -0600 Subject: [PATCH 6/9] fix(hipdnn): graph_contract applies to sink requests from every corpus source A rocKE trace asks for sinks through the use_sinks request field and has no _graph_features, so the contract never saw it. The gfx950 dispatcher serves those shapes and the catalog got a use_sinks=true kernel under the same catalog key as its sinkless twin, although the engine's graph_match declines sinks. contract_refusal now treats use_sinks as the bound sink_token feature for every shape, so a profile whose graph_contract does not admit sink_token reports such a shape as out of contract. The dispatcher is unchanged: the kernel may support sinks, the engine does not. On the real corpus the gfx950 catalog-key collision is gone (16 sink trace shapes out of contract, 781 servable, no _h digests); gfx942's 13 sink shapes no longer read as served only by this integration. --- .../tests/test_dispatch_parity.py | 32 ++++++++++++++++++ .../tools/dispatch_parity.py | 33 +++++++++++-------- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py index 22018411275d..1b421bf90c57 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py @@ -675,3 +675,35 @@ def test_names_are_the_catalog_key_not_a_position(self, monkeypatch, tmp_path): "gfx950_attention_dense_dtBF16_hs128_nqh8_nkh8_ca1_ra0_sw0_ba1_sq512_sk512" "_bm256_bn64" ) + + +class TestTheGraphContractAppliesToEverySource: + """The gfx950 engine's graph_match declines sinks, so no catalog entry may carry + use_sinks=true, even though the gfx950 kernel and dispatcher serve sinks. A + rocKE trace states sinks as a request field, with no `_graph_features`, and + used to reach the catalog as a second kernel under the same catalog key.""" + + _TRACE = ( + Path(__file__).resolve().parents[5] + / "dnn-providers/hip-kernel-provider/rocke/library/benchmarks/gfx950" + / "attention/prefill/gpt_oss_sink_prefill_shapes.json" + ) + + def test_a_real_sink_prefill_trace_is_out_of_contract(self, monkeypatch, tmp_path): + import mine_shapes + + profile = _real_dispatcher_or_skip(monkeypatch) + record = json.loads(self._TRACE.read_text().splitlines()[0]) + assert record["variant"] == "full_sink_prefill_s512" and record["has_sinks"] + tree = tmp_path / "bench" + tree.mkdir() + (tree / "sink_shapes.json").write_text(json.dumps(record) + "\n") + [shape] = mine_shapes.from_rocke_bench(tree, "bf16") + assert shape["use_sinks"] is True + + sink, control = dispatch_parity.resolve_shapes( + [shape, {**shape, "use_sinks": False}], profile + ) + assert sink.kind == "out_of_contract", sink + assert "sink_token" in sink.reason + assert control.spec is not None, "the sinkless twin is still served" diff --git a/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py b/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py index 219cadcd39d1..a35014677fcd 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py +++ b/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py @@ -181,17 +181,24 @@ def _required(decl: dict, scope: str, *keys: str) -> list: def contract_refusal(shape: dict, profile: dict) -> str | None: - """Why the graph behind `shape` is outside the profile's graph contract, or - None. Only graph-corpus shapes carry `_graph_features` (see mine_shapes). + """Why the request or graph behind `shape` is outside the profile's graph + contract, or None. The request class has no field for varlen, paging, dropout or layout, so - the dispatcher's answer for such a graph is about a different problem. - `graph_contract.features` lists the bound features the engine admits - (default none) and `graph_contract.layouts` the operand layouts (default - unchecked). + the dispatcher's answer for such a graph is about a different problem; only + graph-corpus shapes carry those, in `_graph_features` (see mine_shapes). + Sinks are different: the request carries them as `use_sinks`, from every + source, and a kernel may support them while the engine's graph_match does + not. So a shape asking for sinks binds the `sink_token` feature whatever + its source. `graph_contract.features` lists the bound features the engine + admits (default none) and `graph_contract.layouts` the operand layouts + (default unchecked; checked for graph shapes only). """ features = shape.get("_graph_features") - if features is None: + bound = list((features or {}).get("features") or []) + if shape.get("use_sinks") and "sink_token" not in bound: + bound.append("sink_token") + if features is None and not bound: return None contract = profile.get("graph_contract") or {} if not isinstance(contract, dict) or any( @@ -201,16 +208,14 @@ def contract_refusal(shape: dict, profile: dict) -> str | None: "graph_contract must be a mapping with optional 'features' and " "'layouts' lists" ) - unsupported = [ - f - for f in features.get("features") or [] - if f not in contract.get("features", []) - ] + unsupported = [f for f in bound if f not in contract.get("features", [])] if unsupported: return ( - f"graph binds {', '.join(unsupported)}, which the request cannot carry " - f"and the profile's graph_contract.features does not admit" + f"request binds {', '.join(unsupported)}, which the profile's " + f"graph_contract.features does not admit (the engine declines it)" ) + if features is None: + return None layouts = contract.get("layouts") if layouts: have = features.get("layouts") From 8b463b2146255ce1666b1e1edf198f8ebdc81806 Mon Sep 17 00:00:00 2001 From: yassinsolim <108886216+yassinsolim@users.noreply.github.com> Date: Thu, 1 Oct 2026 06:10:29 -0600 Subject: [PATCH 7/9] fix(hipdnn): knob_sweep arms reach the coverage shapes a tile knob exists for knob_sweep built every arm by mutating the dispatcher's resolution of each shape, so a shape the dispatcher's own tile refuses (Sq % 256 == 128, Skv % 64 == 32) never reached any arm, and every tile arm reported the same served count. Such a shape is now probed at canonical multiples of the profile's runtime_param_fields that keep the real values' equality and order. When the dispatcher serves the probe with a spec that reads every runtime field as a kernel argument, each arm builds that spec at the real values with its own knobs, and the spec class and predicate decide whether the arm serves it. Arms report COVERAGE separately from NARROWED, since coverage shapes have no parity time. runtime_param_fields entries may now name the request field ({canonical: 512, request: seqlen_k}); the gfx950 profile does so for seqlen_kv. NARROWED now counts shapes, not deduplicated kernels. --- .../gfx950_attention_dense.profile.yaml | 4 +- .../tests/test_knob_sweep.py | 74 ++++++++- .../tools/dispatch_parity.py | 44 ++++-- .../IngestorGenerator/tools/knob_sweep.py | 148 ++++++++++++++---- 4 files changed, 222 insertions(+), 48 deletions(-) diff --git a/projects/hipdnn/tools/IngestorGenerator/configs/gfx950_attention_dense.profile.yaml b/projects/hipdnn/tools/IngestorGenerator/configs/gfx950_attention_dense.profile.yaml index f1fecb2c2334..5291a531bca3 100644 --- a/projects/hipdnn/tools/IngestorGenerator/configs/gfx950_attention_dense.profile.yaml +++ b/projects/hipdnn/tools/IngestorGenerator/configs/gfx950_attention_dense.profile.yaml @@ -220,7 +220,9 @@ sweep: runtime_param_fields: batch: 1 seqlen_q: 512 - seqlen_kv: 512 + # The request spells this field seqlen_k; knob_sweep needs the request name + # to probe coverage shapes. + seqlen_kv: {canonical: 512, request: seqlen_k} # Graph features the engine's graph_match admits beyond the request fields # (Gfx950AttentionDenseNative.cpp): dense BSHD operands only, and none of the diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_knob_sweep.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_knob_sweep.py index 2bd9c9544d71..4ed77f5439fa 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_knob_sweep.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_knob_sweep.py @@ -80,7 +80,7 @@ def test_the_baseline_arm_carries_every_resolved_shape(self, sweep): """Control: with no overrides both shapes are supported, so an exclusion below comes from the perturbation.""" profile, resolutions = sweep - config, unbuildable = knob_sweep._arm(resolutions, profile, {}) + config, unbuildable, _gained = knob_sweep._arm(resolutions, profile, {}) assert unbuildable == [] assert len(config["packs"][0]["kernels"]) == 2 @@ -89,7 +89,9 @@ def test_a_shape_unsupported_only_after_the_override_is_excluded_and_named( ): """Naming the excluded shape is what makes a narrowed arm measurable.""" profile, resolutions = sweep - config, unbuildable = knob_sweep._arm(resolutions, profile, {"head_size": 64}) + config, unbuildable, _gained = knob_sweep._arm( + resolutions, profile, {"head_size": 64} + ) assert [index for index, _reason in unbuildable] == [0] assert "unsupported" in unbuildable[0][1] @@ -105,7 +107,9 @@ def test_a_profile_with_no_predicate_still_builds_every_arm(self, sweep): this tool must not invent an answer.""" profile, resolutions = sweep profile.pop("predicate") - _config, unbuildable = knob_sweep._arm(resolutions, profile, {"head_size": 64}) + _config, unbuildable, _gained = knob_sweep._arm( + resolutions, profile, {"head_size": 64} + ) assert unbuildable == [] @@ -115,7 +119,7 @@ def test_the_arm_carries_the_specialization_block(self, sweep): specialization_contract, leaving the receiving machine nothing to check the compiled bytes against.""" profile, resolutions = sweep - config, _unbuildable = knob_sweep._arm(resolutions, profile, {}) + config, _unbuildable, _gained = knob_sweep._arm(resolutions, profile, {}) assert config["specialization"] == profile["specialization"] def test_a_missing_declaration_is_refused_rather_than_emitted_empty(self, sweep): @@ -135,3 +139,65 @@ def test_a_callback_resolved_knob_with_no_declared_readout_is_refused(self, swee } with pytest.raises(Exception, match="use_cfvst"): knob_sweep._arm(resolutions, profile, {}) + + +_SHIPPED_PROFILE = ( + Path(__file__).resolve().parents[1] + / "configs" + / "gfx950_attention_dense.profile.yaml" +) +_D128 = dict(nhead_q=8, nhead_k=8, hdim_q=128, hdim_v=128, dtype="bf16", mask_type=0) + + +class TestCoverageShapesReachTheArmsThatServeThem: + """942:S6-2: a tile arm exists for shapes the dispatcher's own tile cannot + serve (Skv % 64 == 32 needs block_n=32). The sweep resolved every shape at the + dispatcher's choice first, so those shapes never reached any arm and every + arm reported the same served count.""" + + def _sweep(self, monkeypatch, tmp_path, capsys): + import importlib + import json + + from dispatch_parity import _bind_provider, _load_profile + + profile = _load_profile(str(_SHIPPED_PROFILE)) + monkeypatch.setattr(sys, "path", list(sys.path)) + _bind_provider(profile["provider_root"]) + try: + importlib.import_module(profile["dispatch"]["module"]) + except ImportError as exc: + pytest.skip(f"the rocKE library cannot be imported here ({exc})") + shapes = tmp_path / "shapes.json" + shapes.write_text( + json.dumps( + [ + {**_D128, "batch": 1, "seqlen_q": 512, "seqlen_k": 512}, + {**_D128, "batch": 1, "seqlen_q": 512, "seqlen_k": 288}, + ] + ) + ) + argv = ["--profile", str(_SHIPPED_PROFILE), "--shapes", str(shapes)] + rc = knob_sweep.main([*argv, "--isolate", "--out-dir", str(tmp_path / "arms")]) + return rc, capsys.readouterr().out, tmp_path / "arms" + + def test_the_block_n_32_arm_serves_the_shape_the_parity_tile_refuses( + self, monkeypatch, tmp_path, capsys + ): + import yaml + + rc, out, arms = self._sweep(monkeypatch, tmp_path, capsys) + assert rc == 0, out + assert "coverage shapes 1" in out + assert "COVERAGE: serves 1 of 1" in out + + def seqlens(arm): + config = yaml.safe_load((arms / arm).read_text()) + return sorted( + k["kernel_source"]["spec"]["seqlen_kv"] + for k in config["packs"][0]["kernels"] + ) + + assert seqlens("arm_block_n_32.yaml") == [288, 512] + assert seqlens("arm_parity.yaml") == [512], "parity serves only its own tile" + assert seqlens("arm_block_n_64.yaml") == [512] diff --git a/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py b/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py index a35014677fcd..ba2a2e04082e 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py +++ b/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py @@ -306,23 +306,45 @@ def resolve_shapes(shapes: list[dict], profile: dict) -> list[Resolution]: return out -def _canonical_values(profile: dict) -> dict: - """The profile's `runtime_param_fields` mapping: spec field -> the value a - catalog entry is built at. The list form names the fields without values, - so it cannot say what to canonicalise to.""" +def runtime_fields(profile: dict) -> dict: + """The profile's `runtime_param_fields` mapping, normalised to + `spec field -> (canonical value, request field)`. + + Each entry is the canonical integer, or `{canonical: , request: + }` when the request spells the field differently (the spec's + `seqlen_kv` is the attention request's `seqlen_k`). The list form names the + fields without values, so it cannot say what to canonicalise to. + """ declared = profile.get("runtime_param_fields") if not declared: return {} - if not isinstance(declared, dict) or any( - type(v) is not int for v in declared.values() - ): + out = {} + for field, value in declared.items() if isinstance(declared, dict) else (): + if type(value) is int: + out[field] = (value, field) + elif ( + isinstance(value, dict) + and type(value.get("canonical")) is int + and isinstance(value.get("request", field), str) + ): + out[field] = (value["canonical"], value.get("request", field)) + else: + out = None + break + if not out: raise ParityError( "runtime_param_fields must map each spec field to its canonical " - "integer value (e.g. {batch: 1, seqlen_q: 512, seqlen_kv: 512}) for " - "dispatch_parity to canonicalise; pass --per-shape to keep one " - "kernel per shape at its own values instead." + "integer value, or to {canonical: , request: } " + "(e.g. {batch: 1, seqlen_q: 512, seqlen_kv: {canonical: 512, request: " + "seqlen_k}}) for dispatch_parity to canonicalise; pass --per-shape to " + "keep one kernel per shape at its own values instead." ) - return declared + return out + + +def _canonical_values(profile: dict) -> dict: + """Spec field -> the value a catalog entry is built at.""" + return {field: value for field, (value, _) in runtime_fields(profile).items()} def canonicalise( diff --git a/projects/hipdnn/tools/IngestorGenerator/tools/knob_sweep.py b/projects/hipdnn/tools/IngestorGenerator/tools/knob_sweep.py index 88975b63ef94..bea8c060f5a6 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tools/knob_sweep.py +++ b/projects/hipdnn/tools/IngestorGenerator/tools/knob_sweep.py @@ -36,6 +36,7 @@ build_config, knob_partition, resolve_shapes, + runtime_fields, ) @@ -74,6 +75,48 @@ def _promote(spec, arch_spec_cls, overrides: dict): return arch_spec_cls(**{**shared, **overrides}) +def coverage_probes(resolutions, profile: dict) -> dict: + """Shapes the dispatcher refuses or declines that a runtime-shape spec of the + same kernel could still serve with a different knob value: the shapes a + coverage tile exists for (Sq % 256 == 128 under a 256-row default tile). + + The dispatcher answers only for its own choice, so each such shape is asked + again at probe values of the profile's `runtime_param_fields`: canonical + multiples that keep the real values' equality and order (Sq == Skv stays + equal, Sq < Skv stays smaller), so the probe asks about the same problem. + A probe the dispatcher serves, with a spec that reads every runtime field + as a kernel argument, gives `index -> (probe spec, {spec field: real + value})`. Each arm then builds that spec at the real values with its own + knobs (`_arm`), and the spec class and predicate decide whether the arm + serves the shape. Empty when the profile declares no runtime fields. + """ + runtime = runtime_fields(profile) + if not runtime: + return {} + probes = {} + for index, resolution in enumerate(resolutions): + if resolution.spec is not None or resolution.kind not in ( + "refused", + "declined", + ): + continue + reals = {f: resolution.shape.get(req) for f, (_, req) in runtime.items()} + if any(type(v) is not int or v <= 0 for v in reals.values()): + continue + rank = {v: i for i, v in enumerate(sorted(set(reals.values())))} + probe = dict(resolution.shape) + for field, (canonical, request_field) in runtime.items(): + probe[request_field] = canonical * (rank[reals[field]] + 1) + [answer] = resolve_shapes([probe], profile) + spec = answer.spec + if spec is None or not set(runtime) <= set( + getattr(spec, "runtime_param_fields", ()) or () + ): + continue + probes[index] = (spec, reals) + return probes + + def _support_predicate(profile: dict): """The engine's own eligibility predicate, or None when the profile declares none. Bound once per arm rather than per shape: a predicate that @@ -85,8 +128,12 @@ def _support_predicate(profile: dict): def _arm( - resolutions, profile: dict, overrides: dict, arch_spec_cls=None -) -> tuple[dict, list[tuple[int, str]]]: + resolutions, + profile: dict, + overrides: dict, + arch_spec_cls=None, + probes: dict | None = None, +) -> tuple[dict, list[tuple[int, str]], list[int]]: """One arm: the parity set with `overrides` forced onto every served spec. Built by mutating the dispatcher's own resolution, so the arm differs from @@ -94,10 +141,12 @@ def _arm( one is declared, including the baseline, which would otherwise also differ in the subclass's private defaults. - Returns `(config, unbuildable)`, where `unbuildable` lists + Returns `(config, unbuildable, gained)`. `unbuildable` lists `(shape_index, reason)` for every served shape whose spec refuses this arm's value -- a property of the set that the caller must report, since a - ratio over a different shape population is not a comparison. + ratio over a different shape population is not a comparison. `gained` + lists the coverage shapes (see `coverage_probes`) this arm serves although + the dispatcher's own choice does not; they are in the config too. A shape is refused either by the spec constructor or by the eligibility predicate, which is asked about the final spec, after promotion and this @@ -105,14 +154,22 @@ def _arm( """ mutated = [] unbuildable: list[tuple[int, str]] = [] + gained: list[int] = [] predicate = _support_predicate(profile) arch = profile.get("arch") + probes = probes or {} for index, resolution in enumerate(resolutions): - if resolution.spec is None: + coverage = resolution.spec is None and index in probes + if resolution.spec is None and not coverage: mutated.append(resolution) continue + base, fields = ( + (probes[index][0], {**overrides, **probes[index][1]}) + if coverage + else (resolution.spec, overrides) + ) clone = copy.copy(resolution) - private = [k for k in overrides if not hasattr(resolution.spec, k)] + private = [k for k in overrides if not hasattr(base, k)] if private and arch_spec_cls is None: raise ParityError( f"{private} are not on the spec the dispatcher returns and no " @@ -126,16 +183,17 @@ def _arm( # wide-DMA for. Drop those shapes and count them rather than aborting. try: if arch_spec_cls is not None: - clone.spec = _promote(resolution.spec, arch_spec_cls, overrides) + clone.spec = _promote(base, arch_spec_cls, fields) else: # The spec is a frozen dataclass, so replace() rather than # setattr: mutating a shared object in place is how one arm's # override leaks into the next one's baseline. - clone.spec = dataclasses.replace(resolution.spec, **overrides) + clone.spec = dataclasses.replace(base, **fields) except ParityError: raise except Exception as exc: - unbuildable.append((index, f"{type(exc).__name__}: {exc}")) + if not coverage: + unbuildable.append((index, f"{type(exc).__name__}: {exc}")) continue if predicate is not None: # The final spec, promoted to the builder's class and carrying this @@ -145,10 +203,13 @@ def _arm( predicate, clone.spec, **({"arch": arch} if arch else {}) ) if not supported: - unbuildable.append((index, f"unsupported: {why}")) + if not coverage: + unbuildable.append((index, f"unsupported: {why}")) continue + if coverage: + gained.append(index) mutated.append(clone) - return build_config(mutated, profile), unbuildable + return build_config(mutated, profile), unbuildable, gained def _write(config: dict, path: Path) -> int: @@ -191,6 +252,7 @@ def main(argv=None) -> int: _bind_provider(profile.get("provider_root")) shapes = json.loads(Path(args.shapes).read_text()) resolutions = resolve_shapes(shapes, profile) + probes = coverage_probes(resolutions, profile) except ParityError as exc: print(f"FAIL: {exc}", file=sys.stderr) return 2 @@ -222,6 +284,10 @@ def main(argv=None) -> int: print("knob sweep") print(f" shapes served {len(served)}") + print( + f" coverage shapes {len(probes)} (the dispatcher's choice refuses them; " + f"an arm may serve them)" + ) print(f" dispatcher varies {', '.join(varies) or '(none)'}") print(f" candidates {len(candidates)}") for knob in candidates: @@ -247,37 +313,48 @@ def main(argv=None) -> int: if not candidates: print("\nFAIL: no candidate knobs to isolate.", file=sys.stderr) return 1 - baseline, base_unbuildable = _arm(resolutions, profile, {}, arch_spec_cls) + baseline, base_unbuildable, base_gained = _arm( + resolutions, profile, {}, arch_spec_cls, probes + ) assert not base_unbuildable, ( "the PARITY baseline itself has unbuildable shapes, which is impossible " "by construction -- the dispatcher resolved these specs, so they build. " f"Got: {base_unbuildable[:3]}" ) count = _write(baseline, out / "arm_parity.yaml") - served_total = count - print(f"\n arm_parity.yaml {count:5d} kernels (the baseline)") + print( + f"\n arm_parity.yaml {count:5d} kernels (the baseline" + + (f"; +{len(base_gained)} coverage shapes" if base_gained else "") + + ")" + ) base_specs = [ k["kernel_source"]["spec"] for k in baseline["packs"][0]["kernels"] ] for knob in candidates: for value in knob.values: - arm, unbuildable = _arm( - resolutions, profile, {knob.name: value}, arch_spec_cls + arm, unbuildable, gained = _arm( + resolutions, profile, {knob.name: value}, arch_spec_cls, probes ) name = f"arm_{knob.name}_{value}.yaml" count = _write(arm, out / name) # An arm whose value is what the dispatcher already resolves is # the baseline under another name: it measures 1.000x and # reports "no effect" having never tried the other side. - arm_specs = [ - k["kernel_source"]["spec"] for k in arm["packs"][0]["kernels"] - ] - identical = arm_specs and all( - b.get(knob.name) == a.get(knob.name) - for b, a in zip(base_specs, arm_specs) + identical = ( + base_specs + and all(b.get(knob.name) == value for b in base_specs) + and len(gained) == len(base_gained) ) note = " == parity, measures nothing" if identical else "" print(f" {name:<30}{count:5d} kernels{note}") + # Coverage is what a tile knob is for: shapes the dispatcher's + # own tile cannot serve. Counted separately, since they are not + # in the baseline and so are not part of any ratio against it. + if len(gained) > len(base_gained): + print( + f" {'':30}{'':5} COVERAGE: serves {len(gained)} of " + f"{len(probes)} coverage shapes the parity set cannot" + ) # A narrowed arm is not a full comparison. Report the fraction # and the distinct reasons so the arm's coverage is a number # the reader checks rather than assumes. @@ -286,9 +363,9 @@ def main(argv=None) -> int: for _, why in unbuildable: reasons[why] = reasons.get(why, 0) + 1 print( - f" {'':30}{'':5} NARROWED: covers {count} of " - f"{served_total} served shapes; " - f"{len(unbuildable)} cannot express this value" + f" {'':30}{'':5} NARROWED: covers " + f"{len(served) - len(unbuildable)} of {len(served)} served " + f"shapes; {len(unbuildable)} cannot express this value" ) for why, n in sorted(reasons.items(), key=lambda kv: -kv[1]): print(f" {'':30}{'':5} {n:4d} x {why}") @@ -296,7 +373,8 @@ def main(argv=None) -> int: "\n Two arms per knob, everything else at parity, so an effect is " "attributable\n to that knob alone. Measure these before pairing anything." "\n A NARROWED arm is measurable but is NOT a comparison over the whole" - "\n set -- report its fraction, never a bare ratio against parity." + "\n set -- report its fraction, never a bare ratio against parity. A" + "\n COVERAGE arm's extra shapes have no parity time; report them apart." ) return 0 @@ -318,13 +396,19 @@ def main(argv=None) -> int: for combo in combos: overrides = dict(zip(names, combo)) label = "_".join(f"{k}{v}" for k, v in overrides.items()) - arm, unbuildable = _arm(resolutions, profile, overrides, arch_spec_cls) - count = _write(arm, out / f"pair_{label}.yaml") - note = ( - f" NARROWED: {len(unbuildable)} shapes cannot express this combination" - if unbuildable - else "" + arm, unbuildable, gained = _arm( + resolutions, profile, overrides, arch_spec_cls, probes ) + count = _write(arm, out / f"pair_{label}.yaml") + notes = [] + if unbuildable: + notes.append( + f"NARROWED: {len(unbuildable)} shapes cannot express this " + f"combination" + ) + if gained: + notes.append(f"COVERAGE: +{len(gained)} of {len(probes)}") + note = (" " + "; ".join(notes)) if notes else "" print(f" pair_{label:<26}{count:5d} kernels{note}") return 0 From 86f2fa258ccff994796d6bed3c66bab4145101c6 Mon Sep 17 00:00:00 2001 From: yassinsolim <108886216+yassinsolim@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:47:56 -0600 Subject: [PATCH 8/9] fix(hipdnn): find the repo root in a tree without git metadata launch_surface.find_repo_root looked only for a .git ancestor and otherwise returned the start directory. In a git archive extract that start is IngestorGenerator/tools, so dispatch_parity resolved the profile's repo-relative provider_root under tools/ and every real-dispatcher test failed with 'tools/dnn-providers/... does not exist'. Without .git it now takes the nearest ancestor holding projects/hipdnn. The real-dispatcher tests skip, with the reason, only when the rocKE tree is genuinely absent. --- .../tests/test_dispatch_parity.py | 13 ++++++++----- .../IngestorGenerator/tests/test_knob_sweep.py | 7 +++++-- .../tests/test_launch_surface.py | 17 +++++++++++++++-- .../IngestorGenerator/tools/launch_surface.py | 18 ++++++++++++++---- 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py index 1b421bf90c57..b5f4c134b542 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py @@ -477,9 +477,7 @@ def _resolve_with_the_real_dispatcher(monkeypatch, **overrides): """ import importlib - profile = dispatch_parity._load_profile(str(_SHIPPED_PROFILE)) - monkeypatch.setattr(sys, "path", list(sys.path)) - dispatch_parity._bind_provider(profile["provider_root"]) + profile = _real_dispatcher_or_skip(monkeypatch) dispatch, request = profile["dispatch"], profile["request"] try: factory_module = importlib.import_module(dispatch["module"]) @@ -531,12 +529,17 @@ def test_the_unpinned_control_does_resolve_the_persistent_arm(self, monkeypatch) def _real_dispatcher_or_skip(monkeypatch) -> dict: - """The shipped gfx950 profile, bound, or a skip when rocKE cannot import.""" + """The shipped gfx950 profile, bound, or a skip when the rocKE tree is absent + or cannot import. The root is found from this file's location (a checkout or + a `git archive` extract alike), so a skip here means the tree really lacks it.""" import importlib profile = dispatch_parity._load_profile(str(_SHIPPED_PROFILE)) monkeypatch.setattr(sys, "path", list(sys.path)) - dispatch_parity._bind_provider(profile["provider_root"]) + try: + dispatch_parity._bind_provider(profile["provider_root"]) + except dispatch_parity.ParityError as exc: + pytest.skip(f"the rocKE tree is not present in this tree ({exc})") try: importlib.import_module(profile["dispatch"]["module"]) except ImportError as exc: diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_knob_sweep.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_knob_sweep.py index 4ed77f5439fa..5ddaa86cb373 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_knob_sweep.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_knob_sweep.py @@ -159,11 +159,14 @@ def _sweep(self, monkeypatch, tmp_path, capsys): import importlib import json - from dispatch_parity import _bind_provider, _load_profile + from dispatch_parity import ParityError, _bind_provider, _load_profile profile = _load_profile(str(_SHIPPED_PROFILE)) monkeypatch.setattr(sys, "path", list(sys.path)) - _bind_provider(profile["provider_root"]) + try: + _bind_provider(profile["provider_root"]) + except ParityError as exc: + pytest.skip(f"the rocKE tree is not present in this tree ({exc})") try: importlib.import_module(profile["dispatch"]["module"]) except ImportError as exc: diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_launch_surface.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_launch_surface.py index 3c75e5c1f661..465a12a5c0bd 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_launch_surface.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_launch_surface.py @@ -579,9 +579,22 @@ class TestRepoRootResolution: """The CLI resolves cpp_mirror/test paths against the REPO ROOT, not the process cwd, so the same profile reports the same result from anywhere.""" - def test_find_repo_root_locates_the_git_checkout(self): + def test_find_repo_root_locates_the_tree_holding_this_tool(self): + """A checkout or a `git archive` extract: either way the root is the + directory holding projects/hipdnn, never the tools directory.""" found = launch_surface.find_repo_root(_TOOLS) - assert (found / ".git").exists() + assert (found / "projects" / "hipdnn" / "tools" / "IngestorGenerator").is_dir() + + def test_a_tree_without_git_metadata_resolves_to_its_root(self, tmp_path): + """`git archive` drops .git. Falling back to the start directory made every + repo-relative profile path resolve under tools/.""" + if any((p / ".git").exists() for p in (tmp_path, *tmp_path.parents)): + pytest.skip("the temporary directory sits inside a git checkout") + tools = ( + tmp_path / "projects" / "hipdnn" / "tools" / "IngestorGenerator" / "tools" + ) + tools.mkdir(parents=True) + assert launch_surface.find_repo_root(tools) == tmp_path.resolve() def test_check_from_a_nested_cwd_matches_check_from_the_repo_root(self): from_root = _run( diff --git a/projects/hipdnn/tools/IngestorGenerator/tools/launch_surface.py b/projects/hipdnn/tools/IngestorGenerator/tools/launch_surface.py index 98ab53b29865..19cf78ac2c9d 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tools/launch_surface.py +++ b/projects/hipdnn/tools/IngestorGenerator/tools/launch_surface.py @@ -101,14 +101,24 @@ def _path_part(value: str) -> str: return value.split(":", 1)[0].strip() +#: A directory every rocm-libraries tree has at its root, checkout or not. +_REPO_MARKER = Path("projects") / "hipdnn" + + def find_repo_root(start: Path) -> Path: - """The nearest ``.git`` ancestor of ``start``, or the resolved ``start``. - Callers anchor on this script's location rather than ``Path.cwd()``, since - every path a surface names is repo-relative. Always absolute.""" + """The nearest ``.git`` ancestor of ``start``; else, for a tree without git + metadata (``git archive``, a tarball), the nearest ancestor holding + ``projects/hipdnn``; else the resolved ``start``. Callers anchor on this + script's location rather than ``Path.cwd()``, since every path a surface + names is repo-relative. Always absolute.""" current = start.resolve() - for candidate in (current, *current.parents): + ancestors = (current, *current.parents) + for candidate in ancestors: if (candidate / ".git").exists(): return candidate + for candidate in ancestors: + if (candidate / _REPO_MARKER).is_dir(): + return candidate return current From 09b48758cc9f25717d8e4cf46e2d1251a5a92dc2 Mon Sep 17 00:00:00 2001 From: yassinsolim <108886216+yassinsolim@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:21:30 -0600 Subject: [PATCH 9/9] fix(hipdnn): dispatch_parity resolves knob-dependent metadata at the pinned knob --knobs pinned the tile after the policy-owned metadata had been resolved at the dispatcher's default tile. On gfx942 v_row_pad follows block_n, so the block_n=32 catalog said v_row_pad 0 where the binary is built with 32, and hkp_pack refused it (942:S6-16). Each knob combination is now applied to the spec first; the builder's spec class and the predicate validate it (a refused combination is dropped and reported), and only then is the metadata resolved from that spec. --knobs also accepts a list of mappings that names each combination to emit, since an approved tile set such as {256/64, 128/32} is not a cross-product (942:S6-9). --- .../tests/test_dispatch_parity.py | 102 ++++++++++ .../tools/dispatch_parity.py | 192 ++++++++++++------ 2 files changed, 235 insertions(+), 59 deletions(-) diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py index b5f4c134b542..7c3d10d97b35 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py @@ -710,3 +710,105 @@ def test_a_real_sink_prefill_trace_is_out_of_contract(self, monkeypatch, tmp_pat assert sink.kind == "out_of_contract", sink assert "sink_token" in sink.reason assert control.spec is not None, "the sinkless twin is still served" + + +#: A builder whose policy-owned field follows the tile, as gfx942's v_row_pad +#: follows block_n, and whose spec refuses a tile that does not divide the length. +_KNOB_STUB = """ +import dataclasses + + +@dataclasses.dataclass(frozen=True) +class Spec: + seqlen_q: int + head_size: int + block_n: int = 64 + v_row_pad: object = None + + def __post_init__(self): + if self.seqlen_q % self.block_n: + raise ValueError(f"seqlen_q must be a multiple of block_n={self.block_n}") + + def resolved_v_row_pad(self): + return v_row_pad(self.block_n) + + +def v_row_pad(block_n): + return 32 if block_n == 32 else 0 + + +def supports(spec, arch=None): + return True, "" +""" + + +class TestKnobPinsComeBeforeWhatDependsOnThem: + """942:S6-16: `--knobs` pinned the tile after the policy-owned metadata was + resolved at the dispatcher's tile, so the catalog said v_row_pad 0 where the + block_n=32 binary is built with 32, and packing refused it. 942:S6-9: an + approved tile SET could only be written as a cross-product.""" + + @staticmethod + def _build(tmp_path, monkeypatch, knobs): + (tmp_path / "knob_stub.py").write_text(_KNOB_STUB) + monkeypatch.syspath_prepend(str(tmp_path)) + monkeypatch.delitem(sys.modules, "knob_stub", raising=False) + import knob_stub + + fields = ["seqlen_q", "head_size", "block_n", "v_row_pad"] + profile = { + "slug": "stub", + "arch": "gfxstub", + "source": "stub.py", + "builder": "build_stub", + "engine": {"name": "stub:Engine"}, + "kmd_fields": [], + "metadata_fields": fields, + "predicate": {"module": "knob_stub", "function": "supports"}, + "arch_spec": {"module": "knob_stub", "class": "Spec"}, + "policies": { + "v_row_pad": { + "module": "knob_stub", + "function": "v_row_pad", + "args": ["block_n"], + } + }, + "specialization": { + "metadata_fields": fields, + "matcher_only_fields": [], + "bindings": { + **{f: {"field": f} for f in fields[:3]}, + "v_row_pad": {"method": "resolved_v_row_pad"}, + }, + }, + } + served = [ + dispatch_parity.Resolution( + {"seqlen_q": 512}, spec=knob_stub.Spec(seqlen_q=512, head_size=128) + ) + ] + config = dispatch_parity.build_config(served, profile, knobs) + return [k["metadata"] for k in config["packs"][0]["kernels"]] + + def test_policy_metadata_is_resolved_at_the_pinned_tile( + self, tmp_path, monkeypatch + ): + [metadata] = self._build(tmp_path, monkeypatch, {"block_n": [32]}) + assert metadata["block_n"] == 32 + assert metadata["v_row_pad"] == 32, "resolved at the dispatcher's block_n=64" + + def test_an_explicit_combination_list_emits_exactly_those( + self, tmp_path, monkeypatch + ): + rows = self._build(tmp_path, monkeypatch, [{"block_n": 64}, {"block_n": 32}]) + assert sorted((m["block_n"], m["v_row_pad"]) for m in rows) == [ + (32, 32), + (64, 0), + ] + + def test_a_combination_the_builder_refuses_is_dropped_and_reported( + self, tmp_path, monkeypatch, capsys + ): + rows = self._build(tmp_path, monkeypatch, {"block_n": [64, 48]}) + assert [m["block_n"] for m in rows] == [64] + assert "multiple of block_n=48" in capsys.readouterr().err diff --git a/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py b/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py index ba2a2e04082e..11e3b9bfc112 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py +++ b/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py @@ -38,7 +38,6 @@ import inspect import itertools import json -import math import os import re import sys @@ -505,6 +504,59 @@ def _specialization(profile: dict) -> dict: return copy.deepcopy(declaration) +def _knob_combos(knobs) -> list[dict]: + """The knob combinations to emit per shape. A mapping of value lists is + crossed (`{"block_m": [256, 128], "block_n": [64, 32]}` is four tiles); a + list of mappings names the combinations exactly (`[{"block_m": 256, + "block_n": 64}, {"block_m": 128, "block_n": 32}]` is two), since an approved + tile set is rarely a cross-product. No knobs is the single empty + combination: the parity set.""" + if not knobs: + return [{}] + if isinstance(knobs, dict): + for knob, values in knobs.items(): + if not isinstance(values, list) or not values: + raise ParityError( + f"--knobs entry '{knob}' must be a non-empty list of values, got " + f"{values!r}. An empty list's cross-product is empty, which would " + f"silently emit ZERO kernels instead of failing here." + ) + names = sorted(knobs) + return [ + dict(zip(names, combo)) + for combo in itertools.product(*[knobs[k] for k in names]) + ] + if isinstance(knobs, list) and all( + isinstance(combo, dict) and combo for combo in knobs + ): + return [dict(combo) for combo in knobs] + raise ParityError( + "--knobs must be a mapping of knob name to a list of values (crossed), or " + "a list of non-empty mappings (one per combination to emit)." + ) + + +def _refuses(spec: dict, arch_cls, predicate, profile: dict) -> str | None: + """Why the builder or the predicate refuses `spec`, a pinned variant, or + None. The builder's spec class validates the combination (a tile must + divide the canonical lengths, fit LDS), and the predicate is asked about the + final spec, as knob_sweep does for an arm.""" + built = spec + if arch_cls is not None: + try: + built = arch_cls(**spec) + except Exception as exc: # the builder's own refusal, per combination + return f"{type(exc).__name__}: {exc}" + if predicate is not None and arch_cls is not None: + arch = profile.get("arch") + ok, why = _predicate_result( + predicate, built, **({"arch": arch} if arch else {}) + ) + if not ok: + return f"unsupported: {why}" + return None + + def build_config( resolutions: list[Resolution], profile: dict, knobs: dict | None = None ) -> dict: @@ -541,8 +593,8 @@ def build_config( arch_field_names.add(field.name) if field.default is not dataclasses.MISSING: arch_defaults[field.name] = field.default - knobs = knobs or {} - for knob, values in knobs.items(): + combos = _knob_combos(knobs) + for knob in sorted({k for combo in combos for k in combo}): if knob not in metadata_fields: raise ParityError( f"--knobs names '{knob}', which this profile's metadata_fields does " @@ -550,12 +602,6 @@ def build_config( f"resolveDescriptorSets(), so crossing on one would emit a package " f"that cannot load. Declared: {', '.join(metadata_fields) or '(none)'}." ) - if not isinstance(values, list) or not values: - raise ParityError( - f"--knobs entry '{knob}' must be a non-empty list of values, got " - f"{values!r}. An empty list's cross-product is empty, which would " - f"silently emit ZERO kernels instead of failing here." - ) # A knob the builder's spec does not accept can only be written to # metadata, which makes both arms name the same binary under two # catalog entries; the sweep then reports "no effect" for a knob whose @@ -569,22 +615,17 @@ def build_config( f"kernel and the sweep would measure nothing. Either the name is " f"wrong, or the field is not a build-time knob of this kernel." ) - kernels = [] - # Shapes that resolve to one identical spec (always the case for shapes - # differing only in canonicalised runtime fields) are one compiled kernel, - # so one catalog entry. - emitted: set = set() - for resolution in resolutions: - if resolution.spec is None: - continue - spec = { - f.name: getattr(resolution.spec, f.name) - for f in dataclasses.fields(resolution.spec) - } - identity = repr(sorted(spec.items())) - if identity in emitted: - continue - emitted.add(identity) + predicate_decl = profile.get("predicate") or {} + predicate = ( + _import(*_required(predicate_decl, "predicate", "module", "function")) + if predicate_decl and any(combos) + else None + ) + + def resolve_metadata(spec: dict) -> dict: + """The catalog key of `spec`, with policy-owned fields resolved by the + kernel's own policy AT THIS SPEC's values: a pinned tile changes what + the policy answers (gfx942 v_row_pad follows block_n).""" metadata = {} for name in metadata_fields: if name in resolvers and spec.get(name) is None: @@ -610,25 +651,48 @@ def build_config( if isinstance(mapping, dict): value = mapping.get(value, value) metadata[name] = value - # The shipping cross-product: dispatcher-resolved shapes crossed with - # the knobs that earned a slot. Not a pack `axes:` block, since axes + return metadata + + dropped: dict = {} + kernels = [] + # Shapes that resolve to one identical spec (always the case for shapes + # differing only in canonicalised runtime fields) are one compiled kernel, + # so one catalog entry. + emitted: set = set() + for resolution in resolutions: + if resolution.spec is None: + continue + spec = { + f.name: getattr(resolution.spec, f.name) + for f in dataclasses.fields(resolution.spec) + } + identity = repr(sorted(spec.items())) + if identity in emitted: + continue + emitted.add(identity) + # The shipping set: dispatcher-resolved shapes crossed with the knob + # combinations that earned a slot. Not a pack `axes:` block, since axes # cross one kernel_template and here every shape carries its own spec. - # Knobs absent from --knobs keep their policy-resolved value; with no - # --knobs this is a single empty combination, the parity set. - axis_names = sorted(knobs) - for combo in itertools.product(*[knobs[k] for k in axis_names]): - pinned = dict(zip(axis_names, combo)) - variant_metadata = dict(metadata) - variant_spec = dict(spec) - for knob, value in pinned.items(): - variant_metadata[knob] = ( - int(value) if isinstance(value, bool) else value + # Knobs absent from a combination keep their policy-resolved value; + # with no --knobs this is a single empty combination, the parity set. + for pinned in combos: + # Pins first, then everything that depends on them: the metadata + # the policy resolves, and the builder's own validation. Resolving + # at the dispatcher's tile and pinning afterwards wrote a catalog + # key the compiled binary contradicts (942:S6-16). + # Written into the spec, not only the metadata: the spec is what the + # builder compiles, and an arch-private knob is absent from the + # shared spec, so a `knob in variant_spec` guard would skip exactly + # the knobs worth sweeping. + variant_spec = {**spec, **pinned} + if pinned: + why = _refuses( + variant_spec, arch_cls if arch_decl else None, predicate, profile ) - # Write it into the spec, not only the metadata: the spec is - # what the builder compiles, and an arch-private knob is absent - # from the shared spec, so a `knob in variant_spec` guard would - # skip exactly the knobs worth sweeping. - variant_spec[knob] = value + if why: + dropped[why] = dropped.get(why, 0) + 1 + continue + variant_metadata = resolve_metadata(variant_spec) name = _kernel_name(slug, variant_spec, variant_metadata, metadata_fields) kernels.append( { @@ -642,6 +706,17 @@ def build_config( "metadata": variant_metadata, } ) + if dropped: + # A combination the builder or predicate refuses for a shape is not + # emitted: it would fail at pack time, or ship a kernel the engine + # declines. Reported, since the shipped set is then narrower. + print( + f" WARNING: {sum(dropped.values())} knob variant(s) not emitted; the " + f"builder or predicate refuses them:", + file=sys.stderr, + ) + for why, count in sorted(dropped.items(), key=lambda kv: -kv[1]): + print(f" {count:4d} x {why}", file=sys.stderr) # Kernels the name fields cannot tell apart get a digest of their spec, and # the list is ordered by name, so neither names nor descriptor order depend # on the order of the shape corpus. @@ -743,10 +818,13 @@ def main(argv=None) -> int: ) parser.add_argument( "--knobs", - help="JSON mapping of knob name to the list of values that SURVIVED the " - "sweep, e.g. '{\"use_exp2_fast\": [0, 1]}'. The dispatcher-resolved set is " - "crossed with it to build the shipping package (RUNBOOK §6). Omit it and " - "you get the parity set: one kernel per servable shape.", + help="The knobs that SURVIVED the sweep (RUNBOOK §6), as JSON: a mapping of " + "knob name to a list of values, crossed (e.g. '{\"use_exp2_fast\": [0, 1]}'), " + "or a list of mappings naming each combination to emit (e.g. " + '\'[{"block_m": 256, "block_n": 64}, {"block_m": 128, "block_n": 32}]\'). ' + "Pinned values are applied before policy-owned metadata is resolved and the " + "builder validates each combination. Omit it and you get the parity set: " + "one kernel per servable shape.", ) args = parser.parse_args(argv) @@ -820,17 +898,14 @@ def main(argv=None) -> int: if args.out: try: knobs = json.loads(args.knobs) if args.knobs else {} - if not isinstance(knobs, dict): - raise ParityError( - f"--knobs must be a JSON mapping of knob name to a list of " - f"values, got {type(knobs).__name__}." - ) + combos = _knob_combos(knobs) + knob_names = sorted({k for combo in combos for k in combo}) config = build_config(catalog, profile, knobs) # Emit the compact form; build_config stays the source of truth and # `_compact` refuses anything that does not re-expand - # kernel-for-kernel. The knob axes are exactly --knobs, since the - # dispatcher returns one spec per shape. - text = _compact(config, sorted(knobs), profile) + # kernel-for-kernel. The knob axes are exactly the pinned knobs, + # since the dispatcher returns one spec per shape. + text = _compact(config, knob_names, profile) except json.JSONDecodeError as exc: print(f"FAIL: --knobs is not valid JSON: {exc}", file=sys.stderr) return 2 @@ -839,12 +914,11 @@ def main(argv=None) -> int: return 2 Path(args.out).write_text(text) count = len(config["packs"][0]["kernels"]) - if knobs: - arms = math.prod(len(v) for v in knobs.values()) + if knob_names: + arms = len(combos) print( - f"\n wrote {args.out}: {count} kernels " - f"= {count // arms} catalog entries ({len(served)} servable shapes) " - f"x {arms} surviving knob combination(s) ({', '.join(sorted(knobs))})" + f"\n wrote {args.out}: {count} kernels for {len(served)} servable " + f"shapes x {arms} knob combination(s) ({', '.join(knob_names)})" ) # The cap the runbook states, enforced where the number is known: # past the low thousands the pack time, the archive and the catalog