Repository navigation
Use the row contiguous flag to pick the contiguous sort kernel - #4137
kapellirohith wants to merge 1 commit into
Conversation
The contiguous block sort kernel enumerates the rows with a single segment stride, taken as the smallest stride of the axes that are not sorted. That only walks the rows when those axes are laid out row major. The contiguous flag only means the data is dense, so a transposed view took the fast path and read its rows in a different order than it wrote them. Sorting a permuted view is wrong whenever the axes that are not sorted are no longer in row major order, for example sort(swapaxes(x, -1, -2)) on a three dimensional array. The multi block path already passes the full strides and is unaffected.
zcbenz
left a comment
There was a problem hiding this comment.
Thanks for finding the bug but this is losing a lot valid cases for the contiguous kernel, and we should certainly fix the condition not enforcing row contiguous.
|
Thanks, that makes sense. Rather than clamping to row_contiguous, I reworked it to state the I implemented it with the existing collapse_contiguous_dims rather than a hand-rolled check, Measured over 9395 (array, axis) cases with the GPU result checked against the CPU backend: One wrinkle: I filter size-1 axes before collapsing. collapse_contiguous_dims tests Does that sound like the right direction before I open a PR? |
|
It sounds plausible but I'm not sure. |
Problem
Sorting a transposed view returns wrong results on the GPU. The values inside
each row are sorted correctly, only the rows land in the wrong places, so
nothing raises and the output looks plausible.
Minimal reproducer (M3 Pro, macOS 26.x, main @ e78d894):
stream=mx.cpu: 0 / 96. A 2-D transpose: 0 / 96.mx.transpose(x, (1, 2, 0)):0 / 96. Only the GPU, only 3-D and higher, and only when the axes that are not
sorted end up out of row major order relative to each other.
Mechanism
single_block_sortselects the contiguous kernel onin.flags().contiguous,which only means the data is dense, not that it is row major. A transpose keeps
that flag. The contiguous kernel enumerates rows with a single segment stride,
the smallest stride among the axes that are not sorted, and
Sort::eval_gpuallocates the output with a plainallocator::malloc(out.nbytes()), so the output is always row major while theinput is permuted. The kernel then reads the rows in one order and writes them
in another.
Positive identification: with
aof shape(3, 4, 8)andy = swapaxes(a, 0, 1),yhas strides(8, 32, 1). Removing the sorted axis leaves strides(8, 32)over shape
(4, 3), whose minimum is 8, so rowris read at offset8r. Thecorrect offset for logical row
(i, j)is8i + 32j. Every output row of theobserved run is a correctly sorted row of the input, just the wrong one:
np.sort(got, axis=None)equalsnp.sort(ref, axis=None)exactly, and eachoutput row matches the input row at
8rrather than at8i + 32j.check_stridesalready restricts the fast path to a sorted axis holding thesmallest or largest stride. For a row major input those are the only two cases,
and both enumerate correctly, which is why this needs a permuted view to fire.
multi_block_sortpasses the full strides and is unaffected, so the sorted axisalso has to be short enough for the single block path.
Introduced by 2615660 "Fix strided sort bug (#1236)" (2024-06-26), which added
output strides to the kernel. This does not revert that, it narrows when the
fast path is selected.
The CUDA backend has the identical line, the identical
check_strides, theidentical
out.set_data(cu::malloc_async(out.nbytes(), encoder)), and the sameinp += blockIdx.y * in_stride_segment_axisrow enumeration, so it is changedtoo. I have no NVIDIA GPU. That half is reasoned from source and validated by
CI, not run locally.
Why existing tests missed it
test_sortcovers strided inputs viaa_mx[::2, :, ::2], which keeps the axesin row major order, and every other sort test uses freshly created arrays. No
sort test transposed its input.
Perf
row_contiguousis sufficient but not necessary. A permutation such as(1, 2, 0)puts the sorted axis on the largest stride and leaves the other axesin row major order relative to each other, so it was correct before and now
takes the general kernel:
Medians of 9 repeats of 100 iterations, population stdev 0.001 to 0.013 ms. A
row major sort of the same shape, measured in the same runs as a control, moves
by at most 5 percent between the two builds, which bounds the drift between
them. Row major inputs take the same path as before.
I posted "the measured cost is zero" in an earlier revision of this description.
That was wrong. Those numbers were taken on
perm(1, 0, 2), which was alreadyreturning wrong results before this change and so was never a deoptimisation.
The
perm(1, 2, 0)family above is the set that was correct before and isslower now, and the cost there is 5 to 11 percent.
A tighter predicate that keeps the fast path whenever the axes that are not
sorted are packed among themselves recovers all of it (0.863, 0.522, 1.031,
0.154 ms) at about 18 lines against one word. I have it written and tested and
will send it if you prefer, but I would rather land the small correct version
first.
Validation
M3 Pro, macOS 26.x, against main @ e78d894.
mx.set_default_device(mx.cpu)test_fft_too_largeon cpu onlytest_opsmodule OKThe 510 differing arrays were each classified against a NumPy reference: all
510 were wrong on main and correct here, none went the other way, and none
changed while both were correct, so no tie-breaking behavior moved.
Not verified locally: CUDA, Linux, Windows. The
cuda/sort.cuchange issource-level reasoning validated by CI.
Checklist
Put an
xin the boxes that apply.pre-commit run --all-filesto format my code / installed pre-commit prior to committing changes