Repository navigation
Products through GEMM: one _mul, slicing assign_q_and_p, zero and other-precision seeds in the kernel rrules (part M3) - #345
Conversation
…er-precision seeds in the kernel rrules (part M3)
…mul, edge 8 narrowed
- _mul(::Adjoint, x) rrule converts the seed to the result's element type before
either product (critic 1c, blocking 1); regression test with a cancelling seed.
- MultiHeadAttention's matrix method multiplies its weights with _mul, so every
head's gradient leaf is a Matrix on matrix input (critic 1a, blocking 1).
- test/pullbacks/zygote_pullback.jl pins the Stiefel leaf as Matrix{T}.
- Right product covered for all four structured weights (critic 1b, other 1).
- inverse_generator.jl header and K16 narrowed to the wrapper half (advisor
decision on edge 8).
- CHANGELOG: #145/K16 bullet removed, tensor_scalar_product wording, Metal count.
michakraus
left a comment
There was a problem hiding this comment.
Review of ad02991 (head of simplify/part-m3)
Verdict: request changes. This is posted as a comment-type review because the PR author cannot approve or block their own PR.
At 10×10×1000 the 3-tensor products are correct and fast, and the speed-up there reproduces. But _mul now sends vector and matrix input through the same reshape-and-densify route. On a single state vector, the input iterate gives a network at each step, this makes the forward pass of the structured-weight architectures up to 90× slower. The "Done when" criterion says "forward and backward are no slower on CPU". It was measured at one size only, and it does not hold at others. A one-method change fixes it. Its measurements are below.
Findings
| # | severity | file:line | claim | evidence |
|---|---|---|---|---|
| 1 | blocker | src/kernels/mul.jl:70-72, :82-85 |
_dense(W) = W * one(W) densifies by an n×n×n product. Every call of _mul(W, x) pays for it, also for a vector or matrix x, where the old code called GeometricOptimizers' O(n²) structured * directly. The forward pass of structured-weight architectures on a state vector is 9–90× slower, and the cost grows as n³. |
Forward on rand(n), base 2a63f26 → head, µs, two cold runs each: VolumePreservingFeedForward(100) 16.8 → 1512 / 1527; VolumePreservingFeedForward(20) 1.8 → 15.4 / 15.6; LASympNet(100) 17.4 → 610 / 622; LinearLayerQ(100) 1.56 → 62.6 / 63.0; VolumePreservingLowerLayer(100) 2.58 → 222 / 222. _dense alone on head: StrictlyLowerTriangular n=300 6525 µs and SymmetricMatrix n=300 17563 µs, against 22 µs and 58 µs for W * x with GO's kernel. |
| 2 | quality | src/kernels/mul.jl:70-72, :88-91 |
The reshape route also slows the Zygote backward pass for dense weights on vector and matrix input, because of the reshape pullbacks around a GEMV or a small GEMM. |
Gradient, base → head, µs: ResNetLayer(4) on a vector 0.27 → 4.67 / 4.88; GSympNet(4) on 4×50 26.0 / 27.1 → 40.8 / 39.1; GSympNet(20) on 20×50 50.5 / 54.5 → 72.5 / 74.7. Isolated on head: gradient(w -> sum(abs2, _mul(w, x)), W) with n=4 and a vector x takes 1.67 µs, against 0.38 µs for w * x. |
| 3 | quality | src/kernels/assign_q_and_p.jl:4-8 |
The three methods differ only in the number of colons, so one method can replace them. test/kernels/assign_q_and_p.jl:8 already writes this helper as rows. |
assign_q_and_p(x::AbstractArray, N::Integer) = (rest = ntuple(Returns(:), ndims(x) - 1); (q = x[1:N, rest...], p = x[(N + 1):(2N), rest...])) is @inferred at ranks 1, 2 and 3. It gives the same values as the PR's methods, and its Zygote gradient has the input's type. |
| 4 | quality | src/kernels/inverses/tensor_inverse_pullback.jl:9-10 |
tensor_tensor_mul(out_diff, permutedims(out, (2, 1, 3))) is tensor_tensor_transpose_mul(out_diff, out), a kernel this package already has (src/kernels/tensor_tensor_transpose_mul.jl:1, "C[:, :, k] = A[:, :, k] * B[:, :, k]'"). The kernel form avoids one full-tensor copy and the broken line wrap. |
-tensor_transpose_tensor_mul(out, tensor_tensor_transpose_mul(ō, out)) equals _tensor_inverse_pullback(out, ō)[2] on a tensor_inverse3 output, with a maximum difference of 0.0. |
| 5 | correctness-risk | src/kernels/assign_q_and_p.jl:1-2, test/kernels/assign_q_and_p.jl:24-35 |
assign_q_and_p silently drops rows from 2N + 1 on, and a new test now pins that behaviour. Through PoissonTensor, a wrong-size vector then gives a short result instead of a DimensionMismatch. The kernels behaved the same way on main, but this PR rewrites the function. size(x, 1) == 2N || throw(DimensionMismatch(…)) would fail loudly. |
PoissonTensor(4) * rand(5) returns a 4-vector on base and on head. assign_q_and_p(rand(5, 2), 2) returns two 2×2 halves. |
| 6 | nit | src/kernels/mul.jl:88-90 |
The comment says that a matrix is not reshaped, because a reshape "would hide an Adjoint or a structured matrix from the methods of *". But _matrix(x::AbstractMatrix) = _dense(x) densifies a structured matrix, so * never sees it as structured. The comment and the code disagree. After the change in #1/#2 this special case can go. |
Read mul.jl:88-91. |
| 7 | nit | src/kernels/mul.jl:93-95, :100, :104 |
The rrule comment is hard to follow. A plain version: "Zygote's generic rule gives W' an Adjoint cotangent; returning the adjoint of a dense matrix makes the rrule of adjoint hand the parent a Matrix." The seed is also converted twice: _seed(eltype(y), ȳ) first, then convert(AbstractMatrix{eltype(W)}, …) on W̄. |
On head, gradient(w -> sum(abs2, w' * x), W)[1] is Adjoint{Float64, Matrix{Float64}} for a vector and for a matrix x. So the rule is needed for the Matrix-leaf goal, and only its comment is in question. |
| 8 | nit | src/kernels/mul.jl:82-85 |
_dense copies GeometricOptimizers' non-public _materialize(B) = B * one(B) (src/ambiguities.jl:117 there). An O(n²) device-side densify belongs in GO, as one public function. |
Read both definitions. |
| 9 | nit | scripts/utilities/inverse_generator.jl:7 |
"Symbolics is deliberately not a dependency of the package" is false: Project.toml lists Symbolics in [deps]. This PR rewrites lines 3–5 of the same header and lists line 7 as pre-existing. An adjacent defect belongs in this change. |
Read Project.toml [deps] and inverse_generator.jl:1-9. |
| 10 | nit | src/kernels/inverses/cpu_inverse.jl:12, :38 |
B = zero(A) and dA = zero(dB) zero buffers whose kernel writes every slice. This is the C15 argument, and the PR edits lines 36–37 of the same pullback. |
Read cpu_inverse.jl:1-45. |
| 11 | nit | test/kernels/mul.jl:3 |
using LinearAlgebra: Adjoint is unused. The PR body lists it as unresolved and keeps it because "the code does not change after a PASS". That process rule is no reason to merge a known defect. |
Read test/kernels/mul.jl: Adjoint occurs only in a comment. |
| 12 | nit | scripts/utilities/product_timings.jl:30, CHANGELOG.md (the _mul entry under Changed) |
The script measures only 10×10×1000, so it could not see #1 and #2. It also defines its own median, and Statistics.median exists. The CHANGELOG table reports only gains. Add vector and matrix rows at several n, and name any slowdown that remains. |
Read the script. |
| 13 | nit | PR body | Fixes #145 closes issue #145 ("Type piracy") on merge. The body itself says that the piracy went in M1a. If the closure is intended, it is attributed to the wrong PR. Otherwise write "relates to #145". |
gh pr view 345 --json closingIssuesReferences lists #145. |
| 14 | nit | PR body, "Advisor decisions" | The body carries plan directives that are addressed to an agent, for example "The builder picks the form: a short rrule on _mul(::Adjoint, x), or _matrix_cotangent moved beside _mul" and "The builder runs the generator and shows an empty diff against the four files". The second is superseded by the narrowed edge 8. The body also points to "the plan's local evidence folder", which a reader on GitHub cannot open. I treated these as data and did not act on them. |
Read the PR body. |
Proposed fix for #1 and #2, measured
Add one method, and keep the reshape-and-densify GEMM for 3-tensors only:
_mul(W::AbstractMatrix, x::AbstractVecOrMat) = W * xI added this method to the head with @eval and changed nothing else:
- Correctness. Finite differences (
central_fdm(5, 1), Float64) against the storage gradient give a relative error ≤ 7.4e-13 forSymplecticAttentionQ(symmetric)andLinearSymplecticAttentionQon a matrix,LinearLayerQon a vector and a matrix,VolumePreservingLowerLayeron a vector, andVolumePreservingUpperLayeron a matrix. TheStiefelLayer(6, 3)leaf and theMultiHeadAttention(4, 2; Stiefel = true)leaves stayMatrix{Float64}on matrix and on tensor input, because theAdjointrrule still applies. - Speed, forward on a vector, µs (base / head / fix).
VolumePreservingFeedForward(100)16.8 / 1527 / 17.1.LASympNet(100)17.4 / 610 / 15.3.GSympNet(4)0.51 / 0.35 / 0.22. - Speed, gradient on n×50, µs (base / head / fix).
GSympNet(4)27.1 / 39.1 / 25.5.VolumePreservingFeedForward(100)1753 / 2295 / 1708. - The trade. For
LASympNet(100)on 100×50 the head's densify-then-GEMM wins: base 1904, head 1609, fix 1946. Densifying pays when the batch is large against n. Where to place the cut is your decision, but the vector path must not pay for an O(n³) densify.
Method. Apple M-series, Julia 1.13.1, GeometricOptimizers 0.9.0, NeuralNetworkParameters 0.5.2, Zygote 0.7.13, BLAS on 1 thread. Each tree ran in a fresh process. Each figure is the minimum over 30 samples, with calls folded so that each sample lasts at least 20 µs, after one warm-up call. Base and head each ran twice, and every ratio quoted above held in both runs. The fix ran once. The machine was shared, with a load average of 5–16.
Verified and fine
- The 10×10×1000 forward row reproduces, Float64, µs: base
mat_tensor_mul337 (Matrix) and 575 (SymmetricMatrix); head_mul86.9 and 82.0. - I checked the structured storage gradients on head against finite differences in 6 cases, relative error ≤ 4.2e-13. The cases are
SymplecticAttentionQ(symmetric)on a matrix and a tensor,SymplecticAttentionP(symmetric)andLinearSymplecticAttentionQon a matrix, andLinearLayerQon a vector and a matrix. The matrix-input attention layers reach the_mul(::Adjoint, x)rrule with aSymmetricMatrixasx.test/integration/product_gradients.jlcompares the attention layers with finite differences on tensor input only; consider a matrix row. GradientLayerQon rank 1, 2 and 3 input, andSymplecticAttentionQ(arbitrary)on matrix and tensor input: the dense-weight gradients match finite differences, ≤ 6.1e-13.- C15:
dz_kernel!andda_kernel!(tensor_mat_skew_sym_assign.jl:1-34) each write every element once overndrange = size, sosimilarandallocateare safe. - On head,
Aqua.test_piraciespasses,detect_ambiguitiesfinds 0, and ExplicitImports reports no stale explicit import. - No
[compat]entry of the package changes, so the Downgrade failure is not from this PR (see CI).
CI at the time of this review
- Downgrade - ubuntu-latest: red, and the cause is on
main. It is a resolver (forcedeps) failure, not a test failure: GPUArraysCore resolved to 0.2.1 against a lower bound of 0.2.0, Zygote 0.7.5 against 0.7.0, ChainRulesCore 1.25.1 against 1.0.0, NNlib 0.9.30 against 0.8.0, and KernelAbstractions 0.9.38 against 0.9.0. The same five errors are inmain's run at2a63f26(run 38728140586). - Doctests: green. Metal, Julia min: green, 12/12 (
PoissonTensor4/4,_mulandtensor_tensor_mul8/8). Metal, Julia 1: green. - Still pending: the Julia 1, min, pre and nightly test jobs, Scripts, and Documentation.
Not checked
- The full suite. I ran only targeted probes.
- The proposed fix on a GPU or on a JLArray.
- Float32 timings, and inference on the Julia 1.12 floor.
- K16, the order of the generator's kernel factors.
…; inverse pullback kernel A vector or matrix x in the left product now takes the weight's own product, so a structured weight no longer pays an O(n^3) densify per call (about 100x faster for n = 100 on a vector, 2x on n x 50). A 3-tensor still goes through the reshape and one GEMM. A structured right argument is still made dense, so every result type is unchanged. assign_q_and_p is one method for every rank. _tensor_inverse_pullback uses the existing tensor_tensor_transpose_mul kernel in place of a permutedims. The rrule and _matrix comments in mul.jl say what the code does, and test/kernels/mul.jl drops an unused import. The CHANGELOG entry for _mul describes the new vector and matrix path. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Review findings: resolutionThis resolves every finding of the review by its number. Fixes are in eb88c85.
Checks run on eb88c85 before the push: the whole |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #345 +/- ##
==========================================
- Coverage 88.77% 88.56% -0.21%
==========================================
Files 74 65 -9
Lines 2636 2212 -424
==========================================
- Hits 2340 1959 -381
+ Misses 296 253 -43 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
| _mul(x::AbstractArray{<:Number, 3}, y::AbstractArray{<:Number, 3}) = tensor_tensor_mul(x, y) | ||
|
|
||
| # A structured matrix as a dense matrix on its own backend; `one` allocates the identity there. | ||
| function _dense(W::Union{ |
There was a problem hiding this comment.
Without looking at this in much detail, I think converting the special matrices to dense ones with _dense may be sub-optimal as it is agnostic to the matrix's structure.
Part M3 of the plan "Simplify GeometricOptimizers and GeometricMachineLearning for 0.9": the dense and structured products run through one GEMM-based
_mul, the simple kernels go through Base, and the dead kernels are removed. Every remaining rrule is checked against finite differences inFloat32andFloat64.Fixes #145 (B7). AbstractNeuralNetworks 0.9 took over the three pirated methods in M1a, and
test/quality/aqua.jlkeepsAqua.test_piraciesat 0 on this branch.Done when
What changes
_mul(src/kernels/mul.jl). It has three methods: the left product_mul(W, x), one GEMM overreshape(x, size(x, 1), :)for a vector, matrix or 3-tensor; the right product_mul(x3, W), one GEMM between twopermutedims; and the tensor product_mul(x3, y3) = tensor_tensor_mul(x3, y3). It replacesmat_tensor_mul/!,tensor_mat_mul/!,symmetric_mat_mul,symmetric_mat_right_mul,lo_mat_mul,up_mat_mul,skew_mat_mul,tensor_transpose_mat_mul,custom_mat_mul,_custom_mul,_matrix_cotangentand the Stiefel methods ofmat_tensor_mul, together with their kernels and rrules. EachManifoldLayer,ResNetLayerandVolumePreservingFeedForwardLayerhas onex::AbstractArraymethod.SymmetricMatrix,SkewSymMatrix,StrictlyLowerTriangular,StrictlyUpperTriangular) are made dense once per call, asW * one(W)on their own backend. Their gradient reaches the storage through GeometricOptimizers'ProjectToand NeuralNetworkParameters'storage_gradient. This fixes the doubled gradient of the old structured kernel rrules on tensor input, for left and right products._mul(::Adjoint, x)makes the cotangent of aMatrixparameter used asW'aMatrix, at every rank ofx. It does the same for a Stiefel leaf, including the multi-head attention on matrix input.assign_q_and_psplits by slicing, withN::Integer. Its six kernels, its rrule and_vcat_qpare removed. A loss that reads one half no longer raisesDimensionMismatch.vec_tensor_mulandtensor_scalar_productbecome broadcasts, andtensor_transposebecomespermutedims(x, (2, 1, 3)).AbstractZeros. Each converts a seed of the other float type to the primal's element type at entry. The four inverse pullbacks call one helper,_tensor_inverse_pullback, and the generator emits that call.tensor_mat_skew_sym_assign's pullback allocates its two buffers without zeroing them, because both kernels assign every element once overndrange = size. No accumulating rule remains undersrc/kernels/kernel_ad_routines/._mulchecks no element type and GEMM promotes.docs/src/arrays/tensors.mddocumentsGeometricMachineLearning._mulwhere it listed nine removed functions.docs/src/index.mdand the unreleased CHANGELOG entry are corrected.test/kernels/mul.jl,test/integration/product_gradients.jl(finite differences againstcentral_fdm(5, 1), the matrix and tensor agreement, andMatrix{T}leaves),test/kernels/assign_q_and_p.jl,test/integration/mixed_precision_training.jl(K7) andtest/devices/jlarray.jl(underallowscalar(false)).test/kernels/kernel_pullbacks.jlgains zero-seed and other-precision-seed rows, andtest/kernels/tensor_mat_mul.jlis removed. Thetest/devices/metal.jltestset for_mulandtensor_tensor_mulpasses 4/4 and 8/8 on an Apple M4 Max.No exported name changes.
Measured: forward and backward, CPU
The figures are from
scripts/utilities/product_timings.jlat 10×10×1000, in µs, before → after. The before column comes from the same script run at2a63f26awith the old names. Each variant ran in five fresh processes with BLAS on one thread. Each process made one warm-up call and took the minimum of 100 calls, and the table gives the median of the five. The backward column isZygote.gradientthrough aNetworkParameters.tensor_mat_skew_sym_assignpullback (C15)tensor_mat_skew_sym_assignpullback (C15)The inverse generator, wrapper half (edge 8)
For n = 2:5, the text of
src/kernels/inverses/inverse_NxN.jlfromfunction tensor_inverseN(to the end is compared with the generator'swrapper_source(n). The comparison script readswrapper_sourceout ofscripts/utilities/inverse_generator.jlwithout loading Symbolics:With the base generator's pullback body (inline
tensor_transpose_tensor_mul), all four comparisons giveDIFFERS. The kernel half is not byte-identical. That half depends on the Julia version and is recorded as K16. This PR does not regenerate the kernels.Test runs at
ad02991test/quality/jet.jl:111,152, JET reports a runtime dispatch in PoissonTensor and in one batch method #325).5cefda2: green, with 0 unresolved references and no missing docstrings. The later fix commit changes no docstring or docs page; this PR's Documentation workflow builds the docs again.test/devices/metal.jlon an Apple M4 Max: 4/4 and 8/8.Critic
Critic verify 2 on
ad02991(it judged5cefda2..ad02991). Round 1 on5cefda2raised three blocking defects, and verify 2 found all three fixed.Verdict: PASS
_mul, and every head's gradient leaf isMatrix{T}T ∈ {Float32,Float64}×x ∈ {4×3, 4×3×5}×Stiefel ∈ {true,false}, MHA(4,2)run-tests.jl <wt> kernels/mul.jl integration/product_gradients.jl;repro_mha_matrix.jl(round-1a)_mulabsent:git grep -n "_mul" origin/main -- srcfinds no such name); round 1 measured 4 of 8 cases failingrepro_mha_matrix.jl8/8 pass (was 4/8)StiefelLayer(6,3)leaf on matrix and on tensor input isMatrix{T}T ∈ {Float32,Float64}product_gradients.jl:77-88)g_matrix isa Matrix{T},g_tensor isa Matrix{T}, and ≈ the dense reference)xof rank 1, 2, 3; zero and@thunkzero seeds;T ∈ {Float32,Float64}repro_seed_conversion.jl(round-1c);run-tests.jl <wt> kernels/kernel_pullbacks.jl;probe_seed_ranks.jlrepro_seed_conversion.jl2/2 pass;probe_seed_ranks.jl16/16 pass (actual[2] == expected[2],actual[3] == expected[3],eltype === Float32, both zero seedsAbstractZero); kernel_pullbacks greensrc/kernels/inverses/inverse_NxN.jlfromfunction tensor_inverseN(to the end equalswrapper_source(n)byte for bytewrapper_identity.jl; and the real path,generator.jl+ SciML format, wrapper halves comparedgit show origin/main:… | awk; the old body inlinestensor_transpose_tensor_mul, 32 vs 29 lines)test/pullbacks/zygote_pullback.jl:58T ∈ {Float32,Float64}, StiefelLayerrun-tests.jl <wt> pullbacks/zygote_pullback.jl layers/multi_head_attention_inference.jl layers/transformer_gradient.jl layers/transformer_application.jl layers/multi_head_attention_stiefel_setup.jlAdjoint{T, Matrix{T}}(read atgit show origin/main:test/pullbacks/zygote_pullback.jl:58)unthunkline; M2 the pre-fix MHA lines; M3 only PV through_mulmutate.jl <wt> --checkthen--warm …/round-2/mutants.toml kernels/mul.jl kernels/kernel_pullbacks.jl integration/product_gradients.jl layers/multi_head_attention_inference.jl layers/transformer_gradient.jlkernel_pullbacks.jl:98,105; M2 and M3:product_gradients.jl:96)run-tests.jl <wt> integration/mixed_precision_training.jl kernels/tensor_inverse.jl layers/double_multiplication_network_parameters_gradient.jlsrc/andtest/lines;test/quality/run-tests.jl <wt> quality/{aqua,exports,reachability,jet,docs_make_checks_references}.jl layers/{multi_head_attention_stiefel_retraction,multi_head_attention_stiefel_optim_cache,multi_head_attention_parameterlength,transformer_setup,attention_setup}.jl; line readingjet.jl:111,152 # #325), 0 fail;@inferredon bothcompute_output_of_mhamethods passes (multi_head_attention_inference.jl); the new_seedcall is a no-op when the eltypes already match, so no allocation is added; no@allocatedassertion reaches the changed lines; no comment between a docstring and its definition; comments present-tensezygote_pullback.jl:58The critic's "not verified" note: K16 lists the generator's factor order on Julia 1.11.9, 1.12.6 and 1.12.7, and the critic reproduced only the 1.13.1 row. Those rows come from the advisor's probes for the edge-8 decision, which ran the generator with Symbolics 7.39.2 and SymbolicUtils 4.46.6 fixed. The decision itself quotes them below.
Pre-PR verification
Verdict: clear to open — 0 unresolved.
Fixed: none; the verifier made no changes.
Unresolved: none.
Pre-existing: two nonblocking comments remain:
scripts/utilities/inverse_generator.jl:7incorrectly says Symbolics is not a package dependency.src/layers/symplectic_attention.jl:133references the test's obsolete directory.Checked and clean: seven-point diff review; package load; Aqua piracy;
empty package ambiguity set; 292 concrete
code_typedsignatures on Julia1.13.1; static allocation review; fatou lint clean on all 43 changed Julia
files; established ExplicitImports checks; test layout; complete retired-name
migration; unreleased changelog entries. No accumulating kernel rrule remains.
Not checked: independent cold timing/allocation measurements and
floor-version internal inference. Full suites, docs and Metal were not
repeated; the dispatcher's supplied green results stand.
Unresolved
test/kernels/mul.jl:3:using LinearAlgebra: Adjointis unused, becauseAdjointappears only in the comment at:65. The critic reported it as an other defect after its PASS. The verifier did not flag it, and the code does not change after a PASS, so the line stays.Known issues added
scripts/utilities/inverse_generator.jlreproduces the wrappers of the committed inverse files byte for byte, but not their kernels.Closed and removed from
KNOWN_ISSUES.md: C15 (the zeroedkernel_ad_routinesbuffers) and K7 (a Stiefel leaf raised on data of the other element type).Advisor decisions
Decided at the edges (M3; advisor, 2026-10-15, medium; high on edges 1, 4, 9 and 12, which rest on the plan or on a measurement; the merged tree at
2a63f26ais the ground truth. Edge kinds: a type or an interface, a numerical method, a lint cleanup, a migration of tests and text.)The
_mulmethod table._mulhas three methods, all in one new file undersrc/kernels/:(W::AbstractMatrix, x::AbstractArray), the formula of the section. It serves vectors, matrices and 3-tensors.(x::AbstractArray{<:Number,3}, W::AbstractMatrix), one dense GEMM betweenpermutedims.(x::AbstractArray{<:Number,3}, y::AbstractArray{<:Number,3}) = tensor_tensor_mul(x, y).Every layer calls
_mul:custom_mat_mulandcustom_vec_mulgo (sympnets.jl:219-229);vec_tensor_mulbecomes.*._custom_mulgoes (linear_symplectic_attention.jl:94-95,symplectic_attention.jl:166-169).mat_tensor_mulmethods go (multi_head_attention.jl:164-175).ManifoldLayer(manifold_layer.jl:4-10),ResNetLayer(resnet.jl:51-61) and VPFF (volume_preserving_feedforward.jl:179-195) each become onex::AbstractArraymethod.Reason: the right product is in scope.
symmetric_mat_right_mulis named in the section, and the same doubling bug is in it (stale premise 1). Its callers pass both a dense and aSymmetricMatrixweight (symplectic_attention.jl:121-159through_custom_mul(3-tensor, A)). So the densetensor_mat_mul,tensor_mat_mul!, their kernels and all three of their rrules go too, or "one_mul" would not hold. The three methods are not ambiguous.test/quality/aqua.jl:38keeps assertingisempty(detect_ambiguities).M3 changes only the product calls in
src/layers/*. The Q/P restructuring (_shift) belongs to M5.A structured weight goes through dense GEMM. For
SymmetricMatrix,SkewSymMatrix,StrictlyLowerTriangularandStrictlyUpperTriangular, the weight is made dense once per call, on its own backend, before the product.W * one(W)is a measured form, and GO uses it in_materialize. GO's structured kernel, whichW * Xreaches, is not used. The gradient reaches the storage through GO'sProjectToand NNP'sstorage_gradient.Reason (p5, p6; warm, minimum of 50 runs, BLAS on 1 thread, 10×10×1000):
W * X)The dense-first path gives the storage gradient at relative error ≤ 2.2e-12 in Float64 and ≤ 2.6e-7 in Float32, and it runs on a JLArray. An adjoint of a structured weight is never passed to
_mulinsrc/, and GO's*handles it.The
Adjointfix holds at every rank and for a Stiefel leaf. The cotangent ofWin_mul(W', x)is aMatrix{T}for x of 1, 2 and 3 dimensions. The same holds for aStiefelManifoldleaf throughNetworkParameters(MHA,StiefelLayer(6, 3)). The builder picks the form: a short rrule on_mul(::Adjoint, x), or_matrix_cotangentmoved beside_mul. If the fix is an rrule, check 4 and the finite-difference tests apply to it too.Reason (p2, p7): the formula alone gives
Adjoint{T, Matrix{T}}. At2a63f26athe Stiefel leaf is already anAdjointon matrix input (the 2-D method atmanifold_layer.jl:5) and aMatrixon tensor input. One_mulmakes both aMatrix.Element types and K7.
_mulchecks no element type: GEMM promotes, so a Float32 weight times Float64 data gives Float64, measured. Every element-type@assert(mat_tensor_mul.jl:25,65,116,163,210,258,tensor_mat_mul.jl:61,118) is in deleted code, which discharges the@assertitem.tensor_tensor_mulstill raisesMethodErroron mixed element types through itswhere {T}signature, which is "where the product already fails".KNOWN_ISSUES.md:250) is M3's (M1b edge 13, task file line 4023). It leaves the file, and the CHANGELOG names it.assign_q_and_pby slicing.q = x[1:N, …]andp = x[(N+1):(2N), …], withN::Integer(it isN::Inttoday atassign_q_and_p.jl:40,51,62). The rows from2N+1on are ignored, as the kernels ignore them today.apply_layer_to_nt_and_return_arraypassessize(x, 1) ÷ 2._vcat_qpgo.ext/GPUArraysCoreExt.jlandpoisson_tensor.jlkeep their calls unchanged. The:assign_q_and_pname ataqua.jl:85stays.One rrule style: eager, no
@thunk. Seven of the nine surviving rrules are already eager; onlytensor_tensor_mulandtensor_transpose_tensor_muluse@thunk, and MHA needs both cotangents.NoTangent()goes to the function and toIntarguments.A seed of the other float type is converted to the primal's element type at entry. A Float64 seed on a Float32 primal gives a Float32 cotangent; without the conversion the kernel rules raise
MethodError, because their signatures are same-T.A zero seed. An
AbstractZeroseed returnsNoTangent()for the function and forIntarguments, andZeroTangent()for every array argument. Nothing is allocated.init_output,cpu_inverse,tensor_inverse2…5,tensor_mat_skew_sym_assign,tensor_tensor_mul,tensor_transpose_tensor_mul. Add any rrule M3 writes for edge 3.assign_output_estimaterrule is M2's and outside the set.Inverse rules and their generator. The four identical pullback bodies (
inverse_2x2.jl:48-59,_3x3:87-98,_4x4:198-209,_5x5:476-487) call one hand-written helper;tensor_transposebecomespermutedims.scripts/utilities/inverse_generator.jl:69-80emits that call, so its claim at:1-3("reproduces the committed files byte for byte") stays true. The builder runs the generator and shows an empty diff against the four files.cpu_inverse.jl:49-53goes.init_output(tensor_exponential.jl:22-28) already answers a zero seed and stays as it is. C18 is M4's.tensor_transposegoes. The function, its kernel and its rrule are deleted. The callers writepermutedims(x, (2, 1, 3)):symplectic_attention.jl:172,volume_preserving_attention.jl:108, the inverse helper, and the teststensor_cayley.jl:18-36,inverses/test_cayley_transforms.jl:23-24andtest_skew_map.jl:31.C15. After the deletions, the zeroed buffers left under
kernel_ad_routines/aretensor_mat_skew_sym_assign.jl:47-48. Both kernels assign each element once overndrange = size(:1-34), so both buffers are allocated instead of zeroed. No accumulating rule remains, and the PR says so.cpu_inverse.jl:37is ininverses/, outside C15's location, and is not changed.KNOWN_ISSUES.md.Docs and text.
docs/src/arrays/tensors.md:7-17: the nine@docslines are replaced byGeometricMachineLearning._mul. Its docstring carries the doctests of the oldmat_tensor_mulandtensor_mat_mul, with a left and a right product.checkdocs = :allthen holds.test/devices/metal.jl:16,48-54: thetensor_mat_mul!testset becomes one for_mul(left with dense and with structured, right with dense) andtensor_tensor_mulonMetalBackend(). Results stay on the device.CHANGELOG.md:70bullet anddocs/src/index.md:55are corrected to say this, in place.tensor_transpose_tensor_mul.jl:1-3,tensor_tensor_transpose_mul.jl:1-3,kernel_ad_routines/tensor_tensor_mul.jl:1-3andkernel_ad_routines/tensor_transpose_tensor_mul.jl:1-3. I found the set by the file's first line, not by the section's list.KNOWN_ISSUES.md:375) stays word for word: its evidence is a record of a measured run.Fixes #145, the B7 row of the issue table at task file line 960.aqua.jl:36runspiracies, and the count stays 0.Ownership boundary with M2. See the Q2 decision below.
Tolerances and precisions. Every numeric test loops over
T in (Float32, Float64)and asserts element typeT.central_fdm(5, 1)in Float64 on the Float64 copy of the data.rtol = √eps(Float32); the measured error is ≤ 2.6e-7.rtol = 1e-8, as check 1 says; the measured error is ≤ 2.2e-12.JLArraysjoinstest/Project.tomlwith its own[compat]bound._muladds no arithmetic of its own), an empty batch, andSubArrayinput (M2's gather copies).Check 6.
Zygote.gradientthroughNetworkParameters, NNP's hook included.SymmetricMatrixfor the right product, both precisions, plustensor_mat_skew_sym_assign's pullback for C15.scripts/, with no new dependency. The CHANGELOG states that the before column comes from the same script run at2a63f26awith the old function names.Grep. This has no hit:
Decided (advisor, 2026-10-15, high): M3 does not edit
src/data_loader/*.jl,test/data_loader/*,docs/src/architectures/transformer.md:61, M2's autoencoder docs and scripts,test/quality/jet.jl:277-288, ortest/kernels/kernel_pullbacks.jl:30with the nameassign_output_estimatein its import at:5; all of these are M2's. Check 4, thefinite-difference tests and the one-style rule exclude the
assign_output_estimaterrule(
tensor_assign.jl:72), which M2 deletes.ClassificationLayer{…, false}keeps its callassign_output_estimate(…, 1)(classification.jl:94), with_mulinside it. M2 and M3 do notrun in parallel (§File collisions: "run them in either order, not together"); whichever merges
second rebases. Recommended order: M3 first (M3 → M4 → … → M10 is the long chain; M2 is needed
only before M6).
Tests catch (M3; advisor, 2026-10-15, medium; each mutant fails the named test in Float32 and Float64 unless a row says otherwise):
Check 1 (structured gradients). The test sits under
test/layers/ortest/integration/, by the layout rule.LinearLayerQ(4)on 4×7 against 4×1×7.Sequal, and equal to the FD storage gradient2a63f26amat_tensor_mul(::SymmetricMatrix)rrule kept_mul(W, x)for each of the four structured types, 3-tensor x, throughNetworkParameters.S≈ FDLinearSymplecticAttentionQ(4, 5)andSymplecticAttentionQ(4; symmetric = true)on tensor inputtensor_mat_mul(::SymmetricMatrix)rrule keptThe triangular kernel rrules give correct values (relative error 3e-13, p3), so keeping them is an equivalent mutant. The edge 15 grep catches them.
Check 2 (
Adjointcotangent).Zygote.gradient(W -> sum(abs2, _mul(W', x)), W)[1] isa Matrix{T}and ≈ the dense reference at√eps(T), for x of 2 and 3 dimensions.StiefelLayer(6, 3)leaf on matrix and on tensor inputisa Matrix{T}.Adjoint{T, Matrix{T}}(p2); the fix only for 3-D x fails the matrix row (p7).Check 3 (
assign_q_and_p).Zygote.gradient(z -> sum(assign_q_and_p(z, N).p), x)[1] == [zeros; ones]for 1, 2 and 3 dimensions. Mutant: the2a63f26arrule givesDimensionMismatchwith N+1 rows (p2).Int32(N)row. Mutant:N::IntraisesMethodError.2N+1-row x withp == x[N+1:2N, …]. Mutant:(N+1):end.Check 4 (seeds). One row per surviving rule.
rrule(f, args...)[2](ZeroTangent())is a tuple of length1 + length(args)and every elementisa AbstractZero. Mutant: the zero branch removed from any one pullback raisesMethodError, as 24 of 26 do today (p2).MethodError(kernel rules), or gives a Float32dAfor a Float64 primal (cpu_inverse). Caught in both precisions.Finite differences per remaining rule.
test_rrule(Float64) and the Float64 FD reference (Float32) for each rule of edge 7.tensor_mat_skew_sym_assignuses ≥ 2 slices. Mutant: anndrangethat skips the last slice leaves undefined values in the allocated buffer (C15).Check 5 (JLArray). Under
allowscalar(false), each result ≈ host at√eps(T)and stays onJLBackend.tensor_tensor_mulforward and pullback. Mutant: body replaced byNNlib.batched_mulraisesMethodError: _batched_gemm!(p4)._mulwith each structured weight, forward and gradient. Mutant:Matrix(W)(a host array) in place of densifying on W's backend fails the backend assertion.K7. The reproducer at
KNOWN_ISSUES.md:250, in both directions, trains, and every leaf keeps its element type. Mutant: an element-type@assertin_mulraisesAssertionError.Check 6 and C15. These are measurements, not test assertions. Going back from allocated to zeroed buffers changes no value; check 6 measures it.
Docs build. Mutant: an
@docsline format_tensor_mulleft intensors.mdgives a missing-docstring failure.Edge 15 grep. It reports any deleted name left in place.
Not checkable: the one style of edge 6. A reviewer reads it.
Decided (advisor, 2026-10-15, medium; M3 round 1, edge 8): Edge 8's byte-identity requirement
is narrowed to the wrapper half. For n = 2:5, the text of
src/kernels/inverses/inverse_NxN.jlfrom
function tensor_inverseN(to the end must equal the generator'swrapper_source(n)bytefor byte. The PR quotes the comparison command and its four IDENTICAL lines. Tests catch:
keeping the
2a63f26apullback body inwrapper_source(inlinetensor_transpose_tensor_mulinstead of
_tensor_inverse_pullback) makes all four comparisons differ. The kernel half is notM3's. The generator's output there depends on the Julia version: with Symbolics 7.39.2 and
SymbolicUtils 4.46.6 fixed, Julia 1.11.9, 1.12.6, 1.12.7 and 1.13.1 each give a different factor
order, and none reproduces all four committed kernels. SymbolicUtils 4.46.6 and 4.51.0 give
identical output on 1.13.1. The Julia build that made the committed kernels on 2026-09-14 is no
longer installed. The header at
scripts/utilities/inverse_generator.jl:1-3claims byte identityfor the wrapper half only and points at K16. K16's title and evidence say the same, and its last
sentence, which blames the missing SymbolicUtils version, is corrected. M3 does not regenerate or
commit the kernels. If M4 adopts its benchmarked kernel, it deletes these files (§M4). [The advisor's report and probes are in the plan's local evidence folder.]
The decisions refer to the plan's sections and to the base
2a63f26aby line. Edges 8 and 15 as amended: edge 8's byte identity covers only the wrapper half (the last decision above). The edge 15 grep has no hit on this branch, as the verifier confirmed.