Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -204,14 +210,28 @@ 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
# 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
# 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
Expand Down
410 changes: 391 additions & 19 deletions projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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]
Expand All @@ -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 == []


Expand All @@ -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):
Expand All @@ -135,3 +139,68 @@ 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 ParityError, _bind_provider, _load_profile

profile = _load_profile(str(_SHIPPED_PROFILE))
monkeypatch.setattr(sys, "path", list(sys.path))
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:
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]
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
170 changes: 170 additions & 0 deletions projects/hipdnn/tools/IngestorGenerator/tests/test_mine_shapes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Loading
Loading