Bound the strided scan to the size of its output - #4254
Open
kapellirohith wants to merge 1 commit into
Open
Conversation
The strided scan kernel writes out[i * stride + j] for i < shape[axis] and j < stride, so it needs shape[axis] * stride elements. Scan::eval_gpu sized the output with in.data_size() while handing the kernel in.strides(), so a size one axis carrying a padded stride, as a sliced view has, made the kernel write past its allocation. Take the no copy path only when the scanned axis fits and let the rest fall to the existing contiguous copy. The CUDA scan has the same dispatch and the same kernel bound, so it changes too.
kapellirohith
marked this pull request as ready for review
August 14, 2026 14:54
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.
Problem
This is not #206 and it is not bounds checking. Nothing here validates a caller
supplied index.
Scan::eval_gpusizes the output fromin.data_size()(
metal/scan.cpp:129,cuda/scan.cu:469) and then hands the kernelin.strides(), while the kernel bounds its writes by the stride(
kernels/scan.h:445,cuda/scan.cu:236) and so addressesshape[axis] * strideelements. Those agree for a densely packed array. Theystop agreeing when the scanned axis has size one:
check_contiguityrequiresstrides[i] == prod(shape[i+1:])only whenshape[i] != 1, so a size one axismay carry any stride and the array is still flagged contiguous and is not
copied. A sliced view gives it exactly that, and MLX's own kernel then writes
past MLX's own allocation.
Minimal reproducer (M3 Pro, macOS 26.x, main @ 3d23f7d):
The scan is asked for one element and writes 64. Nothing raises, and the
tensors it lands on are unrelated to the operation.
Instrumenting the dispatch to print the allocation against the kernel's write
extent, over 120 consecutive dispatches of
cumsum(base[:, 200:], axis=0)on a(1, 256)array:Those element counts are identical for float16, float32, int32, int64 and
complex64. Only the byte overrun scales with itemsize, 224 B allocated against
1024 B written at 4 bytes and 448 B against 2048 B at 8. Whether a given
overrun lands on a tensor you can observe depends on allocator placement and
moves between releases, so the invariant above, not any corruption rate, is the
load bearing evidence here.
What makes it fire and what makes it stop, read off the arrays themselves:
(1,256)unsliced(1,256)[:, 0:]zero offset(1,256)[:, 200:](1,256)[:, 200:]on the size 56 axis(1,64)[:, 63:](2,256)[:, 200:]scanned axis size 2It needs a scanned axis of size one carrying a stride larger than the packed
extent. The last row isolates that: with a size two axis the same slice is no
longer contiguous, so the fast path is not taken at all.
Fix
Take the no copy path only when the scanned axis fits inside the allocation,
which is exactly the kernel's maximum write index:
Inputs that fail it fall to
contiguous_copy_gpu, already the else branchdirectly below. For a row major array the condition holds by construction,
since scanning axis
iof shape(d0 ... dn)givesdi * prod(d[i+1:]) <= prod(d), so nothing that works today changes path. Thesame dispatch, allocation and kernel bound are present in
cuda/scan.cu, so itchanges there too.
Introduced by c423074 "redesign for faster cpu/gpu synch (#1869)"
(2025-03-06), which is where the dispatch began sizing the output from
data_size()while passingstrides(). No existing scan test scans a size oneaxis, and none asserts that an operation left memory it does not own alone.
Testing
Two tests. The Python one allocates canaries and asserts none of them, nor the
parent, moved. The C++ one asserts
out.shape(axis) * out.strides()[axis] <= out.data_size()on a slice thatoverruns by exactly one element; it is there because the invariant is not
observable from Python, where strides are not exposed, and because it is the
only deterministic detector of a one element overrun.
All figures on main @ 3d23f7d, M3 Pro, macOS 26.x.
DEVICE=gputest_ops,DEVICE=gpuDEVICE=cputest_ops,DEVICE=cpuDEVICE=gpuDEVICE=cpu(1,10)[:, 3:], 120 dispatchesDEVICE=gpuMLX_METAL_JIT=ON, new testpre-commit run --all-filesMutation testing of the new condition, each mutant built and run against both
tests:
nbytes()<instead of<=size()instead ofdata_size()shape[axis]factorBoth mutants that still permit an overrun are caught only by the C++ test. The
survivors were classified by enumerating the inputs reachable through reshape,
slice, transpose,
broadcast_toandas_strided:<never permits an overrunbut forces needless copies, and the other two never diverge from the shipped
condition on any reachable input.
tests/CMakeLists.txtgatesgpu_tests.cpponif(MLX_BUILD_METAL OR MLX_BUILD_CUDA), so the invariant test compiles and runson the CUDA CI legs as well as the Metal ones. Metal is covered by both tests,
CUDA by the invariant test.
Perf is unchanged for inputs that already worked: they satisfy the new
condition, so they take the same path and the same kernel. Toggling the new term
at runtime, so that both configurations run alternately in one process, gives
paired median differences of +2.8, +0.9 and -0.6 percent over 12 pairs on three
contiguous scan shapes, mixed in sign and inside this machine's run to run
spread.
(1, 4096)[:, 1:]goes from 53.2 ms to 0.14 ms, since on main thestrided kernel is dispatched with
stride = 4096for a scan of length one.Checklist
Put an
xin the boxes that apply.pre-commit run --all-filesto format my code / installed pre-commit prior to committing changes