diff --git a/projects/hipdnn/tools/IngestorGenerator/README.md b/projects/hipdnn/tools/IngestorGenerator/README.md index 465183a3f71c..3a33c897142e 100644 --- a/projects/hipdnn/tools/IngestorGenerator/README.md +++ b/projects/hipdnn/tools/IngestorGenerator/README.md @@ -376,6 +376,40 @@ echo '*.kdp.json diff=kdp' >> "$(git rev-parse --git-path info/attributes)" The splice tool appends rather than interleaves, so its output reads as pure insertion under every algorithm. +### Shipping a trained model + +`uhd_gen promote` copies a trained model into the bundle's `heuristics/` subtree and adds +its UHD id to the UED, per arch: `sort_kernel_catalog.` after `default`, or a new +role (`predict_engine`, `predict_applicable_kernels`) at the end of the document. The +generator writes the UED too, so record the same binding in the config, or the next +regeneration drops it (and the regeneration gate's byte compare fails): + +1. Run `uhd_gen promote` on the bundle. It prints the arch and the model's UHD id. +2. Add them to `engine.heuristics`, in the order promote added them. For a registry-driven + engine put them in the manifest's `engine` block (`configs/.engine.yaml`) **and** + the selection (`configs/.yaml`): `registry_enumerator.py` copies the manifest's + `engine` into every config it writes, and regeneration gate g4 requires the selection's + engine to equal it, so a binding only in the selection fails g4 and the next + enumeration drops it. + + ```yaml + engine: + heuristics: + sort_kernel_catalog: + gfx942: [4b750e22-2656-46ce-89e7-be9ba588d732] + ``` + +3. Regenerate. The UED comes out byte-identical to the promoted one; nothing else + changes, and the reader still resolves the native UHD from `default` only, so the + model UHD under `heuristics/` mints and reuses no id. +4. `dvc add heuristics` in the bundle directory, and commit the `.dvc` pointer. + +Roles are the three promote accepts. Keys are one concrete arch (`gfx942`, not `gfx9` or +`gfx950-dcgpu`); `default` is allowed except under `sort_kernel_catalog`, whose `default` +is the generator's native UHD (so that role also needs `heuristic: native`). Values are +non-empty lists of distinct UUIDs. A slotted engine refuses the key: its rankers are +per slot. Without `engine.heuristics` every output is unchanged. + ## Output The descriptor directory mirrors the authored root, so the emitted tree copies across @@ -877,6 +911,9 @@ engine: # Must be a single path stem (^[A-Za-z0-9_][A-Za-z0-9_-]*$). knobs: [block_size] # optional; must all be int-typed kmd_fields heuristic: native | none # optional, default "native"; "none" omits the UHD + heuristics: # optional; per-arch model UHDs `uhd_gen promote` bound, + sort_kernel_catalog: # role -> arch -> [UHD id]. See "Shipping a trained + gfx942: [] # model". Not allowed beside `slots`. kmd_fields: # the KMD's fields[] -- one per axis this engine's - name: block_size # kernels vary along diff --git a/projects/hipdnn/tools/IngestorGenerator/codegen/config_loader.py b/projects/hipdnn/tools/IngestorGenerator/codegen/config_loader.py index d8be70ce8ab9..f3056abf1468 100644 --- a/projects/hipdnn/tools/IngestorGenerator/codegen/config_loader.py +++ b/projects/hipdnn/tools/IngestorGenerator/codegen/config_loader.py @@ -209,6 +209,7 @@ def load_config(path: Path) -> IngestorConfig: census_name_symbol=engine_raw.get("census_name_symbol"), shared_workspace=engine_raw.get("shared_workspace", ""), slug_override=engine_raw.get("slug"), + heuristics=_load_heuristics(engine_raw, slotted), ) kmd_fields = _read_kmd_fields(raw.get("kmd_fields", []), "kmd_fields") @@ -735,6 +736,83 @@ def _require_sequence(value, scope: str, what: str = "field names") -> list: return list(value) +#: The UED roles `uhd_gen promote --role` binds per arch, in the order it accepts them. +HEURISTIC_ROLES = ( + "sort_kernel_catalog", + "predict_engine", + "predict_applicable_kernels", +) + +#: One concrete architecture name. Mirrors `device_probe.ARCH_SHAPE` and its family-word +#: refusal (tools/device_probe.py), which `codegen` cannot import; a test pins the two. +_ARCH_TOKEN = re.compile(r"gfx[0-9a-f]{3,}(?:-[a-z]+)*") +_ARCH_FAMILY_WORD = re.compile(r"-(?:generic|all|dcgpu|dgpu|igpu)(?:-|$)") +_UUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + + +def _is_arch_token(text) -> bool: + return ( + isinstance(text, str) + and _ARCH_TOKEN.fullmatch(text) is not None + and not _ARCH_FAMILY_WORD.search(text) + ) + + +def _load_heuristics(engine_raw: dict, slotted: bool) -> dict: + """Read ``engine.heuristics``: role -> arch -> UHD ids, as `uhd_gen promote` + writes them into the UED. Absent is ``{}``, which emits nothing.""" + raw = engine_raw.get("heuristics") + if raw is None: + return {} + scope = "engine.heuristics" + if slotted: + raise ConfigError( + f"{scope} is set on a slotted engine; a slotted engine binds its rankers " + f"per slot (slots[].ranker), so per-arch engine bindings are not supported." + ) + _require_mapping(raw, scope) + heuristics = {} + for role, arches in raw.items(): + if role not in HEURISTIC_ROLES: + raise ConfigError( + f"{scope} names role {role!r}; expected one of {list(HEURISTIC_ROLES)} " + f"(the roles `uhd_gen promote --role` accepts)." + ) + _require_mapping(arches, f"{scope}.{role}") + if not arches: + raise ConfigError(f"{scope}.{role} is empty; bind at least one arch.") + if role == "sort_kernel_catalog" and engine_raw.get("heuristic") == "none": + raise ConfigError( + f"{scope}.sort_kernel_catalog is set but engine.heuristic is 'none'; " + f"the arch entries follow the native 'default' UHD, which 'none' omits." + ) + bound = {} + for arch, ids in arches.items(): + key = f"{scope}.{role}.{arch}" + if arch == "default": + if role == "sort_kernel_catalog": + raise ConfigError( + f"{key}: the generator owns sort_kernel_catalog.default (the " + f"native UHD from engine.heuristic); bind arch keys only." + ) + elif not _is_arch_token(arch): + raise ConfigError( + f"{scope}.{role} key {arch!r} is not one concrete gfx architecture " + f"name (e.g. gfx942) or 'default'." + ) + ids = _require_sequence(ids, key, "UHD ids") + if not ids: + raise ConfigError(f"{key} is an empty list; bind at least one UHD id.") + for uhd in ids: + if not isinstance(uhd, str) or not _UUID.fullmatch(uhd): + raise ConfigError(f"{key} holds {uhd!r}, which is not a UUID.") + if len(set(ids)) != len(ids): + raise ConfigError(f"{key} repeats a UHD id: {ids!r}.") + bound[arch] = ids + heuristics[role] = bound + return heuristics + + def _drop_tag_slot(match) -> str: """Replace a `{tag}` slot and ONE adjacent separator when the tag is empty. @@ -1337,6 +1415,7 @@ def _require_config_shapes(raw: dict) -> None: "census_name_symbol", "shared_workspace", "slug", + "heuristics", } ) #: RFC 0021 §4's per-slot selection members, plus the slot's own ``specialization``. diff --git a/projects/hipdnn/tools/IngestorGenerator/codegen/generator.py b/projects/hipdnn/tools/IngestorGenerator/codegen/generator.py index 2a1e093cd70d..5a56c5be6932 100644 --- a/projects/hipdnn/tools/IngestorGenerator/codegen/generator.py +++ b/projects/hipdnn/tools/IngestorGenerator/codegen/generator.py @@ -458,13 +458,22 @@ def build_ued(config: IngestorConfig, ids: dict) -> dict: "graph_match": {"native": config.graph_match_symbol}, "metadata": ids["kmd"], } + bindings = config.engine.heuristics if config.engine.has_heuristic: # RFC 0019 §3.1: the loader rejects a bare top-level `heuristic` pointer. - ued["sort_kernel_catalog"] = {"default": ids["uhd"]} + # Arch entries follow `default`, where `uhd_gen promote` inserts them. + ued["sort_kernel_catalog"] = { + "default": ids["uhd"], + **bindings.get("sort_kernel_catalog", {}), + } if config.engine.knobs: ued["knobs"] = list(config.engine.knobs) if config.engine.behavior_notes: ued["behavior_notes"] = list(config.engine.behavior_notes) + # `promote` appends a new role at the end of the document. + for role, arches in bindings.items(): + if role != "sort_kernel_catalog": + ued[role] = dict(arches) return ued diff --git a/projects/hipdnn/tools/IngestorGenerator/codegen/models.py b/projects/hipdnn/tools/IngestorGenerator/codegen/models.py index 144de7fe7094..788cf0d0aa2b 100644 --- a/projects/hipdnn/tools/IngestorGenerator/codegen/models.py +++ b/projects/hipdnn/tools/IngestorGenerator/codegen/models.py @@ -435,6 +435,9 @@ class EngineSpec: #: slug splits before every capital, so a local name ending in a digit-capital pair #: (``Unified2D``) would come out as ``unified2_d``. slug_override: str | None = None + #: Per-arch heuristic bindings `uhd_gen promote` writes into the UED: role -> + #: arch (or `default`) -> UHD ids, in config order. Empty emits nothing. + heuristics: dict[str, dict[str, list[str]]] = field(default_factory=dict) @property def namespace(self) -> str: diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/fixtures/promote/gfx942_attention_unified3d.ued.json b/projects/hipdnn/tools/IngestorGenerator/tests/fixtures/promote/gfx942_attention_unified3d.ued.json new file mode 100644 index 000000000000..4bd940518064 --- /dev/null +++ b/projects/hipdnn/tools/IngestorGenerator/tests/fixtures/promote/gfx942_attention_unified3d.ued.json @@ -0,0 +1,20 @@ +{ + "version": "1.0", + "id": "4208dcf6-3128-4146-ab2a-affa5b6ab4eb", + "name": "hipkernel:Gfx942AttentionUnified3d", + "sdk_version": "1.0.0", + "graph_match": { + "native": "hipkernel.gfx942_attention_unified3d.graph_match" + }, + "metadata": "fa42fab2-67a1-4154-b45f-55b25c1f5265", + "sort_kernel_catalog": { + "default": "c3569304-2234-4570-b3ab-d3e007ebe52a", + "gfx942": [ + "4b750e22-2656-46ce-89e7-be9ba588d732" + ] + }, + "knobs": [ + "block_size", + "num_segments" + ] +} diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_config_loader.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_config_loader.py index 19e980237700..af9949edd00f 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_config_loader.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_config_loader.py @@ -3050,3 +3050,130 @@ def test_a_noise_policy_key_must_be_a_finite_non_negative_number( ): with pytest.raises(ConfigError, match=key): self._load(tmp_path, {**self.REDUCTION, key: value}) + + +class TestEngineHeuristics: + """``engine.heuristics``: the per-arch UHD bindings `uhd_gen promote` writes.""" + + MODEL = "4b750e22-2656-46ce-89e7-be9ba588d732" + OTHER = "73276c7b-f432-437b-9a06-8bb62920c823" + + @staticmethod + def _load(tmp_path, configs_dir, heuristics, base="scale_add.yaml", **engine): + raw = yaml.safe_load((configs_dir / base).read_text()) + raw["engine"].update(engine, heuristics=heuristics) + path = tmp_path / base + path.write_text(yaml.safe_dump(raw, sort_keys=False)) + return load_config(path) + + def test_bindings_load_in_config_order(self, tmp_path, configs_dir): + config = self._load( + tmp_path, + configs_dir, + { + "predict_engine": {"gfx950": [self.MODEL], "default": [self.OTHER]}, + "sort_kernel_catalog": {"gfx950": [self.OTHER], "gfx942": [self.MODEL]}, + }, + ) + bindings = config.engine.heuristics + assert list(bindings) == ["predict_engine", "sort_kernel_catalog"] + assert list(bindings["sort_kernel_catalog"]) == ["gfx950", "gfx942"] + assert bindings["predict_engine"]["default"] == [self.OTHER] + + def test_absent_is_empty(self, scale_add_config): + assert scale_add_config.engine.heuristics == {} + + @pytest.mark.parametrize( + "heuristics, match", + [ + ({"rank_engine": {"gfx942": [MODEL]}}, "role 'rank_engine'"), + ( + {"sort_kernel_catalog": {"gfx9": [MODEL]}}, + "sort_kernel_catalog key 'gfx9'", + ), + ( + {"sort_kernel_catalog": {"gfx950-dcgpu": [MODEL]}}, + "key 'gfx950-dcgpu'", + ), + ( + {"sort_kernel_catalog": {"default": [MODEL]}}, + r"sort_kernel_catalog\.default", + ), + ({"sort_kernel_catalog": {"gfx942": []}}, r"sort_kernel_catalog\.gfx942"), + ({"predict_engine": {"gfx942": ["not-a-uuid"]}}, r"predict_engine\.gfx942"), + ({"predict_engine": {"gfx942": MODEL}}, r"predict_engine\.gfx942"), + ( + {"predict_engine": {"gfx942": [MODEL, MODEL]}}, + r"predict_engine\.gfx942 repeats", + ), + ({"predict_engine": {}}, r"heuristics\.predict_engine is empty"), + ({"predict_engine": [MODEL]}, "predict_engine"), + ([MODEL], "engine.heuristics"), + ], + ) + def test_refusals_name_the_key(self, tmp_path, configs_dir, heuristics, match): + with pytest.raises(ConfigError, match=match): + self._load(tmp_path, configs_dir, heuristics) + + def test_arch_entries_need_the_native_default(self, tmp_path, configs_dir): + with pytest.raises(ConfigError, match="engine.heuristic is 'none'"): + self._load( + tmp_path, + configs_dir, + {"sort_kernel_catalog": {"gfx942": [self.MODEL]}}, + heuristic="none", + ) + + def test_refused_beside_slots(self, tmp_path): + fixture = Path(__file__).parent / "fixtures" / "slotted_three_slot.yaml" + raw = yaml.safe_load(fixture.read_text()) + raw["engine"]["heuristics"] = {"predict_engine": {"gfx942": [self.MODEL]}} + path = tmp_path / fixture.name + path.write_text(yaml.safe_dump(raw, sort_keys=False)) + with pytest.raises(ConfigError, match="engine.heuristics is set on a slotted"): + load_config(path) + + def test_refusal_exits_2(self, tmp_path, configs_dir, monkeypatch, capsys): + import generate + + raw = yaml.safe_load((configs_dir / "scale_add.yaml").read_text()) + raw["engine"]["heuristics"] = {"sort_kernel_catalog": {"gfx9": [self.MODEL]}} + path = tmp_path / "scale_add.yaml" + path.write_text(yaml.safe_dump(raw, sort_keys=False)) + argv = [ + "generate.py", + "--config", + str(path), + "--output-dir", + str(tmp_path / "o"), + ] + monkeypatch.setattr(sys, "argv", argv) + with pytest.raises(SystemExit) as exit_info: + generate.main() + assert exit_info.value.code == 2 + assert "engine.heuristics.sort_kernel_catalog" in capsys.readouterr().err + + @pytest.mark.parametrize( + "token", + [ + "gfx942", + "gfx90a", + "gfx1151", + "gfx1250-strict", + "gfx9", + "gfx11-generic", + "gfx950-dcgpu", + "gfx950-all", + "GFX942", + "default", + "sm90", + ], + ) + def test_arch_check_matches_device_probe(self, token): + tools = str(Path(__file__).parent.parent / "tools") + if tools not in sys.path: + sys.path.insert(0, tools) + import device_probe + from codegen.config_loader import _is_arch_token + + assert _is_arch_token(token) == device_probe.is_arch_token(token) diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_generator.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_generator.py index a706431d983e..12ed9de179bd 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_generator.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_generator.py @@ -20,6 +20,7 @@ from codegen.generator import ( PLACEHOLDER_MARKER, _dedup_key, + _dump, build_kdp, build_kdp_documents, build_kmd, @@ -3468,3 +3469,104 @@ def test_a_sibling_kernel_naming_neither_source_key_is_refused( ids_from=bundles.home_dir, engine_ids_from=[bundles.satellite_dir], ) + + +class TestHeuristicBindings: + """``engine.heuristics`` lands in the UED in the key order `uhd_gen promote` + writes, so a promoted UED and a regenerated one are byte-identical.""" + + MODEL = "4b750e22-2656-46ce-89e7-be9ba588d732" + OTHER = "73276c7b-f432-437b-9a06-8bb62920c823" + PROMOTED = Path(__file__).parent / "fixtures" / "promote" + + def test_arch_entries_follow_default_in_config_order(self, scale_add_config): + config = copy.deepcopy(scale_add_config) + config.engine.heuristics = { + "sort_kernel_catalog": {"gfx950": [self.OTHER], "gfx942": [self.MODEL]} + } + ids = mint_ids(config) + ued = build_ued(config, ids) + assert list(ued["sort_kernel_catalog"].items()) == [ + ("default", ids["uhd"]), + ("gfx950", [self.OTHER]), + ("gfx942", [self.MODEL]), + ] + assert list(ued).index("sort_kernel_catalog") < list(ued).index("knobs") + + def test_other_roles_follow_knobs_and_behavior_notes(self, scale_add_config): + config = copy.deepcopy(scale_add_config) + config.engine.heuristics = { + "predict_engine": {"gfx950": [self.MODEL, self.OTHER]}, + "predict_applicable_kernels": {"default": [self.OTHER]}, + } + ued = build_ued(config, mint_ids(config)) + assert list(ued)[-4:] == [ + "knobs", + "behavior_notes", + "predict_engine", + "predict_applicable_kernels", + ] + assert ued["predict_engine"] == {"gfx950": [self.MODEL, self.OTHER]} + assert ued["sort_kernel_catalog"] == { + "default": ued["sort_kernel_catalog"]["default"] + } + + def test_a_promoted_ued_regenerates_byte_identical(self): + """The fixture is `uhd_gen promote --role sort_kernel_catalog` run on the + gfx942 unified-3d bundle (AICK-2545): `default` + one gfx942 model, knobs after. + """ + # Text, not bytes: the generator writes LF, while an autocrlf checkout (Windows + # CI) holds this git-tracked fixture as CRLF, as test_registry_regeneration's g2 + # notes for the shipped descriptors. + promoted = (self.PROMOTED / "gfx942_attention_unified3d.ued.json").read_text( + encoding="utf-8" + ) + doc = json.loads(promoted) + config = make_minimal_config( + engine=make_engine( + name=doc["name"], + knobs=["block_size", "num_segments"], + heuristics={"sort_kernel_catalog": {"gfx942": [self.MODEL]}}, + ) + ) + ids = { + "ued": doc["id"], + "kmd": doc["metadata"], + "uhd": doc["sort_kernel_catalog"]["default"], + } + assert _dump(build_ued(config, ids)) == promoted + + def test_regeneration_keeps_bytes_and_ids_around_a_heuristics_tree( + self, generator, scale_add_config, tmp_path + ): + """A bundle holding `heuristics/**/*.uhd.json` (what promote copies in) + regenerates without new ids or errors, and only the UED changes.""" + generator.render(scale_add_config, tmp_path) + bundle = tmp_path / scale_add_config.descriptor_dir + before = json_bytes(bundle) + ued_name = next(n for n in before if n.endswith(".ued.json")) + ued_id = json.loads(before[ued_name])["id"] + model_dir = ( + bundle / "heuristics" / ued_id / "sort_kernel_catalog" / "gfx942" / "t" + ) + model_dir.mkdir(parents=True) + (model_dir / "heuristic.uhd.json").write_text( + json.dumps({"version": "1.0", "id": self.MODEL, "adapter": "tree_data"}), + encoding="utf-8", + ) + (model_dir / "model.bin").write_bytes(b"\0model") + + bound = copy.deepcopy(scale_add_config) + bound.engine.heuristics = {"sort_kernel_catalog": {"gfx942": [self.MODEL]}} + generator.render(bound, tmp_path) + after = json_bytes(bundle) + assert {n for n in before if before[n] != after[n]} == {ued_name} + catalog = json.loads(after[ued_name])["sort_kernel_catalog"] + assert catalog["gfx942"] == [self.MODEL] + assert ( + catalog["default"] + == json.loads(before[ued_name])["sort_kernel_catalog"]["default"] + ) + + generator.render(scale_add_config, tmp_path) + assert json_bytes(bundle) == before diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_prior_ids.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_prior_ids.py index 5e81b0bf27fb..d452784e3494 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_prior_ids.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_prior_ids.py @@ -372,3 +372,27 @@ def test_a_default_that_is_not_a_uhd_is_refused(self, generator, tmp_path): with pytest.raises(ValueError, match="sort_kernel_catalog.default"): resolve_ids(bundles.home, tmp_path / "out", ids_from=bundles.home_dir) + + +def test_a_heuristics_tree_resolves_without_new_ids(generator, tmp_path): + """`uhd_gen promote` copies a model UHD under `heuristics/` and adds an arch entry + beside `sort_kernel_catalog.default`; the reader keeps resolving the native UHD + from `default` and takes the extra document as no descriptor of its own.""" + bundles = home_and_satellite(generator, tmp_path) + baseline = resolve_ids(bundles.home, bundles.home_root, ids_from=bundles.home_dir) + (ued_path,) = bundles.home_dir.glob("*.ued.json") + ued = json.loads(ued_path.read_text(encoding="utf-8")) + model = str(uuid.uuid4()) + leaf = ( + bundles.home_dir / "heuristics" / ued["id"] / "sort_kernel_catalog" / "gfx942" + ) + leaf.mkdir(parents=True) + (leaf / "heuristic.uhd.json").write_text( + json.dumps({"version": "1.0", "id": model, "adapter": "tree_data"}), + encoding="utf-8", + ) + (leaf / "model.bin").write_bytes(b"\0model") + _edit(ued_path, lambda doc: doc["sort_kernel_catalog"].update(gfx942=[model])) + bound = resolve_ids(bundles.home, bundles.home_root, ids_from=bundles.home_dir) + assert bound.ids == baseline.ids + assert model not in bound.ids.values() diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_registry_enumerator.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_registry_enumerator.py index 7b024cd0d10a..34a450141221 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_registry_enumerator.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_registry_enumerator.py @@ -658,3 +658,21 @@ def test_a_shape_only_an_also_section_lists_is_covered(probe, capsys): 1 if gaps else 0 ) assert "covered: 1 shapes" in capsys.readouterr().out + + +def test_manifest_heuristics_reach_the_generated_engine_and_its_ued(probe): + """A promoted model's per-arch binding lives in the manifest's `engine` block: the + enumerator copies that block into the config it writes (so regeneration gate g4's + engine equality holds), and the generator emits it after `default`.""" + from codegen.config_loader import load_config + from codegen.generator import build_ued, mint_ids + + model = "8b3c960a-a47c-4ce3-96f8-343befdbc543" + manifest = copy.deepcopy(probe.manifest) + manifest["engine"]["heuristics"] = {"sort_kernel_catalog": {"gfx942": [model]}} + assert _run_probe(probe, manifest) == 0 + config = load_config(probe.out) + assert config.engine.heuristics == {"sort_kernel_catalog": {"gfx942": [model]}} + catalog = build_ued(config, mint_ids(config))["sort_kernel_catalog"] + assert list(catalog) == ["default", "gfx942"] + assert catalog["gfx942"] == [model]