Skip to content

Add CoreAIProbedInspector: read intermediates as extra model outputs - #134

Open
cymbalrush wants to merge 12 commits into
apple:mainfrom
cymbalrush:feat/intermediates
Open

cymbalrush wants to merge 12 commits into
apple:mainfrom
cymbalrush:feat/intermediates

Conversation

@cymbalrush

Copy link
Copy Markdown
Contributor

Add CoreAIProbedInspector: read intermediates as extra model outputs

Why

CoreAIInspector reads intermediates through the runtime's intermediate logger, which does not fire for work placed on the neural engine, so on the ANE it returns None for every op. The ANE does not natively expose intermediates, so the only route today is to make them outputs of the model.

How it works

CoreAIProbedInspector(program, function_name="main", specialization_options=None, max_cached_models=4) implements the usual Inspector interface, so it drops in where CoreAIInspector is used. For get_intermediates_for_ops(op_ids, inputs, state=None):

  1. Copy and rewrite. It deep-copies the program and appends each requested op's tensor results to the function's coreai.output. It updates the function type and result names, so the model's own outputs keep their names and positions.
  2. Compile and run. It saves, compiles and runs the copy with the options you chose, for example a preferred compute unit.
  3. Map back. Results come back keyed by Core AI op ID, one array per result. Ops that cannot be probed return None and are logged.

The rewrite lives in probes.py and does not need the Python runtime, so it can be reused elsewhere:

  • add_probe_outputs(program, op_ids, function_name) returns a ProbedProgram.
  • split_outputs(arrays) separates a run's outputs into the model's own and the probed values.
  • manifest() returns the probe names as JSON-able values.
  • save(dir) writes probed.aimodel and probes.json for another runtime.

Other behaviors:

  • State. Stateful models work. Pass state= by name. The inspector copies it on each call, so every call starts from the same values and your arrays are left alone. last_state holds what the run left behind.
  • Compile cache. Compiled copies are kept per set of probed op IDs, least recently used dropped first. max_cached_models sets the limit (default 4). 0 compiles on every call, and clear_cache() drops everything.
  • last_outputs. It returns only the model's own outputs, without the probe outputs.

Be careful: probed values are not the original model's values

Every probe changes the model, so treat results as error-prone.

  • A probe is an output. The compiler cannot fuse it away, so it may split or re-place work. Probed values can differ from what the unprobed model computes, and the ANE may handle the extra outputs differently.
  • ANE values are fp16-ANE values. In a small test they were within about 3e-4 relative error for conv and relu, and about 2.5% for sigmoid×x and the conv after it. Compare against a reference of the same precision, or use a loose tolerance.
  • Check each probe set before relying on it. Add a small set, then compare the final output and compute-plan placement (plan_diff) against an unprobed run. If either changes, those values cannot be trusted. Reduce the set or try another. If they match, they are a reasonable stand-in. This is a manual check; the inspector does not run it.
  • Prefer fewer, coarser probes. Probing every op perturbs the model most and costs the most compiles.
  • Cached models are tied to the exact probe set. A copy compiled for a larger set is not reused to answer a smaller one.
  • Each new probe set costs a compile. A big model is expensive.
  • Limits:
    • Only tensor results of ops in the function's top-level graph can be probed. Ops inside invoked graphs, ops without tensor results, and unknown IDs return None.
    • The program must not be mutated after the inspector is created, or the cache goes stale.

Not included

Module-level probing, which would choose probe points at module boundaries, is not part of this PR. Module attribution is incomplete for some ops. This PR probes whichever op IDs it is given.

Testing

tests/debugging/test_probed_inspector.py (7 tests) covers:

  • the original program and its outputs are left alone;
  • unprobeable IDs are reported;
  • intermediates are returned;
  • stateful models are read without advancing the state;
  • the manifest, split and save work without a runtime;
  • cache reuse, the size limit, and switching the cache off.

They pass on the default runtime. I also ran the first 5 on the neural-engine setting, but the cache tests were added afterwards and have only run on the default runtime. They use a small fp16 toy model. I have not run this on a large model or a real KV-cache model, and I have not measured the compile-time saving.

CoreAIInspector relies on the runtime's intermediate logger, which does not
fire for work placed on the neural engine. This copies the program, appends
the requested operations' values to the graph's outputs, compiles the copy
and runs it, so an intermediate comes back like any other output on any
compute unit.

The rewrite lives in probes.py and does not need the Python runtime. The
inspector keeps compiled copies per set of probed operations.
@cymbalrush
cymbalrush marked this pull request as draft October 19, 2026 23:25
…ction

Thread an optional state argument through the inspectors, validator and
comparator so stateful models can be debugged.

CoreAIProbedInspector now compares each probe set's outputs and state with an
unprobed run, within rtol and atol, and reduces the set when they move or when
the probed copy fails to compile or run. Operations that cannot be read this
way are reported in perturbing_ops and failed_ops.

create_comparator_for_programs can read target values with the probed
inspector via target_inspector='probed'.
PerturbationPolicy and TargetInspector replace string options and keep the
string values working. The probed inspector tests now cover its main
behaviors.
benchmark, benchmark_coreai_program and compare_runs take an optional state.
Every run, warmup included, starts from a fresh copy, so no run is timed on
the state a previous one left.
Add module_output_op_ids: which Core AI operations hold each module
instance's output. A module's output is a torch node that something
outside the module reads; the operation holding it is the one realizing
that node (the largest torch id in its location). The module is left out
rather than guessed when the value is not certain: no or several realizing
operations, a multi-output node, or a reader that left no operation of its
own (batch norm folded into a conv).

Read stack traces through fused locations. get_stack_trace returns nothing
for a fused location, which core-elide-broadcast builds for an nn.Linear's
matmul, so those operations had no module. operation_stack_traces returns
every stack on an operation, and operation_stack_trace the one it belongs to;
module paths and the benchmarker use the latter.

Record the FX graph while converting (TorchConverter.torch_graph) so a
module's output node can be found, which the Core AI program alone cannot say.

Normalise OpResult.owner to a plain Operation before the isinstance checks.
Some Core AI builds return the typed op view, which failed them, so no
location or output map was recorded at all.
Take a module path, resolve the Core AI operations that certainly hold its
output with module_output_op_ids, and return their values from a
CoreAIProbedInspector or a CoreAIInspector. Raise ModuleOutputUnknownError
when the module is absent or its output is not certain, rather than guess.
module_output_op_ids and get_module_outputs no longer need the converter or
the FX graph. The converter now records, on each graph's location, a
`source_nodes` metadata: the ATen target of every node converted into the
graph and the id of its output node. It survives compilation, is saved with
the asset and comes back out of a loaded model.

An operation is owned by the largest torch id in its location that is not
shape-only. A module's output is an owned operation that an operation of
another module reads, or that returns the program's output. The module is
left out when an id between the operation's owner and its reader's owner shows
up in no operation and is not known to be shape-only: a node was folded away
without a trace (batch norm into a conv), or its kind is unknown.

Without the table no id is known to be shape-only and only the root may take
the program's output, so fewer modules are reported; with no debug info none.
Split module_output_op_ids out of modules.py into module_outputs.py, built
from a _Boundaries class and a three-way _Verdict (the value stays, leaves, or
is not certain) instead of one long function. The behaviour is unchanged by
the move.

Two changes recover modules that were left out:
- a value only written to state (create_token, read_handle, write_handle) is
  not a module output and no longer makes a module uncertain, so a stateful
  model with several outputs keeps its root;
- expand returns its input's values, so it is shape-only like view and no
  longer blocks a boundary it sits on.

Move the module-output tests to test_module_outputs.py and the stack-trace
tests to test_source_annotator.py. Add a test that a stateful two-head model
returns its two heads, matching torch.
…ison

A search consumes its strategy: ExhaustiveStrategy yields its single batch once
and keeps what it was told, LevelOrderStrategy narrows its depth range and keeps
its results. Validator.check and Comparator.compare reused the strategy as it
was, so a second call on the same object yielded nothing (the comparator
compared no operations and reported no failures) or returned the first call's
findings (the validator reported no infinities for an input that has them).

Add SearchStrategy.reset, implemented for both strategies and used by their
constructors, and call it at the start of each search. Add tests that call a
validator and a comparator repeatedly with other inputs and state, which fail
without the reset, and tests for the strategies' reset.
@cymbalrush
cymbalrush marked this pull request as ready for review October 23, 2026 01:13
cymbalrush and others added 2 commits October 23, 2026 11:01
The node table on a graph's location was found by scanning the printed location
for its brace-delimited data. Find the entry by name in the location's metadata
list and print that attribute alone instead, so MLIR parses exactly the table.
A build whose locations do not list their metadata has no table to read, so the
reduced behaviour applies there.

Drop the scanner and its tests, and test that a program converted without debug
info reports no modules.

This branch has not been deployed

No deployments
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.

1 participant