Fix hadamard_transform on the GPU for n = m with no power-of-2 factor - #4054
Open
axiom-of-choice wants to merge 2 commits into
Open
Fix hadamard_transform on the GPU for n = m with no power-of-2 factor#4054axiom-of-choice wants to merge 2 commits into
axiom-of-choice wants to merge 2 commits into
Conversation
Fixes ml-explore#4049. `hadamard_transform` is documented for n = m*2^k with m in (1, 12, 20, 28), but on Metal the three sizes where the power-of-2 factor is exactly 1 -- n = 12, 20, 28 -- failed to build the Metal library: hadamard.h:44:19: error: constexpr variable 'num_steps' must be initialized by a constant expression note: division by zero constexpr short num_steps = logN / logR; `decompose_hadamard` returns (n=1, m) for those sizes, so the n2 stage is instantiated with N=1, giving logR = ctz(max_radix_2) = ctz(1) = 0 and a 0/0 in the kernel. The n1 and m stages are already guarded by `> 1`; n2 was not. Two changes: - Emit and launch the n2 stage only when n2 > 1. When n2 == 1 it is the identity and carries no scale: `scale_n2` is `scale` only if m == 1, and n == 1 && m == 1 returns before the primitive (ops.cpp:530), so n2 == 1 here always implies m > 1 and `scale_n2 == 1`. Since the skipped stage was also what copied x into y, the m stage now reads x directly when n == 1, which matters on the non-donatable path where y is freshly allocated. - Cap `read_width_m` at n. The m kernel strides by `n / read_width`, so with n = 1 and read_width 4 the group width was `1/4 == 0`: zero threads dispatched, leaving the output untouched. This is why a batched input returned all zeros rather than crashing once the build was fixed. Verified on an M2 Pro against the CPU backend, bit-exact (max |gpu-cpu| = 0) for m in (12, 20, 28) over shapes (m,), (4, m) and (3, 5, m), with and without an explicit scale, and on both the donatable and freshly-allocated output paths. The existing test_hadamard sweeps k from 1, so n = m*2^0 was never covered; the new test pins all three sizes, and a batch dimension, because the 1-D case alone passes even with the zero-thread dispatch when the input is donated.
JasonHonKL
reviewed
Aug 7, 2026
| auto kernel = d.get_kernel("m" + kname, lib); | ||
| compute_encoder.set_compute_pipeline_state(kernel); | ||
| compute_encoder.set_input_array(y, 0); | ||
| // n = 1 means neither power-of-2 stage ran, so x has not been copied into y |
Contributor
There was a problem hiding this comment.
Just personally thinks these comment are a bit nasty haha. (Maybe the author likes it idk)
Author
There was a problem hiding this comment.
🤣
Can short them for sure
| int read_width_n1 = n1 == 2 ? 2 : 4; | ||
| int read_width_n2 = n2 == 2 ? 2 : 4; | ||
| int read_width_m = (n == 2 || m == 28) ? 2 : 4; | ||
| // n == 1 (m in (12, 20, 28) with no power-of-2 factor) leaves the m stage as |
Contributor
There was a problem hiding this comment.
yea just a bit too much comment to me hahaha.
Review feedback: the rationale lives in the commit message, the inline comments only need to say what the code does.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4049.
Summary
hadamard_transformis documented forn = m*2^kwithm in (1, 12, 20, 28), but on Metal the three sizes where the power-of-2 factor is exactly 1 (n = 12, 20, 28) failed to build the Metal library:decompose_hadamardreturns(n=1, m)for those sizes, so the n2 stage is instantiated withN=1, givinglogR = ctz(max_radix_2) = ctz(1) = 0and a0/0. The n1 and m stages are already guarded by> 1; n2 was not.Changes
Emit and launch the n2 stage only when
n2 > 1. Whenn2 == 1the stage is the identity and carries no scale:scale_n2isscaleonly whenm == 1, andn == 1 && m == 1returns before the primitive is created (ops.cpp:530), son2 == 1inside the backend always impliesm > 1andscale_n2 == 1.0. The skipped stage was also what copiedxintoy, so the m stage now readsxdirectly whenn == 1; that matters on the non-donatable path, whereyis freshly allocated and otherwise never written.Cap
read_width_matn. The m kernel strides byn / read_width, so withn = 1andread_width = 4the group width was1/4 == 0and zero threads were dispatched, leaving the output untouched. Fixing only the build error surfaces this as a silent all-zero result for any batched input, so both changes are needed.Verification
Built with Metal on an M2 Pro (Mac mini, macOS 26.5.2) and compared against the CPU backend. Bit-exact,
max |gpu - cpu| = 0, form in (12, 20, 28)across shapes(m,),(4, m),(3, 5, m), with and without an explicitscale, on both the donatable and freshly-allocated output paths. Sizes withk >= 1(24, 40, 48, 56, 80, 112, ...) are unchanged and still bit-exact.pytest python/tests/test_ops.py python/tests/test_autograd.py python/tests/test_vmap.py: 227 passed. The one failure,test_unstack, is unrelated and pre-existing here (np.unstackneeds numpy >= 2.1, this env has 1.26.4); it fails identically without this patch.clang-format21.1.8 andblackclean.Why it was not caught
test_hadamardbuilds its cases fromrange(1, 14), sokstarts at 1 andn = m*2^0was never tested;m = 12andm = 20do not appear in its[1, 28]list at all. The newtest_hadamard_m_onlypins all three sizes, and includes a batch dimension deliberately: the 1-D case alone still passes with the zero-thread dispatch when the input happens to be donated, so a 1-D-only test would not have caught the second half of this.Risks
Low, and confined to
n == 1. For every other size the emitted kernels, launch order, grid dimensions andread_width_mare byte-identical to before, sok >= 1paths cannot change behavior. The one judgement call is reading fromxin the m stage whenn == 1; it is guarded onn > 1 ? y : xrather than changing the general path, so the multi-stage cases keep chaining throughyexactly as they did.Pre-merge / post-merge
None. No API or build changes.