Skip to content

[XNNPACK] Delegate GEMMs whose weight is computed from parameters - #24352

Open
JakeStevens wants to merge 1 commit into
pytorch:mainfrom
JakeStevens:xnnpack-computed-gemm-weights
Open

JakeStevens wants to merge 1 commit into
pytorch:mainfrom
JakeStevens:xnnpack-computed-gemm-weights

Conversation

@JakeStevens

@JakeStevens JakeStevens commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #22078

The XNNPACK partitioner requires a GEMM's weight and bias to be static params. A convolution, linear, addmm or mm whose weight is computed from parameters is declined, and it runs on the portable kernels together with the computation of its weight. torch.nn.utils.parametrizations.weight_norm and spectral_norm produce exactly this graph, and they are common in audio models; the issue measured 941x on wav2vec2's positional convolution.

This is a smaller alternative to #22391 and #23154, which fold parameter-only subgraphs in transform_for_pre_decomposition. Folding there rewrites the shared ATen program, before functionalization and before every other partitioner and method sees it. Here the top-level graph is not modified. get_static_param_deps lets a GEMM config accept a weight or bias computed with pure ATen ops from parameters, unmutated buffers or constants. It partitions that computation together with the GEMM and tags it, and FoldComputedParamsPass, the first XNNPACK preprocess pass, folds it into a constant inside the delegate. After the fold, the delegate looks exactly as if the weight had been a parameter, and the named data store's content hashes deduplicate identical folds across methods.

The partitioner only accepts a computation that the fold can replace outright without growing the program. Only the folded tensor and its source parameters may be used outside the computation, and only by that GEMM. The folded tensor may be no larger than the parameters it frees, and sources tagged for an external data file are left alone. A computation shared by several GEMMs is therefore not folded, such as the packed bias split across three linears in #23154; delegating it would require merging those GEMMs into one partition, which can create a cycle. Tags are set while a GEMM is being checked, before it is accepted, and a GEMM rejected by an earlier partitioner leaves them on nodes that later become delegate inputs, so the pass folds a tagged node only when one of the GEMMs that tagged it is in the same delegate and the node's inputs are params or already folded. As with a buffer used directly as a weight today, a buffer that only another method writes is treated as constant. Exclusivity is also checked per method. If another method uses a source without delegating it, as when an embedding lookup shares its weight with x @ W.t(), the program stores the source as a constant and the delegate stores the folded tensor. F.linear(x, emb.weight) is already stored twice like this on main.

Suggested review order: get_static_param_deps and its two call sites in gemm_configs.py, then fold_computed_params.py.

wav2vec2 pos_conv, weight_norm(Conv1d(1024, 1024, 128, padding=64, groups=16), dim=2) on its own at sequence length 499, synthetic weights, macOS arm64, median of 5 runs after 2 warm-up runs:

delegates ops left on portable median latency max abs diff vs eager
main 2 pow, sum, convolution 3709.8 ms 1.43e-05
this PR 1 none 3.6 ms 1.45e-05

The .pte is 1,280 bytes smaller: one folded weight replaces weight_g and weight_v, and the program has one delegate instead of two.

Test plan

python -m pytest backends/xnnpack/test/passes/test_fold_computed_params.py

The new tests lower a weight_norm Conv1d and a linear with a computed weight and bias, run them through the runtime against eager, and check that nothing is left outside the delegate and, for the linears, that the delegate's constants are exactly the folded tensors. A linear with a computed bias is also covered under static and dynamic quantization. They also check what must not fold: a source used elsewhere, a mutated buffer, a low-rank product that would grow the program, a source that stays another input of the GEMM, an intermediate that also feeds the GEMM, sources tagged for an external file, and a weight computed by a custom op. A rejected convolution whose weight is computed by a delegated matmul, a weight computed by an earlier partitioner's delegate, and an addmm that one partitioner tags and rejects and a later one takes with a dynamic weight still export and match eager. A param that already has the folded weight's name is not replaced, and a scaled addmm with a computed weight stays on portable and matches eager.

python -m pytest backends/xnnpack/test/passes backends/xnnpack/test/ops backends/xnnpack/test/serialization backends/xnnpack/test/quantizer backends/xnnpack/test/test_xnnpack_partitioner.py -o addopts=""

This includes test_linear.py, test_bmm.py and the quantizer tests, which pytest.ini skips. The failures are the same as on main: three qd8 int4 linear tests, and quantizer tests that fail depending on run order. I also compared against main for weight_norm on Linear, Conv2d, depthwise and transposed convolutions, spectral_norm, a dynamic sequence length, and two methods sharing a folded weight. Tied embeddings within one method, packed biases and low-rank weights produce the same .pte as on main.

This PR was authored with Claude Code.

@pytorch-bot

pytorch-bot Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

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

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

❌ 5 New Failures

As of commit 1a735c5 with merge base 455aa9c (image):

NEW FAILURES - The following jobs have failed:

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 Oct 30, 2026
@JakeStevens
JakeStevens force-pushed the xnnpack-computed-gemm-weights branch from 96e5183 to 3008de5 Compare November 9, 2026 21:04
@github-actions

github-actions Bot commented Nov 9, 2026

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.

@JakeStevens
JakeStevens force-pushed the xnnpack-computed-gemm-weights branch from 3008de5 to dc58a2f Compare November 10, 2026 13:47
A convolution, linear, addmm or mm whose weight or bias is computed from
parameters, such as a convolution under weight_norm or spectral_norm, was
declined by the XNNPACK partitioner and ran on the portable kernels together
with the computation of its weight.

The GEMM configs now accept such a weight or bias when it is computed with
pure ATen ops from parameters, unmutated buffers or constants, the computation
feeds only that GEMM, and the folded tensor is no larger than the parameters
it replaces. The computation is partitioned with the GEMM, and
FoldComputedParamsPass folds it into a constant at the start of XNNPACK
preprocess, inside the delegate, so the top-level graph is not modified.

Fixes pytorch#22078

This change was authored with Claude Code.
@JakeStevens
JakeStevens force-pushed the xnnpack-computed-gemm-weights branch from dc58a2f to 1a735c5 Compare November 10, 2026 14:06
@JakeStevens

Copy link
Copy Markdown
Contributor Author

@claude review effort:max

@claude

claude Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Claude finished @JakeStevens's task in 8m 21s —— View job


This PR is functionally sound and no blocker turned up, but the partitioner and the fold pass communicate through node-name tags that get written during constraint checks and are never cleared, and the main weight_norm test never checks that a fold happened.

Detailed review

PR Review: #24352

Summary

The PR lets XNNPACK GEMM configs accept a weight or bias computed from params with pure ATen ops: the computation is partitioned with the GEMM, then FoldComputedParamsPass folds it into a constant during preprocess. No correctness bug was confirmed. The issues are the tagging contract between the two halves, a few overstated guarantees, the cost of the new search, and test coverage. Three independent reviewers covered all 6 changed files, and every finding below was re-checked against the code.

Code Quality

  • The partitioner and the pass are coupled through a side channel (gemm_configs.py:119-121, fold_computed_params.py:27-35, utils/utils.py:31-34). All three reviewers raised this.
    • Where tags get written: get_static_param_deps writes COMPUTED_PARAM_TAG from inside check_constraints, which should be a predicate.
      • It runs for GEMMs that are later rejected. For example, the bias passes at :206, then the weight fails at :207.
      • It runs again from get_node_and_deps.
      • For linear it also runs from ConfigerationBasedPartitioner.ops_to_not_decompose's filter_fn (exir/backend/canonical_partitioners/config_partitioner.py:183-186). That writes tags onto the pre-decomposition ATen graph, using ATen node names.
    • Tags are never cleared. They persist in the top-level program's node meta.
    • How the pass compensates: it folds any tagged node whenever any node in the delegate graph (placeholders included) has a name in the tag set.
    • Why it holds today: this is safe only because of cycle avoidance and because nodes are folded only from params, which keeps it semantically correct. The contract "this tag means the GEMM consuming this value is in this delegate" is never checked.
    • Suggested fix: make the check local in the pass. Fold a root only if its users outside values are call_function nodes whose name is in the root's tag. Separately, tag only from get_node_and_deps (the accept path), not from check_constraints. That removes the stale-tag case the comment at fold_computed_params.py:30-32 has to explain.
    • Fix this →
  • "Without growing the program" is only checked per method (gemm_configs.py:107-117).
    • The sharing and size checks look only at the current method's graph.
    • In a multi-method export where another method uses weight_v/weight_g directly, those sources stay in the .pte and the folded tensor is added on top.
    • The same per-method scope is why a buffer written only by another method gets baked in at its initial value. That case is acknowledged in the PR description, but neither appears in the docstring or the tests.
    • Either qualify the guarantee in the docstring at :57-65, or say explicitly that this is accepted.
  • Rejection messages got less useful (gemm_configs.py:103). For a plain dynamic weight, the old messages were "Expected weight to be a static param" and "Expected bias to be a static param". Now the message is "{dep} is neither a param nor an op that can be folded into one", where dep can be an arbitrary ancestor, such as a user-input placeholder several layers upstream. It also no longer says whether the weight or the bias failed. Consider keeping the old message when node itself is not foldable, or prefixing the argument role.
  • Comment density (minor). The 9-line docstring at gemm_configs.py:57-65, the comment at utils/utils.py:31-33, and the comment at xnnpack_preprocess.py:229 largely restate the PR description, and # noqa: C901 is needed. CLAUDE.md asks for minimal comments. Splitting the search (:70-105) from the user and size validation (:107-117) would remove the noqa and make the contract easier to read.

Testing

  • The main use case never asserts that the fold happened (test_fold_computed_params.py:192-193). test_fp32_weight_norm_conv1d doesn't pass folded=, so nothing checks that weight_g/weight_v left the delegate or that the serialized constant is the normalized weight. The linear tests do assert this through named_data_store_output.buffers.

  • Cases the description relies on have no test:

    • Multi-method content-hash dedupe of identical folds.
    • A computation shared by several GEMMs being declined (e.g. w2 = w * 2 used by two linears). SourceUsedElsewhere and WeightComputationFeedsActivation cover other sharing patterns, not this one.
    • spectral_norm.
    • conv2d, depthwise and transposed conv with a computed weight. Their weights are permuted or regrouped at serialization.
    • A positive case with a dynamic activation shape.

    The description says these were compared manually against main. Adding the conv2d/transposed and multi-method ones as tests would lock in that behavior.

Performance

  • Unbounded ancestor walk for dynamic weights (gemm_configs.py:73-105). Two reviewers raised this; it was not measured.
    • Before this PR, a non-param fp32 weight or bias was rejected in O(1).
    • Now get_static_param_deps walks every pure-ATen ancestor until it reaches a user input or an unfoldable op, with no depth limit.
    • For linear/mm/addmm/conv whose weight comes from activations, that walk can cover most of the upstream graph. It runs at least twice per GEMM (check_constraints at :146, get_node_and_deps at :153), three times for linear because of filter_fn, and mutated_buffers is rebuilt on every call.
    • On large fp32 graphs this can be roughly quadratic.
    • Rejecting as soon as a non-param placeholder is popped would help, and so would checking all_input_nodes for user-input placeholders before descending, or caching the result per (node, user).

Recommendation

Needs Discussion

The fold is correct and the benchmark result is compelling. Before merging, the partitioner→pass contract should be checked locally (or tags written only on accept) rather than inferred from graph-wide name matching. The weight_norm test should also assert the fold.

@digantdesai digantdesai left a comment

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.

I will take 1000X improvement for these gemms, thank you! :)

@JakeStevens

Copy link
Copy Markdown
Contributor Author

@john-rocky and @mansnils can you take a look and see how well this covers your respective PRs? both were starting to get a little unwieldily and devolving with multiple rounds of review. this is not necessarily a 1:1 complete replacement, but i hope it can get most of the benefits you were hoping for with a more limited blast radius

@john-rocky

Copy link
Copy Markdown
Contributor

Thanks, @JakeStevens; folding inside the delegate is cleaner than mine. I lowered #22391's test graphs plus a few on main and both branches, without running them.

Same delegation as #22391: weight_norm on convolutions and Linear, x @ w.t(), a 600-op chain and .item() scaling. spectral_norm is delegated here but not in #22391.

#22391 also delegates GEMMs whose weight comes out of split, chunk, unbind or max, and t() of .ptd-tagged weights; here they stay on portable, as on main.

An Embedding shared by a lookup method and an x @ W.t() method gives a 526,976-byte .pte here, 264,720 on main.

I'll close #22391 once this lands. If useful, I'm happy to help with a small follow-up for the packed split nn.MultiheadAttention uses in cross-attention.

@JakeStevens

Copy link
Copy Markdown
Contributor Author

@john-rocky thanks, reproduced: 526,976 vs 264,720 bytes. Main stores F.linear(x, emb.weight) in the second method the same way:

second method main this PR
x @ W.t() 264,720 526,976
F.linear(x, W) 526,976 526,976

On main, x @ W.t() stores W once only because just the permute_copy is delegated, with W as a runtime input, and the mm stays on portable. Here the fold stores W.t() in the delegate's named data, and the lookup method keeps W as a program constant. Named data isn't deduplicated against program constants, and the exclusivity check runs one method at a time, so it doesn't see the lookup. The same applies to any static XNNPACK weight today. Fixing it would take a cross-method or cross-segment change rather than something in this PR, so I've added the cross-method case to the description.

Thanks also for checking #22391's graphs. A follow-up for the packed nn.MultiheadAttention split would be welcome.

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

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.

to_edge_transform_and_lower does not constant-fold parameter-only subgraphs, so weight_norm'd convolutions never reach the delegate (941x on one module)

3 participants