Skip to content

Recognise a buffer as mutated when the write is not its direct user - #21979

Closed
john-rocky wants to merge 2 commits into
pytorch:mainfrom
john-rocky:coreml-mutated-buffer-detection
Closed

Recognise a buffer as mutated when the write is not its direct user#21979
john-rocky wants to merge 2 commits into
pytorch:mainfrom
john-rocky:coreml-mutated-buffer-detection

Conversation

@john-rocky

Copy link
Copy Markdown
Contributor

Fixes #21855.

What happens today

tag_constant_data decides whether a buffer is mutated by walking its users and looking
for the node that performs the write:

for node_user in node.users:
    if node_user.name in buffers_to_mutate:
        mutated_buffer.add(node)

That holds only when the buffer is written in one step. A cache that is concatenated with
its new values and written back further down the chain has the cat as its user, not the
write, so it is taken for constant data and tagged into the delegate.

A backend that compiles mutable buffers into state then produces a model that asks the
runtime for state — after the partitioner was told, by take_over_mutable_buffer=False,
that this runtime has none. On Core ML that is a load which succeeds and an execute which
fails:

[backend_delegate.mm:435] [Core ML] Model execution failed
  The input feature for layers_7_conv_conv_state must be an MLState, but it was not.
[method.cpp:1530] CALL_DELEGATE execute failed at instruction 0: 0x32

Measured on LFM2.5-350M at partition time, ten of its twenty-two mutated buffers were
tagged and twelve were not. The twelve are the KV caches, written by index_put directly.
The ten are the short-convolution states, written a step later — and they are exactly the
buffers named in the error.

The change

The signature already records which buffers are mutated, so match on the target rather
than on the mutating node's name. The direct-user walk stays for parameters and lifted
constants, which have no buffer target to match.

Checking it

The new test in test_partitioner.py fails on main ('tag0' is not None: a mutated buffer must not be tagged as constant data) and passes with the change.

End to end, export_llm with the Core ML backend now runs where it previously failed at
execute. LFM2.5-350M, c4w, macOS arm64:

decode 87 tok/s
"The capital of France is" " Paris", matching the unquantised model

Argmax agrees with the original model on every position where the context determines the
token; the two that differ are the first, where the distribution after <bos> alone is
nearly flat.

I also checked that nothing else moves: the other buffer-mutation and constant-data tests
in that file pass, take_over_mutable_buffer=True behaves as before, and a mutable-buffer
model lowered to XNNPACK produces identical outputs with and without the change, cache
advancing correctly across calls.

One thing this does not fix, for completeness: a Qwen3.5-0.8B c4w build now runs but its
output is wrong, which looks like the 4-bit weights rather than the partitioning. I have
not shipped that one and will report it separately once I know which.

tag_constant_data decided whether a buffer is mutated by looking through its
users for the node that performs the write. A cache that is first concatenated
with its new values and written back further down the chain has the concat as
its user, not the write, so it was taken for constant data and tagged into the
delegate.

A backend that compiles mutable buffers into state then produced a model that
asks the runtime for state — after the partitioner had been told, by
take_over_mutable_buffer=False, that this runtime has none. On the Core ML
backend that is a load that succeeds and an execute that fails with 'The input
feature for layers_N_conv_conv_state must be an MLState, but it was not'.

The signature already records which buffers are mutated, so match on the target
instead of on the mutating node's name. The direct-user check stays for
parameters and lifted constants, which have no target to match.
@pytorch-bot

pytorch-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/21979

Note: Links to docs will display an error until the docs builds have been completed.

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 20, 2026
@github-actions

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@john-rocky

Copy link
Copy Markdown
Contributor Author

Correction to my own PR description, before anyone spends time on it.

I wrote that LFM2.5-350M "runs where it previously failed" and answers " Paris" matching
the unquantised model. The first half stands. The second half was one prompt, and one
prompt is not a check. Running five:

'The capital of France is'                  -> ' Paris'   eager ' Paris'
'Water boils at a temperature of'           -> ' '        eager ' '
'The largest planet in our solar system is' -> ' not'      eager ' Jupiter'
'Shakespeare wrote a play called Romeo and' -> 'Sh'        eager ' Juliet'
'Two plus two equals'                       -> '.\n'       eager ' four'

argmax agreement 8/27 = 0.296

So the c4w build produces wrong text. I should not have quoted a single lucky prompt as
evidence of correctness and I have not shipped the model.

What this does and does not change. The partitioning fix is unaffected: the state error
is gone, the model loads and executes, the new test fails on main and passes here, the
existing buffer-mutation and constant-data tests pass, take_over_mutable_buffer=True is
untouched, and an XNNPACK mutable-buffer model gives identical outputs either way. Those I
measured properly.

What I do not yet know is whether the wrong text is the 4-bit weights or something else
this path does. Qwen3.5-0.8B c4w is wrong in the same way, which points at the
quantisation rather than at any one model, but I am checking an 8-bit build before saying
so. I will report the answer here either way.

Please review the change on its own terms — it fixes a misclassification with a test — and
treat the LLM as a case that now reaches execution rather than as one that is correct.

@john-rocky

Copy link
Copy Markdown
Contributor Author

Correcting the correction — and this time in the change's favour, which is why I want to
be precise about what was wrong.

My five-prompt check ran every prompt through one loaded method. The KV cache persists
across execute calls, which is the point of it, so the second prompt started at position
zero on top of the first prompt's keys and everything after the first sequence was
measured against a polluted cache. That is a bug in my harness, not in the model.

Reloading the method per prompt, LFM2.5-350M through the Core ML delegate with no
quantisation:

'The capital of France is'                  -> ' Paris'    eager ' Paris'
'Water boils at a temperature of'           -> ' '         eager ' '
'The largest planet in our solar system is' -> ' Jupiter'   eager ' Jupiter'
'Shakespeare wrote a play called Romeo and' -> ' Juliet'    eager ' Juliet'
'Two plus two equals'                       -> ' four'      eager ' four'

argmax agreement 27/27 = 1.000, decode 93 tok/s

Exact agreement with the unquantised reference on every counted position. So the model this
change unblocks is not merely reaching execution — it is correct.

The 4-bit builds are a separate matter and are lossy, which is what I had been seeing
through the broken measurement: same harness, same prompts, c4w scores 21/27 = 0.778 and
misses Jupiter and four. Qwen3.5-0.8B c4w scores 3/21 = 0.143, so on that model 4-bit
weights are not usable at all. Neither belongs to this PR; I mention them so the numbers
in this thread are not read as the delegate's.

Summary of what is measured, so the record is straight:

new test fails on main, passes here
existing buffer-mutation and constant-data tests pass
take_over_mutable_buffer=True unchanged
XNNPACK mutable-buffer model identical outputs either way
LFM2.5-350M, Core ML, unquantised runs, 27/27 agreement, 93 tok/s

Apologies for the churn on this thread. Both wrong claims came from the same harness bug,
and I would rather post the correction than leave either version standing.

john-rocky added a commit to john-rocky/executorch-samples that referenced this pull request Aug 21, 2026
The reel's acts and the LLM's backend both come from Documents now, so the
same install records the Core ML take and the XNNPACK take. Before
pytorch/executorch#21979 the Core ML side could not run at all.

Two things the text act needed. It now waits on the model instead of reporting
it missing: opening the Core ML build compiles it, the act arrives long before
that finishes, and the old code read a not-yet-open model as a not-present one.
And the act deadline is minutes rather than 45 seconds, because a deadline
shorter than the compile turns a first run into a blank screen.
@metascroy

Copy link
Copy Markdown
Contributor

@claude review this code

@claude

claude Bot commented Aug 26, 2026

Copy link
Copy Markdown

Claude finished @metascroy's task in 1m 31s —— View job


Code review

I read the change against exir/backend/utils.py:345 (tag_constant_data) and the new test. Short version: the fix is correct, minimal, and well-targeted. A few observations, none blocking.

The fix is right

The bug is real. buffers_to_mutate maps the mutation-output node name → buffer target (fqn), while inputs_to_buffers maps placeholder name → buffer target (fqn). The old code compared a buffer's direct users' names against the keys of buffers_to_mutate, which only matches when the write is the buffer's immediate user. For a cache read via cat and written back later, the direct user is the cat, so the buffer fell through to the constant-tagging path.

The new code compares on the value side instead:

mutated_targets = set(buffers_to_mutate.values())
...
if buffers_map.get(node.name) in mutated_targets:
    mutated_buffer.add(node)
    continue

buffers_map.get(node.name) yields the buffer's fqn, and mutated_targets is the set of mutated fqns — so this recognises a mutated buffer regardless of how many steps separate the read from the write. This is the right key to match on, and it's the same information the runtime/backend ultimately uses. Keeping the old direct-user walk for params and lifted constants (which have no buffer target) is the correct call.

One correctness nuance worth a note (non-blocking)

buffers_map.get(node.name) in mutated_targets returns None for non-buffer placeholders (params/constants), and None in mutated_targets is False — so there's no risk of a false positive there. Good. The one thing to be aware of: this relies on None never being a legitimate member of mutated_targets, which holds because mutated targets are always fqn strings. Fine as-is; just flagging the implicit assumption.

The sibling function has the same latent pattern

tag_mutated_buffer (exir/backend/utils.py:415) still identifies mutated buffers purely by the direct-user walk:

for node_user in node.users:
    if node_user.name in buffers_to_mutate:
        is_mutated_buffer_node = True

That's the exact pattern you just fixed in tag_constant_data. Any partitioner that relies on tag_mutated_buffer to tag an indirectly-mutated buffer into its partition would miss it. It's out of scope for this PR (and possibly not hit by the Core ML path, since the symptom here was mis-tagging as constant, not failing to tag as mutated), but it's worth a follow-up so the two functions agree on what "mutated" means. Fix this →

The test is good

test_buffer_mutated_indirectly_is_not_taken_for_constant is well-constructed:

  • The catcopy_ → slice pattern faithfully reproduces the indirect-mutation shape.
  • The guard assertion ("this test is only meaningful while the mutation is not a direct user") is a nice touch — it protects the test from silently becoming a no-op if export lowering ever changes the graph shape so the write becomes a direct user.
  • It correctly fails on main and passes here, as claimed.

Two minor test nits (optional):

  • The placeholder = next(...) picks the first buffer placeholder. The model has exactly one buffer, so this is unambiguous — but a comment or an explicit cache filter would make the intent obvious if someone adds a second buffer later.
  • Consider also asserting the buffer is absent from / not handed to the delegate partition, to lock in the end-to-end intent, not just the delegation_tag is None symptom. The current assertion is a fine proxy, so this is purely defensive.

On the thread churn

The load/execute error, the measurement correction, and the final 27/27 unquantised agreement all point to this being a genuine partitioning fix independent of the separate 4-bit-weight quality issue — agreed that the 4-bit results don't belong to this PR. Good discipline posting the correction rather than leaving the wrong numbers standing.


Verdict: LGTM. Correct root-cause fix, matches on the right key, keeps the old path where it's still needed, and ships with a test that fails without the change. Only real follow-up is the mirror pattern in tag_mutated_buffer.

@metascroy

Copy link
Copy Markdown
Contributor

@john-rocky did you confirm this PR fixes the "The input feature for layers_7_conv_conv_state must be an MLState, but it was not." issue in the motivation?

Generally that error I have seen when you run a model with states on a binary compiled for < macOS15/iOS18.

Comment thread exir/backend/utils.py Outdated
# this hold for a buffer that is not mutated in one step: a cache that is first
# concatenated with the new values and written back further down the chain has
# something other than the mutation as its user, and used to be taken for a
# constant. It would then be handed to a delegate as a buffer, and a backend that

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you remove references to CoreML in the comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Splitting the function for the C901 lint moved the explanation into the helper's docstring, and the backend-specific wording — the Core ML mention and take_over_mutable_buffer — is gone.

@metascroy

Copy link
Copy Markdown
Contributor

The PR seems reasonable, but can you take a look as well @JacobSzwejbka since it's editing exir

Review asked for two things here: the comment explaining the
target-matching should not name a specific backend, and flake8 C901
flagged tag_constant_data at complexity 13 against the limit of 12.
Moving the detection loop into _mutated_buffer_placeholders answers
both — the explanation moves into the helper's docstring with the
backend-specific wording dropped, and both functions sit below the
complexity limit. Tagging behaviour is unchanged.
@john-rocky

Copy link
Copy Markdown
Contributor Author

@metascroy Yes — and to make sure the answer doesn't rest on the LFM2.5 runs alone, I re-confirmed it today with a controlled A/B.

Your diagnosis of when the error fires matches what I measure. It takes a model with state features running on a binary that does not bind states. On the same pip executorch 1.4.0 pybind runtime (macOS arm64), a genuinely stateful model — a directly written buffer, take_over_mutable_buffer=True, iOS18 target — fails with exactly this error too, so that binary declines states regardless of this PR, which matches the build-time gating you describe.

What the PR fixes is why a nominally stateless export was stateful at all. With take_over_mutable_buffer=False — what export_llm hardcodes — the old direct-user check missed a buffer whose write is not its direct user, tagged it as constant data into the delegate, and coremltools compiled it into a state. A pipeline that had opted out of states produced a model demanding one.

The A/B, single variable: same script, same binary, same macOS, only the tag_constant_data implementation swapped.

class ConvCache(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.register_buffer("conv_state", torch.zeros(1, 8, 4))
        self.conv = torch.nn.Conv1d(8, 8, 5)

    def forward(self, x):
        h = torch.cat([self.conv_state, x], dim=2)  # the buffer's user is the cat
        self.conv_state.copy_(h[:, :, 1:])          # the write lands downstream
        return self.conv(h)

exported with CoreMLPartitioner(compile_specs=CoreMLBackend.generate_compile_specs(minimum_deployment_target=ct.target.iOS18), take_over_mutable_buffer=False) and run through the pybind runtime:

  • old: the buffer is folded into the delegate (no top-level buffer input), load succeeds, execute fails with The input feature for conv_state must be an MLState, but it was not.
  • new: the buffer stays a top-level mutable input, two steps run and match eager, max |diff| 1.5e-4.

So the fix removes the error by removing the unintended states, not by teaching the runtime to bind them — a model that is supposed to have states still needs a macOS15/iOS18-capable binary, exactly as you said. Happy to fold this two-cause distinction into the PR description if that's useful.

@john-rocky

Copy link
Copy Markdown
Contributor Author

On test-qnn-delegate-linux: the failing case is test_qnn_backend_linear_block (the bias=True subtest), which lowers a plain nn.Linear. It registers no buffers, so buffers_to_mutate is empty and the branch this PR adds cannot fire there — the job's own log confirms the mutated-buffer path never ran in that test, while the bias=False subtest and the non-block variant pass, and the job is green on trunk. The lint-fix push will re-run it; if it fails again deterministically I'll dig further.

@metascroy

Copy link
Copy Markdown
Contributor

This fix to tag_constant_data was landed by someone else here: #22172

(Thanks for originally finding it @john-rocky!)

There is a similar fix needed for mutable buffers here: #22296

@john-rocky

Copy link
Copy Markdown
Contributor Author

Thanks @metascroy for the pointer, and thanks @usamahz for landing the fix. #22172 fixes tag_constant_data the same way this PR did — matching against the signature's mutation targets — and adds a regression test, so I'm closing this one. If a cat-and-write-back cache shape (where the mutated value is derived through other ops) would be a useful extra test case for #22296, I'm happy to port mine over.

ご教示ありがとうございます @metascroy さん、修正を入れてくださった @usamahz さんにも感謝します。#22172 が本 PR と同じ方式(signature のミューテーション対象との照合)で tag_constant_data を修正し回帰テストも追加しているため、こちらは閉じます。cat で結合して書き戻す cache 形状(変更後の値が他の op を経由するケース)が #22296 のテストとして役立つようでしたら、喜んで移植します。

@john-rocky john-rocky closed this Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

export_llm Core ML builds fail at execute: "must be an MLState" even though take_over_mutable_buffer=False

3 participants