diff --git a/dnn-providers/hip-kernel-provider/rocke/TESTING.md b/dnn-providers/hip-kernel-provider/rocke/TESTING.md index 572e3a6131de..22ea03df5576 100644 --- a/dnn-providers/hip-kernel-provider/rocke/TESTING.md +++ b/dnn-providers/hip-kernel-provider/rocke/TESTING.md @@ -421,6 +421,18 @@ skip; a partial or internally inconsistent LLVM installation fails. Flavor auto-detection errors, including unavailable COMGR evidence, also remain failures during this migration. +The reproduction scheduler preserves exhaustive coverage while avoiding +redundant compiler startup. It batches declaration discovery, preflights each +target once, and combines expected-positive calls per architecture. The +committed artifact schedules those batches but cannot make them pass: every call +must survive compile, link, and target assembly, and a failed batch is bisected +to individual verdicts. Expected-negative cells remain individual and use +explicit operand witnesses. Independent work shares a quota-aware bounded +worker pool, with `--jobs` available for an explicit site override. +Expected crashes and timeouts whose normalized evidence still matches are not +retried; changed evidence, unexpected verdicts, and unclassified outcomes still +receive a contention-free retry. + The fast structure and generator units remain source-only in `platform/tests/tools/test_arch_domain_artifact.py`. Normal pytest and the `ROCKE_BACKEND=both` pytest pass do not re-run the compiler sweep. The dedicated diff --git a/dnn-providers/hip-kernel-provider/rocke/platform/tests/README.md b/dnn-providers/hip-kernel-provider/rocke/platform/tests/README.md index e2d536b48be8..065d58621262 100644 --- a/dnn-providers/hip-kernel-provider/rocke/platform/tests/README.md +++ b/dnn-providers/hip-kernel-provider/rocke/platform/tests/README.md @@ -102,6 +102,22 @@ source tool and committed source column, while the installed entry invokes a packaged test-only copy of the tool and the column in the relocated `rocke` package. This matches the library-side CI model: CMake stages every input, CTest owns execution, and no test reconstructs or reaches back into the source tree. + +Reproduction uses the committed column only to schedule work, never as its +verdict. Declaration recognition is batched through `opt` and recursively split +on failure. Each compiler/architecture pair receives an intrinsic-free target +preflight. Calls for expected-positive cells are combined per architecture; a +failed module is recursively split until ordinary single-cell probes identify +every changed result. Expected-negative cells remain individual, and every +positive call still passes through compile, link, and target assembly. Special +integer and `immarg` operands come from explicit `ProbeSpec` witnesses rather +than speculative value sweeps. Target preflights, positive batches, and +negative singletons share a quota-aware worker pool capped at 32 by default; +`--jobs` is the explicit override for constrained or dedicated hosts. An +expected crash or timeout whose normalized evidence still matches is not rerun +serially; changed evidence, changed verdicts, unclassified results, and otherwise +transient-looking results are. + The migration preserves the former pytest item's result mapping: | Condition | CTest result | diff --git a/dnn-providers/hip-kernel-provider/rocke/platform/tests/tools/test_arch_domain_artifact.py b/dnn-providers/hip-kernel-provider/rocke/platform/tests/tools/test_arch_domain_artifact.py index d1b2cc420dc1..3261e94112e1 100644 --- a/dnn-providers/hip-kernel-provider/rocke/platform/tests/tools/test_arch_domain_artifact.py +++ b/dnn-providers/hip-kernel-provider/rocke/platform/tests/tools/test_arch_domain_artifact.py @@ -279,6 +279,22 @@ def test_no_tool_sizes_a_pool_from_the_machine(self): self.assertNotIn("cpu_count", code, f"{name} sizes from the machine") self.assertIn("available_cpus", code, f"{name} sizes from nothing") + def test_arch_domain_default_is_quota_aware_and_bounded(self): + with mock.patch.object(G, "available_cpus", return_value=8): + self.assertEqual(G._probe_jobs(0), 8) + with mock.patch.object(G, "available_cpus", return_value=128): + self.assertEqual(G._probe_jobs(0), G.MAX_PROBE_JOBS) + + def test_explicit_jobs_override_the_default_ceiling(self): + with mock.patch.object(G, "available_cpus", return_value=8): + self.assertEqual(G._probe_jobs(48), 48) + + def test_jobs_parser_accepts_auto_and_positive_but_rejects_negative(self): + self.assertEqual(G._nonnegative_jobs("0"), 0) + self.assertEqual(G._nonnegative_jobs("48"), 48) + with self.assertRaisesRegex(G.argparse.ArgumentTypeError, "zero or greater"): + G._nonnegative_jobs("-1") + class DriftTest(unittest.TestCase): """What `--check` forgives, and what it must not. @@ -337,6 +353,340 @@ def test_an_unreadable_column_is_drift_not_a_crash(self): self.assertIn("not valid JSON", G._drift("{oops", self._doc())) +class NameDiscoveryBatchTest(unittest.TestCase): + _A = "declare float @llvm.fabs.f32(float)" + _B = "declare i32 @llvm.amdgcn.example(i32)" + + def test_healthy_declarations_share_one_opt_process(self): + output = ( + "declare float @llvm.fabs.f32(float) #0\n" + "declare i32 @llvm.amdgcn.example(i32) #1\n" + ) + completed = mock.Mock(returncode=0, stdout=output, stderr="") + with tempfile.TemporaryDirectory() as td, mock.patch.object( + G.subprocess, "run", return_value=completed + ) as run: + verdicts = G._name_exists_many( + "opt", [("fabs", self._A), ("example", self._B)], Path(td) + ) + + self.assertEqual(run.call_count, 1) + self.assertTrue(verdicts["fabs"].exists) + self.assertEqual(verdicts["fabs"].canonical, "llvm.fabs.f32") + self.assertTrue(verdicts["example"].exists) + + def test_alias_keys_share_one_declaration_query(self): + output = "declare float @llvm.fabs.f32(float) #0\n" + completed = mock.Mock(returncode=0, stdout=output, stderr="") + with tempfile.TemporaryDirectory() as td, mock.patch.object( + G.subprocess, "run", return_value=completed + ) as run: + verdicts = G._name_exists_many( + "opt", [("fabs", self._A), ("fabs-alias", self._A)], Path(td) + ) + + self.assertEqual(run.call_count, 1) + self.assertEqual(verdicts["fabs"], verdicts["fabs-alias"]) + self.assertTrue(verdicts["fabs"].exists) + + def test_a_failed_batch_is_bisected_to_singleton_answers(self): + failed = mock.Mock(returncode=1, stdout="", stderr="bad combined module") + a = mock.Mock(returncode=0, stdout=self._A + " #0\n", stderr="") + b = mock.Mock(returncode=0, stdout=self._B + " #0\n", stderr="") + with tempfile.TemporaryDirectory() as td, mock.patch.object( + G.subprocess, "run", side_effect=(failed, a, b) + ) as run: + verdicts = G._name_exists_many( + "opt", [("fabs", self._A), ("example", self._B)], Path(td) + ) + + self.assertEqual(run.call_count, 3) + self.assertTrue(verdicts["fabs"].exists) + self.assertTrue(verdicts["example"].exists) + + def test_remangling_is_a_positive_and_verbatim_is_absent(self): + remangled = "declare <4 x i16> @llvm.amdgcn.read.v4i16(i32)" + present = G._verdict_from_opt_output( + "declare <4 x i16> @llvm.amdgcn.read(i32)", [remangled] + ) + absent = G._verdict_from_opt_output(self._A, [self._A]) + + self.assertTrue(present.exists) + self.assertEqual(present.canonical, "llvm.amdgcn.read.v4i16") + self.assertFalse(absent.exists) + + +class PositiveBatchTest(unittest.TestCase): + def test_failed_batches_are_bisected_to_the_changed_cell(self): + batches = [] + singletons = [] + + def batch_status(keys): + batches.append(tuple(keys)) + return G.STATUS_ARCH_ABSENT if "changed" in keys else G.STATUS_OK + + def single_result(key): + singletons.append(key) + if key == "changed": + return G.STATUS_ARCH_ABSENT, "changed diagnostic", None + return G.STATUS_OK, "", None + + answers = G._localize_expected_positive( + ["left", "changed", "right", "also-right"], + batch_status, + single_result, + ) + + self.assertEqual(set(answers), {"left", "changed", "right", "also-right"}) + self.assertEqual(answers["left"], (G.STATUS_OK, "", None)) + self.assertEqual( + answers["changed"], + (G.STATUS_ARCH_ABSENT, "changed diagnostic", None), + ) + self.assertEqual(answers["right"], (G.STATUS_OK, "", None)) + self.assertEqual(answers["also-right"], (G.STATUS_OK, "", None)) + self.assertIn(("left", "changed", "right", "also-right"), batches) + self.assertEqual(singletons, ["left", "changed"]) + self.assertNotIn("right", singletons) + self.assertNotIn("also-right", singletons) + + def test_batch_module_keeps_every_call_and_operand_witness(self): + specs = [ + G.ProbeSpec("declare float @llvm.fabs.f32(float)", None, None), + G.ProbeSpec("declare i32 @llvm.example(i32 immarg)", None, 1), + ] + text, why = G._batch_probe_module(specs, "e-p:64:64") + + self.assertEqual(why, "") + self.assertEqual(text.count("call float @llvm.fabs.f32(float %a0)"), 1) + self.assertEqual(text.count("call i32 @llvm.example(i32 1)"), 1) + self.assertEqual(text.count("define amdgpu_kernel void @probe_"), 2) + + def test_stage_a_verdict_precedes_unsupported_target(self): + preflight = {"unsupported": (G.STATUS_TARGET_UNSUPPORTED, "invalid target ID")} + common = ("unsupported", "llvm20", {"missing"}, {}, {"unsupported"}, preflight) + self.assertEqual( + G._terminal_cell("missing", *common), + (G.STATUS_NAME_ABSENT, "not an intrinsic in llvm20"), + ) + self.assertEqual( + G._terminal_cell( + "unmeasured", + "unsupported", + "llvm20", + set(), + {"unmeasured": (G.STATUS_TOOLCHAIN_CRASH, "opt crashed")}, + {"unsupported"}, + preflight, + ), + (G.STATUS_TOOLCHAIN_CRASH, "opt crashed"), + ) + self.assertEqual( + G._terminal_cell( + "present", + "unsupported", + "llvm20", + set(), + {}, + {"unsupported"}, + preflight, + ), + (G.STATUS_TARGET_UNSUPPORTED, "invalid target ID"), + ) + + def test_scheduler_partitions_every_measurable_cell_once(self): + keys = ["positive", "negative", "absent", "unmeasured"] + arches = ["gfx-a", "gfx-b", "unsupported"] + expected = { + "positive": { + "gfx-a": {"status": G.STATUS_OK}, + "gfx-b": {"status": G.STATUS_ARCH_ABSENT}, + }, + "negative": { + "gfx-a": {"status": G.STATUS_ARCH_ABSENT}, + "gfx-b": {"status": G.STATUS_OK}, + }, + } + + batches, individual = G._schedule_probes( + keys, + arches, + {"absent"}, + {"unmeasured": (G.STATUS_TOOLCHAIN_CRASH, "diagnostic")}, + {"unsupported"}, + expected, + ) + + batched = {(key, arch) for arch, batch in batches.items() for key in batch} + singleton = set(individual) + self.assertEqual(batched, {("positive", "gfx-a"), ("negative", "gfx-b")}) + self.assertEqual(singleton, {("positive", "gfx-b"), ("negative", "gfx-a")}) + self.assertFalse(batched & singleton) + self.assertEqual( + batched | singleton, + { + (key, arch) + for key in ("positive", "negative") + for arch in ("gfx-a", "gfx-b") + }, + ) + + def test_target_preflight_contains_no_intrinsic_question(self): + text = G._target_preflight_module("e-p:64:64") + self.assertIn("@target_preflight", text) + self.assertNotIn("declare", text) + self.assertNotIn("llvm.", text) + + def test_preflight_expectations_come_from_the_committed_arch_cells(self): + expected = G._expected_preflight_statuses( + ["supported", "unsupported", "unmeasured"], + { + "present": { + "supported": {"status": G.STATUS_OK}, + "unsupported": {"status": G.STATUS_TARGET_UNSUPPORTED}, + }, + "absent": { + "supported": {"status": G.STATUS_NAME_ABSENT}, + "unsupported": {"status": G.STATUS_NAME_ABSENT}, + }, + }, + ) + self.assertEqual( + expected, + { + "supported": G.STATUS_OK, + "unsupported": G.STATUS_TARGET_UNSUPPORTED, + }, + ) + + def test_target_preflight_retries_transient_and_changed_answers_serially(self): + path = Path("probe.ll") + preflight = { + "transient": (G.STATUS_TOOLCHAIN_CRASH, "resource pressure"), + "changed": (G.STATUS_TARGET_UNSUPPORTED, "invalid target ID"), + "unsupported": (G.STATUS_TARGET_UNSUPPORTED, "invalid target ID"), + "supported": (G.STATUS_OK, ""), + } + expected = { + "transient": G.STATUS_OK, + "changed": G.STATUS_OK, + "unsupported": G.STATUS_TARGET_UNSUPPORTED, + "supported": G.STATUS_OK, + } + with tempfile.TemporaryDirectory() as td, mock.patch.object( + G, "_target_preflight", return_value=(G.STATUS_OK, "") + ) as probe: + retried = G._retry_target_preflights( + "clang", path, Path(td), preflight, expected + ) + + self.assertEqual(retried, ["transient", "changed"]) + self.assertEqual( + [call.args[2] for call in probe.call_args_list], + ["transient", "changed"], + ) + self.assertEqual(preflight["transient"], (G.STATUS_OK, "")) + self.assertEqual(preflight["changed"], (G.STATUS_OK, "")) + self.assertEqual( + preflight["unsupported"], + (G.STATUS_TARGET_UNSUPPORTED, "invalid target ID"), + ) + + +class RetryPolicyTest(unittest.TestCase): + def test_expected_deterministic_failures_are_not_retried(self): + self.assertFalse( + G._needs_serial_retry( + G.STATUS_TOOLCHAIN_CRASH, + G.STATUS_TOOLCHAIN_CRASH, + evidence="Segmentation fault", + expected_evidence="Segmentation fault", + ) + ) + self.assertFalse( + G._needs_serial_retry( + G.STATUS_TOOLCHAIN_TIMEOUT, + G.STATUS_TOOLCHAIN_TIMEOUT, + evidence="clang did not terminate within 60s", + expected_evidence="clang did not terminate within 60s", + ) + ) + + def test_expected_failures_with_changed_evidence_are_retried(self): + self.assertTrue( + G._needs_serial_retry( + G.STATUS_TOOLCHAIN_CRASH, + G.STATUS_TOOLCHAIN_CRASH, + evidence="Aborted", + expected_evidence="Segmentation fault", + ) + ) + self.assertTrue( + G._needs_serial_retry( + G.STATUS_TOOLCHAIN_TIMEOUT, + G.STATUS_TOOLCHAIN_TIMEOUT, + evidence="clang did not terminate within 30s", + expected_evidence="clang did not terminate within 60s", + ) + ) + + def test_changed_or_unclassified_results_are_retried(self): + self.assertTrue(G._needs_serial_retry(G.STATUS_TOOLCHAIN_CRASH, G.STATUS_OK)) + self.assertTrue(G._needs_serial_retry(G.STATUS_TOOLCHAIN_TIMEOUT, None)) + self.assertTrue( + G._needs_serial_retry(G.STATUS_PROBE_ERROR, G.STATUS_PROBE_ERROR) + ) + self.assertTrue(G._needs_serial_retry(G.STATUS_ARCH_ABSENT, G.STATUS_OK)) + self.assertTrue(G._needs_serial_retry(G.STATUS_OK, G.STATUS_ARCH_ABSENT)) + + def test_retry_scheduler_selects_every_changed_verdict(self): + work = [ + ("positive", "gfx"), + ("negative", "gfx"), + ("stable", "gfx"), + ("changed_crash", "gfx"), + ("stable_crash", "gfx"), + ] + results = { + "positive": {"gfx": {"status": G.STATUS_ARCH_ABSENT}}, + "negative": {"gfx": {"status": G.STATUS_OK}}, + "stable": {"gfx": {"status": G.STATUS_OK}}, + "changed_crash": { + "gfx": { + "status": G.STATUS_TOOLCHAIN_CRASH, + "evidence": "Aborted", + } + }, + "stable_crash": { + "gfx": { + "status": G.STATUS_TOOLCHAIN_CRASH, + "evidence": "Segmentation fault", + } + }, + } + expected = { + "positive": {"gfx": {"status": G.STATUS_OK}}, + "negative": {"gfx": {"status": G.STATUS_ARCH_ABSENT}}, + "stable": {"gfx": {"status": G.STATUS_OK}}, + "changed_crash": { + "gfx": { + "status": G.STATUS_TOOLCHAIN_CRASH, + "evidence": "Segmentation fault", + } + }, + "stable_crash": { + "gfx": { + "status": G.STATUS_TOOLCHAIN_CRASH, + "evidence": "Segmentation fault", + } + }, + } + self.assertEqual( + G._serial_retry_items(work, results, expected), + [("positive", "gfx"), ("negative", "gfx"), ("changed_crash", "gfx")], + ) + + class ClassifyTest(unittest.TestCase): """A diagnostic this tool does not recognise becomes `probe_error`. @@ -360,23 +710,6 @@ def test_a_target_that_says_so_in_words_is_arch_absent(self): self.assertEqual(status, G.STATUS_ARCH_ABSENT) self.assertTrue(evidence) - def test_an_illegal_transfer_size_asks_for_a_re_probe(self): - """llvm23 rejects an out-of-range load-to-LDS size in the verifier, - before codegen. The message is about our module, not the target, so it - must route to the operand rescue rather than be recorded as an answer. - """ - diag = ( - "error: invalid LLVM IR input: invalid data size for load-to-LDS " - "intrinsic; must be 1, 2, 4, 12, or 16" - ) - status, evidence = G._classify(1, diag) - self.assertEqual(status, G.STATUS_PROBE_ERROR) - self.assertTrue(G._wants_literals(status, evidence)) - - def test_the_sizes_llvm23_names_are_the_ones_we_try(self): - """The candidate list is not a guess; llvm23 enumerates the domain.""" - self.assertEqual(G._IMMARG_PROBE_VALUES, (1, 2, 4, 12, 16)) - def test_selection_dag_addresses_are_not_persisted(self): """Allocator-dependent node addresses must not make --check flaky.""" status, evidence = G._classify( @@ -478,16 +811,17 @@ def test_the_rejection_names_the_instruction_not_the_scratch_file(self): "assembler: instruction not supported on this GPU (v_cvt_scalef32_pk_f32_fp8)", ) - def test_an_instruction_the_target_lacks_is_arch_absent_though_it_links(self): + def test_a_positive_batch_still_runs_target_assembly(self): # The fused scale-and-convert links for gfx942 once its flag operand is - # a constant, and the object then holds a gfx950-only instruction. + # a constant, and the object then holds a gfx950-only instruction. Use + # the combined-module builder so this pins the optimized batch path. from check_ir_validity import _llvm_tool clang = _llvm_tool("clang") if clang is None or not G._llvm_mc_beside(clang).is_file(): self.skipTest("no clang with an llvm-mc beside it on this host") decl = G._decl_table(_resolve_llvm_flavor())["amdgcn.cvt.scalef32.pk.f32.fp8"] - module, why = G._probe_module(decl, "", literal_ints=G._LITERAL_PROBE_VALUES[0]) + module, why = G._batch_probe_module([G.ProbeSpec(decl, 0, None)], "") self.assertFalse(why) with tempfile.TemporaryDirectory() as scratch: path = Path(scratch) / "probe.ll" @@ -637,58 +971,75 @@ def test_an_attributed_addrspace_param_still_takes_the_cast_branch(self): self.assertIn("call void @llvm.amdgcn.probe.cast(ptr addrspace(1) %p0,", text) -class ImmargSweepTest(unittest.TestCase): - """A negative must survive being asked with a legal operand value. - - The probe passes 0 for every `immarg`, and 0 is not a legal transfer size. - ISel rejects it with `Cannot select`, which is also what a genuinely - unsupported target says -- so re-asking with a different value is the only - discriminator. Both directions matter: a false `arch_absent` breaks a build - loudly, a false `ok` admits a kernel that cannot run. - """ +class ProbeWitnessTest(unittest.TestCase): + """Special operands are explicit inputs, not speculative retry values.""" _IMM_DECL = "declare void @llvm.probe.imm(ptr addrspace(1) nocapture, i32 immarg)" - def test_an_immarg_defaults_to_zero_and_the_sweep_moves_it(self): - base, _ = G._probe_module(self._IMM_DECL, "e-p:64:64-i64:64") - self.assertIn("i32 0)", base) - for imm in G._IMMARG_PROBE_VALUES: - with self.subTest(imm=imm): + def test_special_key_mappings_are_an_exact_contract(self): + self.assertEqual( + G._IMMARG_WITNESSES, + { + "global.load.lds": 1, + "raw.ptr.buffer.load.async.lds": 1, + "raw.ptr.buffer.load.lds": 1, + }, + ) + self.assertEqual( + G._LITERAL_WITNESSES, + { + "raw.ptr.buffer.load.async.lds": 4, + "raw.ptr.buffer.load.lds": 4, + }, + ) + + def test_every_special_key_builds_with_its_witnesses(self): + decl = "declare void @llvm.probe.imm(i32, i32 immarg)" + for key in G._IMMARG_WITNESSES: + with self.subTest(key=key): + spec = G._probe_spec(key, decl) text, why = G._probe_module( - self._IMM_DECL, "e-p:64:64-i64:64", imm_int=imm + spec.decl, + "e-p:64:64-i64:64", + literal_ints=spec.literal_ints, + imm_int=spec.imm_int, ) self.assertEqual(why, "") - self.assertIn(f"i32 {imm})", text) - - def test_a_declare_with_no_immarg_has_nothing_to_sweep(self): - """The generator decides what is sweepable by comparing module text, so - a declare the sweep cannot vary must produce an identical module -- - otherwise every negative costs four probes that change nothing. - """ - decl = "declare i32 @llvm.probe.plain(ptr addrspace(1) nocapture, i32)" - base, _ = G._probe_module(decl, "e-p:64:64-i64:64") - for imm in G._IMMARG_PROBE_VALUES: - text, _ = G._probe_module(decl, "e-p:64:64-i64:64", imm_int=imm) - self.assertEqual(text, base, f"imm_int={imm} varied a non-immarg decl") - - def test_a_rescued_cell_says_which_value_answered(self): - """Without it, a first-try `ok` and a fourth-candidate `ok` look alike. - - The value is the evidence that the cell was measured with a legal - operand, and it cannot be re-derived by hand afterwards. - """ + self.assertIn("i32 1)", text) + if key in G._LITERAL_WITNESSES: + self.assertIn("@llvm.probe.imm(i32 4, i32 1)", text) + + def test_load_to_lds_uses_its_declared_witness(self): + spec = G._probe_spec("global.load.lds", self._IMM_DECL) + self.assertEqual(spec.imm_int, 1) + text, why = G._probe_module(spec.decl, "e-p:64:64-i64:64", imm_int=spec.imm_int) + self.assertEqual(why, "") + self.assertIn("i32 1)", text) + + def test_an_ordinary_declaration_has_no_special_witness(self): + spec = G._probe_spec("ordinary", "declare i32 @llvm.probe.plain(i32)") + self.assertIsNone(spec.literal_ints) + self.assertIsNone(spec.imm_int) + + def test_recorded_witnesses_match_every_positive_special_cell(self): for flavor, path in _columns(): doc = json.loads(path.read_text()) - for key, row in doc["keys"].items(): - for arch, cell in row.items(): - if "probe_imm" not in cell: - continue - with self.subTest(flavor=flavor, key=key, arch=arch): - self.assertIn(cell["probe_imm"], G._IMMARG_PROBE_VALUES) - # The sweep only returns early on a win, so a value on - # a negative cell would be reported for a probe it did - # not decide. - self.assertEqual(cell["status"], G.STATUS_OK) + expected = { + (key, arch) + for key, witness in G._IMMARG_WITNESSES.items() + for arch, cell in doc["keys"].get(key, {}).items() + if cell["status"] == G.STATUS_OK + } + actual = { + (key, arch) + for key, row in doc["keys"].items() + for arch, cell in row.items() + if "probe_imm" in cell + } + self.assertEqual(actual, expected, flavor) + for key, arch in sorted(actual): + cell = doc["keys"][key][arch] + self.assertEqual(cell["probe_imm"], G._IMMARG_WITNESSES[key]) def test_global_load_lds_is_available_on_cdna_and_not_on_rdna(self): """The case that motivated the sweep, pinned in both directions. diff --git a/dnn-providers/hip-kernel-provider/rocke/platform/tools/gen_arch_domain.py b/dnn-providers/hip-kernel-provider/rocke/platform/tools/gen_arch_domain.py index 4a924782c3f2..189bea6a7203 100755 --- a/dnn-providers/hip-kernel-provider/rocke/platform/tools/gen_arch_domain.py +++ b/dnn-providers/hip-kernel-provider/rocke/platform/tools/gen_arch_domain.py @@ -13,13 +13,16 @@ # Two stages, because the two axes need different tools -Stage A -- the flavor axis -- asks whether this LLVM knows the name. Arch-free, -so it runs once per key, and it uses `opt -S`: LLVM resolves a recognised -`llvm.*` declare on parse (attaching attributes, remangling overloads) while an -unrecognised name round-trips verbatim. See `_name_exists`. - -Stage B -- the arch axis -- compiles AND LINKS a probe module per (key, arch) -for names that survived stage A. It must link for the same reason +Stage A -- the flavor axis -- asks whether this LLVM knows each name. Arch-free, +so all declarations share one `opt -S` module (recursively split only on +failure): LLVM resolves a recognised `llvm.*` declare on parse (attaching +attributes, remangling overloads) while an unrecognised name round-trips +verbatim. See `_name_exists_many`. + +Stage B -- the arch axis -- preflights each target once, then compiles AND LINKS +expected-positive calls in one module per architecture. A failed batch is +recursively split until changed cells receive individual diagnostics. Expected +negative cells remain individual. It must link for the same reason `check_ir_validity.py` does: a declare for a nonexistent intrinsic passes both `opt -passes=verify` and `clang -S`, since to the backend it is an ordinary external call. It runs in a subprocess because an unsupported intrinsic reaches @@ -85,7 +88,7 @@ import tempfile from importlib.resources import files as resource_files from pathlib import Path -from typing import NamedTuple +from typing import Callable, NamedTuple from _hostcaps import available_cpus from _llvm_identity import flavor_of_clang, tool_identity @@ -173,6 +176,12 @@ def default_out(flavor: str) -> Path: # probe hangs the run and `--check` never returns. PROBE_TIMEOUT_S = 60 +# Each worker owns an external compiler process, and successful probes briefly +# spawn the linker or assembler too. Respect the host/cgroup CPU allowance but +# stop at a bounded fan-out so large bare-metal hosts do not turn one CTest into +# an unbounded process burst. ``--jobs`` remains the explicit operator override. +MAX_PROBE_JOBS = 32 + # -------------------------------------------------------------------------- # Probe module synthesis @@ -298,37 +307,37 @@ def _return_type(ret: str) -> str: # resource as long as the *integer* operands are real values. _FAT_PTR = "ptr addrspace(8)" -# Literal values the immediate-operand rescue tries, in order. See -# `_probe_module`'s `literal_ints` and `_wants_literals`. -_LITERAL_PROBE_VALUES = (0, 4) +# Intrinsics with integer operands that must be concrete even though LLVM does +# not mark them ``immarg``. The value is a known-valid transfer size. +_LITERAL_WITNESSES = { + "raw.ptr.buffer.load.async.lds": 4, + "raw.ptr.buffer.load.lds": 4, +} -# Values the `immarg` sweep tries, in order, when the default 0 produced a -# negative. See `_probe_module`'s `imm_int` and the sweep in `run`. -# -# Not exhaustive: these are the byte counts a load-to-LDS transfer size -# accepts, as llvm23's verifier enumerates them. A selector operand (MFMA -# cbsz/blgp) is legal at 0 and never reaches the sweep. Smallest-first, so the -# recorded winner is the least surprising legal value. -_IMMARG_PROBE_VALUES = (1, 2, 4, 12, 16) - -# The ways LLVM says "that operand had to be an immediate". Same complaint, -# three vintages: llvm22 rejects it in the verifier, llvm20 has no such check -# and dies during type legalisation, llvm23 added a load-to-LDS size check that -# names the legal values outright. -_IMMEDIATE_OPERAND_DIAGS = ( - "immarg operand has non-immediate parameter", - "do not know how to expand this operator's operand", - "invalid data size for load-to-lds intrinsic", -) +# Intrinsics whose zero immediate is not semantically valid. These witnesses +# are part of the probe specification rather than values discovered by trying +# a generic sweep after a failure. +_IMMARG_WITNESSES = { + "global.load.lds": 1, + "raw.ptr.buffer.load.async.lds": 1, + "raw.ptr.buffer.load.lds": 1, +} -def _wants_literals(status: str, evidence: str) -> bool: - """True when a failure looks like our probe passed a variable where the - intrinsic demands a constant, rather than a real arch answer.""" - if status not in (STATUS_ARCH_ABSENT, STATUS_PROBE_ERROR): - return False - low = evidence.lower() - return any(d in low for d in _IMMEDIATE_OPERAND_DIAGS) +class ProbeSpec(NamedTuple): + """A declaration and its semantically valid operand witnesses.""" + + decl: str + literal_ints: int | None + imm_int: int | None + + +def _probe_spec(key: str, decl: str) -> ProbeSpec: + return ProbeSpec( + decl=decl, + literal_ints=_LITERAL_WITNESSES.get(key), + imm_int=_IMMARG_WITNESSES.get(key), + ) def _probe_module( @@ -336,6 +345,9 @@ def _probe_module( datalayout: str, literal_ints: int | None = None, imm_int: int | None = None, + *, + probe_name: str = "probe", + metadata_id: int = 0, ) -> tuple[str, str]: """Build a minimal module that declares an intrinsic and calls it. @@ -365,14 +377,11 @@ def _probe_module( targets lower them; the hand-written row omits `immarg` on three `i1` operands and `opt` supplies it. - `literal_ints`, when set, replaces every integer kernel argument with that - literal value. With the resolved declare in hand this is a backstop rather - than the main line: it still covers an operand LLVM does not mark `immarg` - but cannot legalise as a variable either (`raw.ptr.buffer.load.lds`'s size - operand), and it stays scoped to the diagnostics that say so -- a blanket - retry could constant-fold a genuinely unsupported intrinsic away and - manufacture an `ok`, which is the one error direction that would make the - artifact worse than no artifact. + `literal_ints`, when set by :class:`ProbeSpec`, replaces every integer + kernel argument with a known-valid literal. This covers an operand LLVM + does not mark `immarg` but cannot legalise as a variable either + (`raw.ptr.buffer.load.lds`'s size operand). Keeping the witness explicit + avoids speculative retries that could constant-fold an unsupported call. `imm_int`, when set, is the value given to every `immarg` operand in place of the default 0. A parameter that is already marked `immarg` is never @@ -382,8 +391,7 @@ def _probe_module( from the diagnostic alone: both say `Cannot select`. `global.load.lds` takes a per-lane transfer size whose legal values are 1, 2 and 4, so 0 recorded every CDNA target as incapable of an instruction they run in - production. The only way to tell the two apart is to ask again with a - different value. + production. Its :class:`ProbeSpec` therefore starts with value 1. All `immarg` operands move together, as `literal_ints` does. A declare whose immediates have disjoint legal domains would need a cross product, @@ -408,7 +416,7 @@ def _probe_module( if ty == "metadata": # Not a first-class type. rocke's only metadata operands are the # av.* scope lists, which want a real scope node, not an empty one. - args.append("metadata !0") + args.append(f"metadata !{metadata_id}") elif is_imm: imm = 0 if imm_int is None else imm_int args.append(f"{ty} {('true' if imm else 'false') if ty == 'i1' else imm}") @@ -437,14 +445,47 @@ def _probe_module( f'target datalayout = "{datalayout}"\n' 'target triple = "amdgcn-amd-amdhsa"\n\n' f"{decl.strip()}\n\n" - f"define amdgpu_kernel void @probe({signature}) {{\n" + f"define amdgpu_kernel void @{probe_name}({signature}) {{\n" "entry:\n" + "".join(f"{line}\n" for line in prologue) + tail + " ret void\n" "}\n\n" - '!0 = !{!"agent"}\n' + f'!{metadata_id} = !{{!"agent"}}\n' ) return text, "" +def _batch_probe_module(specs: list[ProbeSpec], datalayout: str) -> tuple[str, str]: + """Build one module whose distinct kernels exercise every declaration.""" + headers: list[str] = [] + declarations: list[str] = [] + functions: list[str] = [] + metadata: list[str] = [] + for index, spec in enumerate(specs): + text, why = _probe_module( + spec.decl, + datalayout, + literal_ints=spec.literal_ints, + imm_int=spec.imm_int, + probe_name=f"probe_{index}", + metadata_id=index, + ) + if why: + return "", why + header, declaration, function, node = text.strip().split("\n\n") + if not headers: + headers.append(header) + if declaration not in declarations: + declarations.append(declaration) + functions.append(function) + metadata.append(node) + return ( + "\n\n".join( + [*headers, "\n".join(declarations), *functions, "\n".join(metadata)] + ) + + "\n", + "", + ) + + class NameVerdict(NamedTuple): """What the flavor axis learned about one declare. @@ -471,6 +512,116 @@ class NameVerdict(NamedTuple): evidence: str = "" +def _verdict_from_opt_output(decl: str, output_decls: list[str]) -> NameVerdict: + """Classify one input declaration from a successful ``opt -S`` output. + + Batched discovery can contain aliases for the same declaration and + overloaded intrinsics that LLVM remangles. Prefer an exact name, then a + remangled name with the input name as its prefix. If neither survives, + AutoUpgrade consumed the declaration, which is still a positive result. + """ + original = _DECL_RE.match(decl.strip()) + if not original: + return NameVerdict( + None, "", "", STATUS_PROBE_ERROR, f"cannot parse declare: {decl!r}" + ) + original_name = original.group(2) + + parsed: list[tuple[str, str, bool]] = [] + for line in output_decls: + attributed = re.search(r"#\d+\s*$", line) is not None + resolved = re.sub(r"\s*#\d+\s*$", "", line) + match = _DECL_RE.match(resolved) + if match: + parsed.append((match.group(2), resolved, attributed)) + + exact = next((item for item in parsed if item[0] == original_name), None) + if exact is not None: + canonical, resolved, attributed = exact + if attributed: + return NameVerdict(True, canonical, resolved) + return NameVerdict(False, "", "") + + remangled = [item for item in parsed if item[0].startswith(original_name + ".")] + if len(remangled) == 1: + canonical, resolved, _attributed = remangled[0] + return NameVerdict(True, canonical, resolved) + if len(remangled) > 1: + return NameVerdict( + None, + "", + "", + STATUS_PROBE_ERROR, + f"opt produced multiple canonical declarations for {original_name}", + ) + + # An unrecognised llvm.* declaration survives verbatim. A declaration + # that vanished was consumed by AutoUpgrade and therefore is recognised. + return NameVerdict(True, "(auto-upgraded)", "") + + +def _name_exists_many( + opt: str, items: list[tuple[str, str]], scratch: Path +) -> dict[str, NameVerdict]: + """Resolve declarations in one ``opt`` process, bisecting failed batches. + + A healthy flavor normally needs one process for the entire declaration + table. If one declaration or a transient tool failure poisons that module, + recursive splitting isolates it without losing answers for its neighbors. + Singleton leaves retain :func:`_name_exists`'s retry and diagnostics. + """ + if not items: + return {} + + # Alias keys can carry the exact same declaration. Repeating it in one + # module makes opt reject a harmless redefinition and forces recursive + # splitting. Resolve one representative and fan its verdict back out. + keys_by_decl: dict[str, list[str]] = {} + for key, decl in items: + keys_by_decl.setdefault(decl, []).append(key) + if len(keys_by_decl) != len(items): + representatives = [(keys[0], decl) for decl, keys in keys_by_decl.items()] + unique_verdicts = _name_exists_many(opt, representatives, scratch) + return { + key: unique_verdicts[keys[0]] + for keys in keys_by_decl.values() + for key in keys + } + + if len(items) == 1: + key, decl = items[0] + return {key: _name_exists(opt, decl, scratch)} + + stem = re.sub(r"[^A-Za-z0-9_.-]", "_", items[0][0]) + src = scratch / f"names.{stem}.{len(items)}.ll" + src.write_text( + "\n".join(decl.strip() for _key, decl in items) + "\n", encoding="utf-8" + ) + try: + proc = subprocess.run( + [opt, "-S", "-o", "-", str(src)], + capture_output=True, + text=True, + check=False, + timeout=PROBE_TIMEOUT_S, + cwd=str(scratch), + ) + except subprocess.TimeoutExpired: + proc = None + + if proc is None or proc.returncode != 0: + middle = len(items) // 2 + return { + **_name_exists_many(opt, items[:middle], scratch), + **_name_exists_many(opt, items[middle:], scratch), + } + + output_decls = [ + line for line in proc.stdout.splitlines() if line.startswith("declare ") + ] + return {key: _verdict_from_opt_output(decl, output_decls) for key, decl in items} + + def _name_exists(opt: str, decl: str, scratch: Path) -> NameVerdict: """Ask this LLVM whether it recognises the declare's name as an intrinsic. @@ -552,28 +703,10 @@ def _name_exists(opt: str, decl: str, scratch: Path) -> NameVerdict: status, f"opt exited {proc.returncode} on the declare: {_first_error(diag)}", ) - for line in proc.stdout.splitlines(): - if line.startswith("declare "): - attributed = re.search(r"#\d+\s*$", line) is not None - # The trailing `#N` references an attribute group this probe module - # will not carry, so it is dropped rather than propagated. - resolved = re.sub(r"\s*#\d+\s*$", "", line) - m = _DECL_RE.match(resolved) - canonical = m.group(2) if m else "" - if attributed: - return NameVerdict(True, canonical, resolved if m else "") - # Remangled but attribute-free: still a resolved intrinsic. - original = _DECL_RE.match(decl.strip()) - if original and canonical and canonical != original.group(2): - return NameVerdict(True, canonical, resolved if m else "") - return NameVerdict(False, "", "") - # The declare did not survive the round-trip at all. An unrecognised - # `llvm.*` name is an ordinary external function and is printed back - # verbatim even when unused, so a vanished declare means AutoUpgrade - # consumed it -- `amdgcn.global.atomic.fadd` becoming a plain `atomicrmw` - # is the live example. That still links, so the name counts as present. - # There is no resolved declare to build from; the hand-written row stands. - return NameVerdict(True, "(auto-upgraded)", "") + output_decls = [ + line for line in proc.stdout.splitlines() if line.startswith("declare ") + ] + return _verdict_from_opt_output(decl, output_decls) # -------------------------------------------------------------------------- @@ -686,12 +819,31 @@ def _first_error(text: str) -> str: return _normalize_dag_node_addresses(first)[:200] +def _target_preflight_module(datalayout: str) -> str: + """A target-only module used once per compiler/architecture pair.""" + return ( + f'target datalayout = "{datalayout}"\n' + 'target triple = "amdgcn-amd-amdhsa"\n\n' + "define amdgpu_kernel void @target_preflight() {\n" + "entry:\n" + " ret void\n" + "}\n" + ) + + +def _target_preflight(clang: str, path: Path, arch: str, out: Path) -> tuple[str, str]: + """Probe target support once; the scheduler owns any serial retry.""" + return _probe(clang, path, arch, out, verify_assembly=False) + + def _probe( clang: str, path: Path, arch: str, out: Path, timeout: float = PROBE_TIMEOUT_S, + *, + verify_assembly: bool = True, ) -> tuple[str, str]: """Compile AND LINK one probe module for one arch. @@ -750,7 +902,7 @@ def _probe( f"clang did not terminate within {timeout:g}s", ) status, evidence = _classify(proc.returncode, (proc.stderr or proc.stdout).strip()) - if status != STATUS_OK: + if status != STATUS_OK or not verify_assembly: return status, evidence return _assemble(clang, path, arch, out, timeout) @@ -946,6 +1098,168 @@ def _prune() -> int: return 0 +def _nonnegative_jobs(raw: str) -> int: + """Parse ``--jobs`` while preserving zero as the automatic sentinel.""" + value = int(raw) + if value < 0: + raise argparse.ArgumentTypeError("--jobs must be zero or greater") + return value + + +def _probe_jobs(requested: int) -> int: + """Resolve the quota-aware default while preserving an explicit override.""" + return requested or min(MAX_PROBE_JOBS, available_cpus()) + + +def _schedule_probes( + keys: list[str], + arches: list[str], + absent: set[str], + unmeasured: dict[str, tuple[str, str]], + unsupported_arches: set[str], + expected_cells: dict[str, dict[str, dict[str, str]]], +) -> tuple[dict[str, list[str]], list[tuple[str, str]]]: + """Partition every measurable cell into a positive batch or singleton.""" + positive_batches: dict[str, list[str]] = {arch: [] for arch in arches} + individual_work: list[tuple[str, str]] = [] + for key in keys: + if key in absent or key in unmeasured: + continue + for arch in arches: + if arch in unsupported_arches: + continue + if expected_cells.get(key, {}).get(arch, {}).get("status") == STATUS_OK: + positive_batches[arch].append(key) + else: + individual_work.append((key, arch)) + return positive_batches, individual_work + + +def _localize_expected_positive( + keys: list[str], + batch_status: Callable[[list[str]], str], + single_result: Callable[[str], tuple[str, str, int | None]], +) -> dict[str, tuple[str, str, int | None]]: + """Prove a positive batch or recursively isolate every changed cell.""" + if not keys: + return {} + if len(keys) == 1: + status, evidence, imm = single_result(keys[0]) + return {keys[0]: (status, evidence, imm)} + if batch_status(keys) == STATUS_OK: + return {key: (STATUS_OK, "", None) for key in keys} + middle = len(keys) // 2 + return { + **_localize_expected_positive(keys[:middle], batch_status, single_result), + **_localize_expected_positive(keys[middle:], batch_status, single_result), + } + + +def _needs_serial_retry( + status: str, + expected_status: str | None, + *, + evidence: str = "", + expected_evidence: str = "", +) -> bool: + """Whether a result needs a contention-free confirmation attempt. + + Any changed committed verdict is unexpected and gets confirmed. Matching + crash and timeout statuses are deterministic only when their normalized + evidence also matches. Without a committed expectation (generation and + target preflight), only a non-answer is plausibly transient. ``probe_error`` + always retries because it is the generator admitting it did not classify + the result. + """ + if status == STATUS_PROBE_ERROR: + return True + if expected_status is not None: + if status != expected_status: + return True + if status in (STATUS_TOOLCHAIN_CRASH, STATUS_TOOLCHAIN_TIMEOUT): + return evidence != expected_evidence + return False + return status in (STATUS_TOOLCHAIN_CRASH, STATUS_TOOLCHAIN_TIMEOUT) + + +def _expected_preflight_statuses( + arches: list[str], expected_cells: dict[str, dict[str, dict[str, str]]] +) -> dict[str, str]: + """Derive the committed target-support answer without using it as verdict.""" + expected: dict[str, str] = {} + for arch in arches: + statuses = { + cell["status"] + for row in expected_cells.values() + if (cell := row.get(arch)) is not None + } + if STATUS_TARGET_UNSUPPORTED in statuses: + expected[arch] = STATUS_TARGET_UNSUPPORTED + elif statuses: + expected[arch] = STATUS_OK + return expected + + +def _retry_target_preflights( + clang: str, + path: Path, + ir_dir: Path, + preflight: dict[str, tuple[str, str]], + expected_statuses: dict[str, str], +) -> list[str]: + """Confirm transient or changed preflights after their worker pool drains.""" + retry = [ + arch + for arch, (status, _evidence) in preflight.items() + if _needs_serial_retry(status, expected_statuses.get(arch)) + ] + for arch in retry: + preflight[arch] = _target_preflight( + clang, path, arch, ir_dir / f"target_preflight.{arch}.hsaco" + ) + return retry + + +def _terminal_cell( + key: str, + arch: str, + flavor: str, + absent: set[str], + unmeasured: dict[str, tuple[str, str]], + unsupported_arches: set[str], + preflight: dict[str, tuple[str, str]], +) -> tuple[str, str] | None: + """Return a non-Stage-B verdict, preserving flavor-axis precedence.""" + if key in absent: + return STATUS_NAME_ABSENT, f"not an intrinsic in {flavor}" + if key in unmeasured: + return unmeasured[key] + if arch in unsupported_arches: + _status, evidence = preflight[arch] + return STATUS_TARGET_UNSUPPORTED, evidence + return None + + +def _serial_retry_items( + work: list[tuple[str, str]], + results: dict[str, dict[str, dict[str, object]]], + expected_cells: dict[str, dict[str, dict[str, str]]], +) -> list[tuple[str, str]]: + """Select every transient or expectation-mismatched cell for confirmation.""" + retry: list[tuple[str, str]] = [] + for key, arch in work: + result = results[key][arch] + expected = expected_cells.get(key, {}).get(arch, {}) + if _needs_serial_retry( + str(result["status"]), + expected.get("status"), + evidence=str(result.get("evidence", "")), + expected_evidence=str(expected.get("evidence", "")), + ): + retry.append((key, arch)) + return retry + + def _unvalidated_exit(requested: int | None, legacy: int) -> int: """Map an existing unvalidated result to a scheduler-requested skip code. @@ -979,7 +1293,7 @@ def main() -> int: action="store_true", help="drop rows whose decl key is gone, on every column; needs no toolchain", ) - ap.add_argument("--jobs", type=int, default=0) + ap.add_argument("--jobs", type=_nonnegative_jobs, default=0) ap.add_argument("--verbose", action="store_true") ap.add_argument( "--unvalidated-exit-code", @@ -1128,11 +1442,68 @@ def main() -> int: print(f" arches : {', '.join(arches)}") print(f" keys : {len(keys)}") + out = args.out or default_out(flavor) + expected_cells: dict[str, dict[str, dict[str, str]]] = {} + if args.check and out.is_file(): + try: + expected_cells = json.loads(out.read_text()).get("keys", {}) + except json.JSONDecodeError: + # The final drift check owns the actionable malformed-JSON error. + # Until then, schedule conservatively as though no baseline exists. + pass + tmp = tempfile.TemporaryDirectory(prefix="rocke_arch_domain_") ir_dir = args.keep_ir if args.keep_ir else Path(tmp.name) ir_dir.mkdir(parents=True, exist_ok=True) datalayout = L._datalayout_for_flavor(flavor) + jobs = _probe_jobs(args.jobs) + + # Target support is an axis of the compiler/architecture pair, not of an + # intrinsic. Ask it once so a bad target ID cannot be rediscovered and + # recorded independently for every declaration. + preflight_path = Path(ir_dir) / "target_preflight.ll" + preflight_path.write_text(_target_preflight_module(datalayout)) + + def preflight_one(arch: str) -> tuple[str, tuple[str, str]]: + return arch, _target_preflight( + clang, + preflight_path, + arch, + Path(ir_dir) / f"target_preflight.{arch}.hsaco", + ) + + preflight: dict[str, tuple[str, str]] = {} + with cf.ThreadPoolExecutor(max_workers=min(jobs, len(arches))) as ex: + for arch, answer in ex.map(preflight_one, arches): + preflight[arch] = answer + preflight_retry = _retry_target_preflights( + clang, + preflight_path, + Path(ir_dir), + preflight, + _expected_preflight_statuses(arches, expected_cells), + ) + if preflight_retry: + print( + f" retried {len(preflight_retry)} non-definitive target preflight(s) " + "serially" + ) + unsupported_arches = { + arch + for arch, (status, _evidence) in preflight.items() + if status == STATUS_TARGET_UNSUPPORTED + } + broken_preflight = { + arch: answer + for arch, answer in preflight.items() + if answer[0] not in (STATUS_OK, STATUS_TARGET_UNSUPPORTED) + } + if broken_preflight: + print("\nFAIL: target preflight did not produce a support verdict:") + for arch, (status, evidence) in broken_preflight.items(): + print(f" {arch:14s} {status}: {evidence}") + return 1 # Stage A -- the flavor axis, once per key. Arch-free, so it costs one # `opt` run instead of one link per arch, and it is the only stage that can @@ -1145,12 +1516,11 @@ def main() -> int: # Keys whose flavor-axis probe did not produce an answer, with the no-data # status and the diagnostic that explains it. unmeasured: dict[str, tuple[str, str]] = {} + name_verdicts = _name_exists_many( + opt, [(key, decls[key]) for key in keys], Path(ir_dir) + ) for key in keys: - v = ( - _name_exists(opt, decls[key], Path(ir_dir)) - if opt - else NameVerdict(True, "", "") - ) + v = name_verdicts[key] if v.exists is None: unmeasured[key] = (v.status, v.evidence) elif v.exists: @@ -1178,50 +1548,43 @@ def main() -> int: # hand-written row covers the two cases that have none (no `opt` on this # host, and a declare AutoUpgrade consumed entirely). modules: dict[str, tuple[Path | None, str]] = {} - literal_modules: dict[str, list[Path]] = {} - imm_modules: dict[str, list[tuple[int, Path]]] = {} + specs: dict[str, ProbeSpec] = {} for key in keys: - decl = resolved.get(key, decls[key]) + spec = _probe_spec(key, resolved.get(key, decls[key])) + specs[key] = spec stem = re.sub(r"[^A-Za-z0-9_.-]", "_", key) - text, why = _probe_module(decl, datalayout) + text, why = _probe_module( + spec.decl, + datalayout, + literal_ints=spec.literal_ints, + imm_int=spec.imm_int, + ) if why: modules[key] = (None, why) continue path = ir_dir / f"{stem}.ll" path.write_text(text) modules[key] = (path, "") - # Two literal values, because neither is safe alone: 0 is rejected - # where the operand is a byte count, and a nonzero value is out of - # range where it is a small enum (an MFMA cbsz/blgp selector). - variants = [] - for lit in _LITERAL_PROBE_VALUES: - lit_text, why_lit = _probe_module(decl, datalayout, literal_ints=lit) - if not why_lit and lit_text != text: - lit_path = ir_dir / f"{stem}.lit{lit}.ll" - lit_path.write_text(lit_text) - variants.append(lit_path) - if variants: - literal_modules[key] = variants - # The immarg sweep, built the same way. A declare with no integer - # immarg yields a module identical to the base one, so the `!=` test - # is what decides sweepability -- no separate signature analysis to - # keep in step with `_probe_module`. - imm_variants = [] - for imm in _IMMARG_PROBE_VALUES: - imm_text, why_imm = _probe_module(decl, datalayout, imm_int=imm) - if not why_imm and imm_text != text: - imm_path = ir_dir / f"{stem}.imm{imm}.ll" - imm_path.write_text(imm_text) - imm_variants.append((imm, imm_path)) - if imm_variants: - imm_modules[key] = imm_variants # Stage B -- the arch axis, only for names that exist. A key stage A could # not measure is held out too: its probe would have to be built from the # hand-written row, which is the generator-4 construction that produced # false `arch_absent` cells. + positive_batches, individual_work = _schedule_probes( + keys, + arches, + absent, + unmeasured, + unsupported_arches, + expected_cells, + ) work = [ - (k, a) for k in keys if k not in absent and k not in unmeasured for a in arches + *individual_work, + *( + (key, arch) + for arch, batch_keys in positive_batches.items() + for key in batch_keys + ), ] def run(item: tuple[str, str]) -> tuple[str, str, str, str, int | None]: @@ -1231,80 +1594,56 @@ def run(item: tuple[str, str]) -> tuple[str, str, str, str, int | None]: return key, arch, STATUS_PROBE_ERROR, why, None out = Path(ir_dir) / f"{path.stem}.{arch}.hsaco" status, evidence = _probe(clang, path, arch, out) - # Rescue a suspected false negative: both diagnostics mean "this - # operand had to be an immediate", so a literal re-probe that lowers - # cleanly means our first probe was malformed, not the arch. - # - # Scoped to those two diagnostics: literals give the backend more - # information, so a blanket retry could constant-fold a genuinely - # unsupported intrinsic away and manufacture an `ok`. - if _wants_literals(status, evidence) and key in literal_modules: - # Try every literal before giving up -- a value out of range for - # one operand kind is in range for another, and stopping at the - # first non-OK answer would let an unlucky choice pass as the - # arch's verdict. - settled: tuple[str, str] | None = None - last: tuple[str, str] | None = None - for i, lit_path in enumerate(literal_modules[key]): - lit_out = Path(ir_dir) / f"{path.stem}.lit{i}.{arch}.hsaco" - lit_status, lit_evidence = _probe(clang, lit_path, arch, lit_out) - if lit_status == STATUS_OK: - return key, arch, STATUS_OK, "", None - last = (lit_status, lit_evidence) - if settled is None and not _wants_literals(lit_status, lit_evidence): - settled = last - # Every variant failed to legalise, and with no variable integer - # operands left that is the target speaking, not our probe -- - # gfx942 takes this call with a literal size and gfx1201 does not. - # The verifier's immarg complaint is excluded: surviving the sweep - # means the non-immediate operand is not an integer, which is our - # module's fault. - if settled is None and last and last[0] == STATUS_ARCH_ABSENT: - settled = last - status, evidence = settled or (status, evidence) - - # Sweep the immediates. Only on a negative, and only on the ~1 key in - # 6 whose declare has an integer immarg, so the common case still - # costs one probe. - # - # These two statuses only: `arch_absent` (ISel failed, and the operand - # value is one reason why) and `probe_error` (llvm23's verifier - # rejects an illegal transfer size before codegen -- a malformed - # module is what the sweep repairs). A crash, timeout or unsupported - # target says nothing about the operand value. - # - # Any legal value wins: the artifact answers "can this target lower - # this intrinsic", not "with the operand our probe happened to pick". - if status in (STATUS_ARCH_ABSENT, STATUS_PROBE_ERROR) and key in imm_modules: - settled_imm: tuple[str, str] | None = None - for imm, imm_path in imm_modules[key]: - imm_out = Path(ir_dir) / f"{path.stem}.imm{imm}.{arch}.hsaco" - imm_status, imm_evidence = _probe(clang, imm_path, arch, imm_out) - if imm_status == STATUS_OK: - return key, arch, STATUS_OK, "", imm - if settled_imm is None and imm_status != STATUS_PROBE_ERROR: - settled_imm = (imm_status, imm_evidence) - # Every candidate failed, so the operand value was not the - # obstacle. Where we started from `arch_absent` the original - # evidence already says so and is kept -- the cell should describe - # the probe as posed by default. - # - # Where we started from `probe_error` it does not: a legal value - # failed too, so leaving it would commit "the generator is broken" - # for a target that simply cannot lower the intrinsic. The first - # candidate with a real classification speaks instead. - if status == STATUS_PROBE_ERROR and settled_imm is not None: - status, evidence = settled_imm - - return key, arch, status, evidence, None + return ( + key, + arch, + status, + evidence, + specs[key].imm_int if status == STATUS_OK else None, + ) + + def run_positive_batch( + item: tuple[str, list[str]], + ) -> list[tuple[str, str, str, str, int | None]]: + """Validate expected-positive cells together, splitting on failure.""" + arch, batch_keys = item + + def batch_status(part: list[str]) -> str: + text, why = _batch_probe_module([specs[key] for key in part], datalayout) + if why: + return STATUS_PROBE_ERROR + first = re.sub(r"[^A-Za-z0-9_.-]", "_", part[0]) + path = Path(ir_dir) / f"batch.{arch}.{first}.{len(part)}.ll" + path.write_text(text) + status, _evidence = _probe(clang, path, arch, path.with_suffix(".hsaco")) + return status + + def single_result(key: str) -> tuple[str, str, int | None]: + _key, _arch, status, evidence, imm = run((key, arch)) + return status, evidence, imm + + # The committed positives schedule batches but never decide verdicts. + # A failed batch is split until ordinary single-probe classification + # identifies every changed cell. + answers = _localize_expected_positive(batch_keys, batch_status, single_result) + return [ + ( + key, + arch, + status, + evidence, + ( + imm + if imm is not None + else specs[key].imm_int if status == STATUS_OK else None + ), + ) + for key, (status, evidence, imm) in answers.items() + ] # Threads, not processes: each unit of work is already its own subprocess. - # - # The cap sits well under the core count: each probe is a clang that - # spawns ld.lld, so fan-out is ~2x the worker count and saturating a big - # host hits RLIMIT_NPROC. That surfaces as probe_error, and a probe_error - # that depends on machine load makes `--check` flaky. - jobs = args.jobs or min(8, available_cpus()) + # ``jobs`` is quota-aware and bounded above; successful probes can briefly + # add a linker or assembler child to each compiler worker. results: dict[str, dict[str, dict[str, str]]] = {k: {} for k in keys} def record( @@ -1313,43 +1652,63 @@ def record( cell: dict[str, object] = {"status": status, "verified_on": flavor} if evidence and status != STATUS_OK: cell["evidence"] = evidence - # Only on a cell the sweep rescued, so its absence means "the default - # 0 answered" -- the common case, not worth a field on 1100 cells. - # Present, it names the value that made the intrinsic lower, which is - # what distinguishes a first-try `ok` from a rescued one. + # Only on a cell whose ProbeSpec declares a non-default immediate. + # Present, it records the semantically valid witness used to establish + # the positive result. if imm is not None: cell["probe_imm"] = imm results[key][arch] = cell - for key in absent: - for arch in arches: - record(key, arch, STATUS_NAME_ABSENT, f"not an intrinsic in {flavor}") - - # The diagnostic `opt` actually produced, not a generic reason: "not an - # intrinsic in llvm20" written over an out-of-memory reads exactly like a - # real negative and cannot be falsified from inside the data. - for key, (status, evidence) in unmeasured.items(): + # Stage A answers precede target support: a spelling absent from this LLVM + # remains name_absent even where the compiler cannot target the requested + # architecture. Only declarations eligible for Stage B inherit the target + # preflight verdict. The same precedence preserves an opt failure's real + # diagnostic instead of laundering it into target_unsupported. + for key in keys: for arch in arches: - record(key, arch, status, evidence) + terminal = _terminal_cell( + key, + arch, + flavor, + absent, + unmeasured, + unsupported_arches, + preflight, + ) + if terminal is not None: + status, evidence = terminal + record(key, arch, status, evidence) with cf.ThreadPoolExecutor(max_workers=jobs) as ex: - for key, arch, status, evidence, imm in ex.map(run, work): + futures: dict[cf.Future, str] = {} + for arch, batch in positive_batches.items(): + if batch: + futures[ex.submit(run_positive_batch, (arch, batch))] = "batch" + for item in individual_work: + futures[ex.submit(run, item)] = "single" + + for future in cf.as_completed(futures): + if futures[future] == "batch": + for key, arch, status, evidence, imm in future.result(): + record(key, arch, status, evidence, imm) + continue + key, arch, status, evidence, imm = future.result() record(key, arch, status, evidence, imm) if args.verbose and status != STATUS_OK: print(f" {key:44s} {arch:14s} {status}") - # Second pass, serial: unclassified and crashed probes get one more chance - # with no contention. A real result is stable under retry, a resource - # failure is not -- llvm.fabs.f32 on gfx11-generic aborted on the process - # limit once in three runs, while permlane64 on the wave64 targets - # reproduces serially. The retry separates the two. + # Second pass, serial: unclassified, transient, and expectation-mismatched + # probes get one more chance with no contention. A real result is stable + # under retry, while a resource failure is not -- llvm.fabs.f32 on + # gfx11-generic aborted on the process limit once in three runs, while + # permlane64 on the wave64 targets reproduces serially. The retry separates + # the two and confirms any changed definitive verdict before reporting drift. # # A timeout retries on the same budget: the point is to remove contention, # not to grant more time to a module this small. - unstable = (STATUS_PROBE_ERROR, STATUS_TOOLCHAIN_CRASH, STATUS_TOOLCHAIN_TIMEOUT) - retry = [(k, a) for k, a in work if results[k][a]["status"] in unstable] + retry = _serial_retry_items(work, results, expected_cells) if retry: - print(f" retrying {len(retry)} unclassified/crashed probe(s) serially...") + print(f" retrying {len(retry)} unexpected/transient probe(s) serially...") for key, arch in retry: _k, _a, status, evidence, imm = run((key, arch)) record(key, arch, status, evidence, imm) @@ -1421,8 +1780,6 @@ def record( counts[s] = counts.get(s, 0) + 1 print("\n " + " ".join(f"{s}={n}" for s, n in sorted(counts.items()))) - out = args.out or default_out(flavor) - if args.check: if not out.is_file(): # Not a failure: a host can only measure its own flavor, so