Skip to content

[Feature] kernelentry: make "the simulator supports this kernel" a computed ledger - #213

Open
WarningRan wants to merge 10 commits into
torch-spyre:mainfrom
WarningRan:feature/issue209-kernelentry
Open

[Feature] kernelentry: make "the simulator supports this kernel" a computed ledger#213
WarningRan wants to merge 10 commits into
torch-spyre:mainfrom
WarningRan:feature/issue209-kernelentry

Conversation

@WarningRan

Copy link
Copy Markdown
Collaborator

Closes #209.

Summary

kernelentry turns "the simulator supports this kernel" from an adjective into a ledger: claims derived from the kernel itself, each closed or open with the file that would close it named. Every distinct op contributes a claim and every output tensor two, so the set grows with the kernel rather than with the tool.

Point it at an .mlir before any paperwork exists:

$ uv run python -m ktir_cpu.kernelentry probe examples/triton-ktir/paged_attention.mlir
paged_attention (no declaration)  function kernel_unified_attention_spyre_2d
  function leg   37/38 closed, 1 OPEN
  cost leg       0/1 closed, 1 UNDETERMINED
  OPEN          exec.runs
                no tensors= on the entry, so the kernel cannot be driven
                -> the entry's tensors= callable
  UNDETERMINED  cost.derivation
                the kernel did not run, so there is nothing to derive
                -> exec.runs

What needs to run the kernel says why it could not, instead of being skipped. Declaring it — one row in examples/entries.py — answers all five questions. probe writes nothing, adopt writes only the files it owns and prints rather than applies any interpreter change, and verify shares one engine with tests/test_kernelentry.py.

Eighteen of the thirty-three kernels under examples/ are declared, and no claim is open, deferred or waived anywhere in the repository. The other fifteen cannot be driven through the tensors= seam at all; the report names each with both reading answers, and docs/kernelentry.md groups them by which of four reasons applies.

What the tool found

Per kernel and per op it is all in docs/kernel_support.md and docs/supported_ops.md. The headlines:

  • Six of the thirty-three kernels are not accepted by the MLIR frontend, and main is green because the adapt suite covers eleven and none of those six — a coverage hole, not a break. Two are fixed here (the dialect prints the op another way: two lines of type syntax, cost figures identical to the digit) and four recorded, because the three ring_reduce* want a reduce that reshapes, which is a decision about the op rather than a rewrite (gap row 2a), and nested_yield.ktir is a separate reproducer.
  • One new gap: ktdp.region_terminator has a frontend adapter and no execution handler (row 36a). No kernel under examples/ reaches it, so the asymmetry is real but the construct's trigger is not established.
  • Two hand-maintained op inventories understated what is implementeddocs/gap_analysis.md rows 13–19 and 28, and README's Supported Subset. memref.subview is the one op they claimed that is genuinely missing.
  • Seventeen of the eighteen already had a reference under tests/, computed in f32 and then cast back to f16 before comparing — which discards exactly the bits the comparison exists to look at. Three were also narrower than their kernel: layernorm_fwd_ktir writes three tensors and its test checks two, and paged_attention's reference is the kernel's own algorithm at one of thirty-two grid positions.
  • ffn_swiglu_4core at unit-variance weights saturates half its gate tensor, so its row draws weights at fan-in scale to keep the comparison about whether the four shards folded. Single-core ffn_swiglu keeps unit variance, so the saturation stays somewhere in the repository.
  • One claim's stated motivation does not reproduce. out.*.nontrivial was justified by an f16 overflow in the RMSNorm notebook path, where zero_rows is 0 at all three of that notebook's configurations. It is kept on other grounds — see the second limitation.

Commits

A large change: 6488 lines added, of which 1149 are generated documents and 1054 are tests. Read in commit order; no commit rewrites an earlier one and each leaves the suite green.

  1. Per-op cost attribution in the latency trace. The trace already carried bytes and flops per op; they are now readable per tensor, which is what lets a derivation say which tensor dominates. No cost formula and no hardware constant changes.
  2. The declaration type, argument specs, and the repository-wide zero-price audit.
  3. The claim engine and the three verbs, plus docs/kernelentry.md.
  4. The two cross-core kernels rewritten in the form the dialect prints — two lines each, ahead of the reports so no generated file claims an acceptance the tree does not yet have.
  5. Eighteen declarations, as eighteen rows of one table. An argument is a spec resolved against the row's parameters, each draw seeded from the argument's own name — otherwise two arguments share values and a kernel that swapped its operands would still agree with its reference.
  6. Three generated documents — support per kernel, ops across the repository, cost derivations — committed and verbatim-compared, so adding a kernel without regenerating them turns the suite red.
  7. The gate: one parameterized suite over the declarations, plus a suite that breaks what each claim should notice.
  8. The two corrected inventories, pointing at the generated documents rather than restating them.

What is NOT changing

No cost formula and no hardware constant — the trace carries figures _estimate already produced. tests/conftest.py and EXAMPLE_PARAMS are untouched, and a gate_params is not a second copy of one: only one of the eighteen coincides with its execute_kwargs. No test is deleted or weakened, and notebooks/ is untouched. FRONTEND_UNSUPPORTED stays where it is, which is why there is deliberately no per-op frontend claim. No new ktdp op, attribute or semantics — the tool reports where a file and the dialect disagree without adjudicating it.

Test plan

uv run python -m ktir_cpu.kernelentry verify --all   # 18 kernel(s) fully supported, 16.5s
uv run pytest tests/ -q                              # 1892 passed, 12 skipped, 13 xfailed
uv run pytest -q --ignore=tests/mlir_frontend        # 1729 passed, 13 xfailed
uv run pytest tests/mlir_frontend/ -q                # 163 passed, 12 skipped (bindings present)
  • parse.frontend was checked only because the bindings are installed here. tests/mlir_frontend/ skips at module level without mlir_ktdp, and green from a run that skipped it is not green, so the frontend leg has a suite that fails if the claim comes back skip where the bindings exist. The rejections and acceptances above are measured, not asserted.
  • TestClaimsDetectViolations is the part worth reading. It removes a reference, swaps in an f16 one, adds a reference key no store wrote, staleness-checks an excuse and closes a deferral's gap, asserting the claim turns each time. A claim that cannot be made to fail there is decoration.
  • No xfail is added. Both counts moved because the rewrite closed two deferred parse.frontend claims — two xfails and two skips became passes.
  • The gate's cost is shape, not entry count. Five full-size kernels are 94% of the 16.5s, and they are declared anyway: a cost model checked only at reduced size is checked where the padding, the tail core and the page table are not real yet.

Follow-ups (not in this PR)

  • Audit the pricing registry against op semantics. @register() defaults latency_category to "zero", so "free" and "unpriced" are one state, and an op priced in the wrong category is still priced — no per-kernel claim substitutes. COMPUTE_INT has no assertion anywhere.
  • Tighten per-output tolerance where the default is too wide — two of the twenty close vacuously against it. The seam is already declarable, so this is choosing numbers.
  • ktdp.region_terminator needs an execution handler (row 36a), and whether ktdp.inter_tile_reduce should reshape at all (row 2a, corrected here to state the divergence rather than an upstream status).

Known limitations

  • cost.derivation cannot contradict the cost model. The breakdown and the totals come from the same code, so a mis-charged op moves both together. What it catches is prose disagreeing with the attribution — "the weight operand's traffic is negligible" against a row reading 72.7%. Whether the model charges correctly is tests/test_latency.py's question, asked by mechanism rather than per kernel.
  • On two of the twenty compared outputs the default atol=2e-1 exceeds the tensor's entire dynamic rangelayernorm_fwd_ktir's Mean and softmax_fwd_ktir's output — so those comparisons would also pass against an all-zero output, and out.*.nontrivial is the only claim behind them. Eleven of the twenty need no atol at all; the pair is sized by matmul_fwd_ktir, which needs 0.103 of it across 524288 f16 values.
  • One row declares a wider pair, and it is the loosest check here. ffn_swiglu's chained f16 matmuls accumulate through peaks far above the values they end on, so an element that cancels escapes a relative tolerance; atol comes from the tensor's largest peak rather than from the element that cancels at this seed, because a shape change moves which element that is. The arithmetic is written beside the row, the claim reports the pair even when it closes, and a test asserts that removing the override reopens the claim.
  • Four frontend rejections are recorded, not fixed, and nested_yield.ktir among them is a reproducer whose dialect validity is somebody else's open question.

`dram_bytes` answers how much traffic a kernel moved; nothing answered which
tensor moved it. That makes a claim about composition — "this weight tensor's
traffic is negligible" — uncheckable, because it is a statement about one term
of a total rather than about the total.

`_estimate` already computes each op's bytes and FLOPs and `record` already
receives them, but only the per-category sums were kept. Retain them per
operation instead, alongside the memory the operation addressed, and add
`LatencyReport.traffic_by_target()` to aggregate on it.

The target is the enclosing `MemRef.base_ptr` — an element index, so it compares
directly against the pointer `execute_function` binds to an argument, which
`KTIRInterpreter.arg_ptrs` now retains. Bytes whose origin is not resolvable are
kept under a `None` key rather than dropped: a breakdown that silently omits a
row adds up to less than the total while looking complete. A distributed memory
view appears as one key per partition, since `distributed_load` charges each
surviving partition separately; folding those into one row per argument needs
each argument's element extent, which the report does not know.

`tests/test_latency.py` covers both halves of the new method. The attribution is
hand-counted off the IR for `matmul_small` — one row per argument at the pointer
it was bound to, four times as many bytes against B as against A, and the rows
summing to `dram_bytes` — because a total of 45,056 says nothing about the split
and a sentence calling one of the two negligible survives the total but not the
breakdown. Without the trace the method raises rather than returning an empty
mapping, and it distinguishes the two ways there is nothing to report: a report
covering no core at all, and one covering cores that were never traced. Returning
an empty mapping for either would make a missing constructor argument look like a
finding about the kernel.

The same file gains a chip-wide hand count of bytes and FLOPs for `matmul_small`,
the companion to the existing single-core count for the simplest kernel, at exact
equality rather than a tolerance — both sides are integer counts, and a mis-charged
operation is exactly the small disagreement a tolerance hides. The stick-rule
arithmetic behind the two `matmul_small` hand counts — this one and the attribution
above — is written once and shared, so the two cannot drift into disagreeing about
the same kernel.

No cost formula changes. The trace only carries figures it was already given.

Signed-off-by: WarningRan <zhuoran.liu@ibm.com>
The three parts of kernelentry that stand on their own, ahead of the engine that
reads them: what a kernel declares, how its arguments are built, and the one check
that is asked of the repository rather than of any kernel.

A declaration is a row — the kernel's file, the parameters the gate runs it at, its
arguments, a reference, and any excuse. Six claim states, and two of the
distinctions are load-bearing. `undetermined` is separate from `closed` because a
check the engine could not evaluate must not read as one that passed — and without
the state such a claim gets omitted instead, which is indistinguishable from clean
in any summary. `deferred` is separate from `waived` because only one of them comes
back: a deferral carries an issue reference and reads as a known gap rather than a
failure, following `tests/test_spec_gaps.py`. Either excuse reports itself as
unnecessary once its check starts passing, which is what fails the build until the
declaration is updated — the xfail marker cannot do that on its own, since it is
applied from the claim's current state and is simply absent once the gap closes.
That is what lets a kernel arrive over two pull requests without the first
pretending the cost leg is done. Both excuses are validated where they are written
rather than where they are read: a deferral naming no issue, and a tolerance naming
an argument the kernel does not have, are rejected at declaration.

An argument is a spec resolved against the row's parameters — `normal(("M", "K"))`,
`zeros(("M", "N"))`, a bare string forwarding a parameter — so declaring a kernel
writes a row and not a file, and a callable `(params, rng)` remains for the
arguments no spec covers. Specs are per argument rather than per kernel, which is
what lets a kernel declare one tensor the long way and leave the rest as rows.
Every draw is seeded from the argument's own name: one generator drawn twice gives
the second tensor the first one's values, and a kernel that swapped its operands
would then still agree with its reference.

The pricing check is repository-wide rather than a per-kernel claim, which a
probe-only prototype over six real artifacts settled. Asked per kernel it produced
66 of that prototype's 74 open claims, about fifteen of them the same structural
ops in every kernel — `arith.constant`, `scf.*`, `construct_memory_view`,
`return` — which really are free; per kernel it is a chore each contributor waives
their way through, and a waiver mapping that is mostly noise stops carrying
information. Asked once per repository it is `pricing.py`: every op the registry
prices `zero` — 46 of 106 today — has to appear in exactly one of two mappings,
each entry carrying a written reason. `ZERO_COST_OPS` is free by decision, 21 ops;
`UNJUDGED_ZERO_OPS` is priced zero with nobody having decided that, 25 ops, each
reason naming the issue that would settle it, because an open question with no
issue behind it is how such a list becomes permanent. An op in neither, or in both,
is a finding. So registering an op without naming a category no longer passes
silently — and it does not become priced either, which is the point: the gate asks
for a decision, and recording "not yet judged" against an issue is a legitimate
answer.

What the audit cannot see is a category that is simply wrong. An integer compare
billed to the float pipe is priced, so a comparison against `zero` passes it, and
that defect survived in this repository for as long as it existed. Only a reader
who knows the op's semantics catches that one.

`conformance.py` records which kernels under `examples/` the MLIR frontend rejects,
and why. That has to be written down rather than measured at render time: the check
needs an optional dependency, so on a machine without the MLIR bindings the claim
reads `skip`, and a report carrying whatever this machine saw would be a document
about the machine. It is a record and not a verdict — it does not say whether the
file or the parser should change. Same shape and same reason as
`tests/mlir_frontend/test_registry_consistency.py::FRONTEND_UNSUPPORTED`.

Signed-off-by: WarningRan <zhuoran.liu@ibm.com>
"Fully supported" was an adjective, and an adjective cannot be checked. Three
kernels have now gone through this repository, each as a kernel plus a notebook
section, and what they delivered under `tests/` varies sevenfold across the commits
that carry them: 471, 363, 220, 84 and 66 lines. The 84 asserts an
arithmetic-intensity formula, per-core cycle structure and three input guards, and
compares no output value. Two simulator gaps were found by walking into them
mid-change rather than by asking first.

Closes torch-spyre#209.

kernelentry makes support a ledger: a set of claims derived from the kernel itself,
each closed or open with the file that would close it named. The set is not a
checklist — every distinct op contributes a claim, every output tensor contributes
two — so it grows with the kernel rather than with this module.

    python -m ktir_cpu.kernelentry probe examples/latency/my_kernel.mlir
    python -m ktir_cpu.kernelentry adopt my_kernel
    python -m ktir_cpu.kernelentry probe --all --write-report
    python -m ktir_cpu.kernelentry verify --all

`probe` takes the bare `.mlir` and writes nothing, so the first question — what does
the simulator not support about this kernel — can be asked before there is a
declaration to ask it with. `adopt` writes only the files it owns and never edits
the interpreter: when a handler or a repriced op is needed it prints the change and
the file, because a tool that quietly changed what the simulator charges would be
changing the answer it was asked for. `verify` shares one engine with the gate, so
the local loop and CI cannot disagree.

Support is five questions asked in order — read by the regex parser, read by the
MLIR frontend, runs, output right, cost pinned — and a kernel with no declaration
answers a prefix of them. That ordering is why the generated report has two tables
rather than one with holes: an unasked question has no cell, not an empty one.

Output tensors are identified from the trace rather than by walking SSA names back
from a store. The walk does not survive `construct_distributed_memory_view`, and
when it fails it fails silently: the prototype produced a kernel with no output
claims at all and a summary line reading 60/61 closed. Deriving the set from the
trace also means a declaration can name an output the trace does not, so a
reference entry no store wrote is reported `undetermined` at the id its comparison
would have had. Left unasked it is the same defect one step over: a comparison that
vanishes rather than fails.

There is deliberately no per-op frontend-reachability claim.
`tests/mlir_frontend/test_registry_consistency.py` already asserts that every
executor op is frontend-installed or allow-listed, and an op appearing in a kernel
is necessarily registered, so an unreachable op already breaks the build
repository-wide; `parse.frontend` catches it for one kernel specifically. A third
check would restate them and would need the allow-list moved out of that test.

`cost.derivation` commits a generated attribution of the kernel's cost, so a change
arrives as a diff naming the term that moved and a reviewer reads a breakdown
instead of a number. It cannot catch the cost model being wrong, since it is
produced by the same code as the measurement, and it says so in its own footer.
That question is not asked per kernel: there is one cost model behind all
thirty-three kernels, and `tests/test_latency.py` already asks it by mechanism,
with hand-counted bytes, FLOPs and cycles per latency category and across the
hardware parameters that scale them. A per-declaration hand count would have each
kernel re-answer one question about one model, so support is five ordered questions
and not six.

Reading `examples/` for discovery, and the frontend allow-list out of the test that
enforces it, makes this package a development tool rather than part of the library,
so `pyproject.toml` excludes it from the wheel. Packaged it would resolve to a
`site-packages` directory carrying neither path, and fail where it read them rather
than at import. Editable installs, which is how the repository is worked in, are
unaffected.

`docs/kernelentry.md` is the contributor entry point — the five questions, the six
states, how to declare a kernel, how to excuse a claim, and which kernels cannot be
declared at all — and `CONTRIBUTING.md` links it from the pull-request section.

Signed-off-by: WarningRan <zhuoran.liu@ibm.com>
The ledger the previous commit adds asks the MLIR frontend whether it accepts each
kernel, and these two were the only ones it refused that anything here declares.
Both write `inter_tile_produce` / `inter_tile_reduce` as

    : T -> !ktdp.tile_future<T, groups = S>

which the regex parser reads and the built dialect does not print, so the frontend
stops at the first of the two ops with `expected '->'`. The dialect's own custom
assembly is

    -> <(T), groups = S>            (produce)
    : <(T), groups = S> -> R        (reduce)

and the regex parser reads that form as well, which settles which of the two
spellings is authoritative: both parsers accept the second, only one accepts the
first.

Two lines per file. The authors' line breaks are left alone, so the diff is the
type syntax and nothing besides it, and nothing about either kernel moves: both
parsers accept the result, and the cost derivations are identical to the digit,
because the spelling of a type is not an input to the cost model.

This precedes the generated reports so that no committed document records an
acceptance the tree does not have at that commit.

The three `ring_reduce*` kernels are deliberately left in the old form. Rewriting
their spelling does not make them parse: all three reduce `tensor<1x128xf16>` to
`tensor<128xf16>`, and the dialect verifies that a reduce's result matches the
future's partial type, so the error becomes `result types must match future partial
types` instead of disappearing. The reduce here takes its result type as a reshape
target (`_result_shape` -> `attach_reshape` in `ktir_cpu/dialects/ktdp_ops.py`), so
closing that gap means deciding whether it should reshape at all — a decision about
the op rather than about how those three files are written down. They stay recorded
as rejected, with that distinction in the reason and in gap row 2a.

Signed-off-by: WarningRan <zhuoran.liu@ibm.com>
Every kernel under `examples/` that can be driven through the `tensors=` seam is
declared, and every claim the eighteen raise is closed. Declaring one is a row in
the `ENTRIES` table in `examples/entries.py` — no new file, nothing to register, no
test to write. The blocker looked larger than it was: seventeen of these kernels
already had a reference inside `tests/`, computed in f32 and then cast back to f16
before comparison, and the cast is what made it a reference to the kernel's own
rounding rather than an independent one. Dropping it is a one-line change per
kernel, at the tolerances the ledger already used. Three needed more than a
transcription:

  - `ffn_swiglu_4core` overflows f16 in `exp` at unit-variance weights and writes
    an all-zero output, which no cost report would flag. Its inputs are scaled by
    1/sqrt(fan-in) — standard initialisation, not a number chosen to make a check
    pass — and `out.*.nontrivial` is what noticed.
  - `layernorm_fwd_ktir` writes three tensors and the end-to-end test checks two.
    `Rstd` now has a reference, so the reciprocal standard deviation of a fold over
    8192 f16 values is compared against something for the first time.
  - `paged_attention` is referenced at all thirty-two grid positions rather than
    the first, and with the softmax taken flat over the masked row rather than
    folded tile by tile. Both matter: a mask indexed by query position is exactly
    what can be right at `pid0 = 0` and wrong everywhere else, and the tiled fold
    is the kernel's own algorithm, so a reference written that way checks the
    arithmetic and not the decomposition.

The fifteen that remain undeclared are not a backlog. Eight `examples/rfc/*` files
take no tensor arguments — their tensors are memrefs at absolute HBM bases, so
there is nothing to bind, and the two that take anything take one scalar size.
Five take raw HBM element indices with no shape attached — the three ring-reduce
kernels and the two `rmsnorm_4core_*` — and the tests that run them seed memory by
replacing `_prepare_execution`, which a declaration cannot express; that is a
property of the argument convention rather than of those files, and it is the live
one, since the two rmsnorm kernels are the most recent to arrive and their tests
seed memory the same way. `softmax_wide.mlir` overflows LX on purpose and its test
asserts the exception, so `exec.runs` failing is the kernel behaving.
`nested_yield.ktir` is rejected by the frontend for a `construct_memory_view` with
no `coordinate_set`, and it is a reproducer written down to the one op under test,
so being minimal rather than dialect-valid is what it is for.

No declaration defers or waives a claim. That is worth stating rather than
assuming, because the two that would have — the cross-core kernels of the previous
commit — are the reason `deferred` exists in the engine at all, and the state ends
up exercised only by the gate. The gap this branch found and did not close is the
one it cannot: the three `ring_reduce*` kernels are on the page, in the record of
what the frontend refuses, with the decision their author has to make written
beside them.

The gate's cost is shape, not entry count: probing all eighteen takes about 16 s,
and timed one kernel at a time, five kernels whose shapes are baked into their IR
are 94% of it. They are here anyway, because a cost model checked only at reduced
size is checked where the padding, the tail core and the page table are not real
yet.

Signed-off-by: WarningRan <zhuoran.liu@ibm.com>
Generated and committed on the same discipline as a lock file: regenerating them is
one command, and the gate compares them verbatim, so adding a kernel without
regenerating them is a red build rather than a document that quietly goes stale.
`docs/kernel_support.md` and `docs/supported_ops.md` are the same repository seen
per kernel and per op; `docs/kernel_cost.md` is the committed cost attribution the
`cost.derivation` claim compares against.

Between them they carry figures nothing here stated before: 490 op occurrences
across thirty-three kernels with a handler for every one, 106 handlers of which 102
are frontend-reachable and four are by design not, and 52 of those 106 exercised by
an example. Four kernels are not accepted by the MLIR frontend — the three
`ring_reduce*` for the typing gap recorded earlier in this branch, and
`nested_yield.ktir` for a `construct_memory_view` with no `coordinate_set` — which
nothing said before either: the frontend suite covers eleven kernels by inheriting
regex test classes and none of these four is among them, so this is a coverage hole
rather than a break.

The op document's cost column prints the pricing decision rather than the
registry's default, so an op priced zero with nobody having decided that reads
differently from one that is free.

`docs/kernel_cost.md` is worth reading rather than skimming. 72.7% of
`matmul_small`'s HBM traffic is the B operand, at a traffic ratio of 3.667 over what
its tensors logically require; `indexed_add` reports 0.02320 for the same figure,
which is not an error — it reads two of 128 slices, and clamping the ratio at 1
would erase exactly the fact worth seeing. The spread across the eighteen is
0.02320 to 32.

Signed-off-by: WarningRan <zhuoran.liu@ibm.com>
The gate for everything above: one parameterized case per claim per declared
kernel, over the same engine `verify` runs, so the local loop and CI cannot come to
different conclusions about the same tree. A claim's state decides its marker, so a
deferral is an `xfail(strict=True)` and closing the gap it names fails the build
until the declaration stops claiming it.

A gate can also be green because nothing it asks is hard, and that is what the
second suite is for. It removes a reference, narrows one to a slice of the output,
swaps in a reference computed in f16, adds a reference key no store wrote, drops a
declared tolerance, staleness-checks a waiver and a deferral by closing the gap
each excuses — and asserts the claim turns each time. A claim that cannot be made
to fail there is decoration, and the suite is where that gets established rather
than assumed.

Three more things are asserted rather than trusted:

  - The generated documents are functions of the repository and not of the host,
    checked by rendering them twice with the MLIR bindings stubbed out. Without
    that they would differ between CI and a contributor's laptop, and each would
    call the other's stale forever.
  - The support report covers every example `tests/conftest.py` already drives, so
    a kernel cannot be added to the test parameters and stay invisible to the
    ledger. The same test pins the one figure the renderer cannot compute — how
    many of those listings pass no arguments — because the package cannot import
    the test suite, and a hand-written count in a generated document is the rot
    this whole change is against.
  - The pricing audit's blind spot is written down as a test: an op billed to the
    wrong pipe is still priced, so no comparison against `zero` can catch it. Left
    unstated it is a gap someone rediscovers; stated, it is the reason the
    follow-up asks for a semantic audit.

`tests/mlir_frontend/test_kernelentry_adapt.py` verifies `conformance.py` against
the real frontend in both directions, so an unrecorded rejection and a rejection
since fixed each fail. It also asserts that `parse.frontend` comes back as a real
answer rather than `skip` where the bindings are installed, because a leg that
silently skips is a leg that reports green without being asked.

Signed-off-by: WarningRan <zhuoran.liu@ibm.com>
Generating an op matrix makes two hand-maintained inventories checkable for the
first time, and both were wrong in the same direction — reporting as missing what
has been implemented for months.

`docs/gap_analysis.md` rows 13–19 (`arith.cmpf`, `negf`, `absf`, `minf`, `minnumf`,
the float casts, the signed and bitwise int ops) and rows 28–29
(`tensor.extract_slice`, `tensor.insert_slice`) were ❌ or 🟡 with handlers in the
registry; `cmpf` and `negf` are used by an example. They are now ✅ with the handler
cited, and the two summary lists at the end agree with them. The Arith and Tensor
preambles no longer carry an inventory at all — they point at the generated document,
because a list copied into prose is what let those rows sit wrong, and re-copying it
correctly would only restart the clock. The coverage-backlog paragraph named three
RFC-mentioned ops as absent; `memref.subview` is the only one still absent, and it
now names just that one.

README's Supported Subset had the same problem from the other side: it described
rectangular coordinate sets only, and omitted `base_map`, `access_tile_order`,
distributed and indirect views, and cross-core reduction. Its Not-Supported list
named `ktdp.transfer` / `ktdp.reduce`, which no longer exist, and
`tensor.insert_slice`, which does; and it called `coordinate_set` unenforced, where
what is actually unchecked is overlap between a distributed view's partitions —
`find_partition` returns the first match, which RFC 0682 §3.3 leaves unspecified.
Both lists are rewritten and now link the two generated documents rather than
restating them.

New gap row 36a, found by the same matrix: `ktdp.region_terminator` has a frontend
adapter and no execution handler. The MLIR bindings' region walk is documented in
`mlir_frontend/parser.py` as emitting an implicit terminator that never appears in
text IR; an adapter is installed so the walk survives, `adapt_block` keeps it in the
region's op list, and `_execute_op` would raise `Unknown operation` for it there.

No kernel under `examples/` reaches it, and the reason is not the one it looks like.
Three inter-tile kernels do get through the frontend — `rmsnorm_4core_2x2.mlir` and
the two rewritten earlier in this branch — and walking every op the frontend
produces for each, into the `inter_tile_produce` and `inter_tile_reduce` regions,
finds `ktdp.yield_partial` and `ktdp.yield_reduced` terminating them and no
`ktdp.region_terminator` in any of the three. So the row records an asymmetry that is
real and a construct whose trigger is not established, rather than a kernel that
would abort.

Row 2a is corrected by the same leg. It described the four-op design's upstream
status rather than this repository, and the status it described is no longer the
case; it now states what is true of the code here — `ktdp.inter_tile_reduce` takes
its result type as a reshape target, the dialect verifies the result against the
future's partial type instead, and the three `ring_reduce*` kernels are the only
non-identity reduce reshape and the only inter-tile kernels the frontend refuses.
Rows 2b-2d are left alone: their implementation status is unmeasured here.

§I and §J are left alone; they are dated 2026-05-30 and are a different kind of
claim.

Signed-off-by: WarningRan <zhuoran.liu@ibm.com>
@WarningRan

WarningRan commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

Three notes to save time on a diff this size:

  • docs/kernel_support.md, docs/supported_ops.md and docs/kernel_cost.md are generated and committed on lock-file discipline. Regenerate with uv run python -m ktir_cpu.kernelentry probe --all --write-report; tests/test_kernelentry.py fails when a committed copy is stale. Worth reviewing for what they claim, not for formatting.
  • Commit 8 corrects README.md and docs/gap_analysis.md, which the generated matrix contradicted: several rows reported as missing what has had a registry handler for months. That is a claim about this repository's own record, and is the part most worth checking against your own knowledge of the code.
  • No execution semantics change. ktir_cpu/latency.py and ktir_cpu/interpreter.py gain per-op cost attribution (nbytes / flops on the trace entry, and an operand backtrace to the argument a load or store reaches HBM through); the two examples/ edits rewrite a !ktdp.tile_future type into the dialect's own printed form so the MLIR frontend reads those two kernels.

The gate is tests/test_kernelentry.py, which runs in CI through the default pytest path; uv run python -m ktir_cpu.kernelentry verify --all is the same engine from the command line.

@WarningRan
WarningRan requested a review from yuhaohaoyu August 25, 2026 14:38
@WarningRan
WarningRan marked this pull request as draft August 25, 2026 14:46
@WarningRan

WarningRan commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

The failing check isn't from this branch — Set up MLIR installs nanobind with no upper bound and 3.0.0 broke CMake configuration for everyone; #214 bounds it.

Once #214 is in I'll merge main here and push, since re-running the checks would just replay the same pinned merge commit.

@fabianlim

Copy link
Copy Markdown
Collaborator

@WarningRan i think need to clarify the purpose of this PR, most of the kernels examples in ktir-cpu, are meant just for testing and are seperate from actual kernels that we want to support. The kernels that are to be supported are versioned in the https://github.com/torch-spyre/spyre-kernels/

@WarningRan

Copy link
Copy Markdown
Collaborator Author

@fabianlim
What this PR does: it turns "ktir-cpu supports this kernel" from an adjective into a computed ledger: five questions asked in order (the regex parser reads it / the MLIR frontend reads it / it runs / its outputs match a reference computed in f32 / the committed cost derivation still reproduces), each gated, and each open one naming the file that would close it.

Where I stand: the kernel comes from elsewhere: we do not produce kernels. So I agree with your line, and this PR stands on it: examples/ are fixtures, the sample the questions are asked through, and what is being asked about is the simulator. It adds no kernel, and docs/supported_ops.md reports 54 handled ops that no example exercises: a catalogue of kernels would have no reason to report that.

Good point on the last one. probe refuses a path outside examples/ today, so a spyre-kernels kernel has to be copied in first. A commit is coming to let it read any path cold, answering the first two questions and writing nothing.

lasch
lasch previously approved these changes Aug 26, 2026

@lasch lasch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, the 'copy-in feature' could be done in a separate PR

`probe <path>` refused any `.mlir` not under `examples/`, telling the caller to
move it there first. That inverts what the tool is for. The kernels this
interpreter is asked to support are versioned elsewhere; `examples/` holds
fixtures, and the two questions a cold probe answers — whether every op in the
file has an execution handler, and whether both parse paths accept it — are
about the simulator rather than about where the file sits.

A cold probe writes nothing, so a path outside the tree leaves nothing behind to
go stale. The entry keeps the relative form when the file is under `examples/`
and the absolute path when it is not, so a cold probe and a declared entry name
the same kernel the same way.

Declarations are deliberately not relaxed with it: a row in the committed report
names a repository-relative path, and a row pointing at one person's own disk is
a row nobody else can regenerate.

Signed-off-by: WarningRan <zhuoran.liu@ibm.com>
@WarningRan
WarningRan marked this pull request as ready for review August 27, 2026 14:23
@WarningRan

Copy link
Copy Markdown
Collaborator Author

Sorry, missed the comments, @lasch . This is the commit I said was coming in the reply above, so it landed here rather than separately: 40 lines, confined to the cold-read path, no change to how a declared kernel is resolved. It dismissed your approval, which I did not intend.

Happy to pull it out into its own PR if you'd still rather have it separate — say so and I'll reset it back to the tree you approved.

@lasch

lasch commented Aug 27, 2026

Copy link
Copy Markdown
Member

Sorry, missed the comments, @lasch . This is the commit I said was coming in the reply above, so it landed here rather than separately: 40 lines, confined to the cold-read path, no change to how a declared kernel is resolved. It dismissed your approval, which I did not intend.

Happy to pull it out into its own PR if you'd still rather have it separate — say so and I'll reset it back to the tree you approved.

I'm not going to reject a PR because it was made more complete 😉
Thanks for adding this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Make "the simulator supports this kernel" a checkable statement

3 participants