Skip to content

[CUDA] Parallelize ArgMax/ArgMin over wide last axes - #32092

Merged
Justin Chu (justinchuby) merged 2 commits into
microsoft:mainfrom
justinchuby:justinchuby/optimize-wide-argmax
Aug 15, 2026
Merged

[CUDA] Parallelize ArgMax/ArgMin over wide last axes#32092
Justin Chu (justinchuby) merged 2 commits into
microsoft:mainfrom
justinchuby:justinchuby/optimize-wide-argmax

Conversation

@justinchuby

@justinchuby Justin Chu (justinchuby) commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Description

arg_min_max_last_axis() in reduction_functions.cu assigns one thread per row and scans the row serially. That is a reasonable design for many narrow rows, but it degenerates badly when a graph reduces a small number of very long rows: the reduction length becomes a serial dependency chain in a single thread, so the cost grows linearly with the reduced axis and the rest of the GPU stays idle.

A [1, 202048] ArgMax (last-axis classification / sampling over a large vocabulary) is therefore computed by exactly one CUDA thread and takes ~5 ms on an H200. The same shape costs microseconds once it is parallelized. This affects any model that reduces a wide last axis with few rows, and it also leaves a lot on the table for the medium cases (thousands of rows of a few hundred to a few thousand columns).

This PR adds a cooperative reduction path and keeps the existing serial kernel for the shapes where it is genuinely better.

Approach

  • A new kernel spreads one row over many warps and, when the row is long enough, over many blocks:
    • thread level scan (grid-strided, 4 elements per thread, coalesced),
    • warp level merge with shuffles,
    • block level merge through shared memory,
    • grid level merge for multi-block rows through a small global buffer, finalized by the last block arriving for that row.
  • The multi-block step reuses the __threadfence() + per-row done-counter structure that reduce_matrix_columns() / reduce_all() already use in the same file, so the intermediate buffer follows the existing sizing and alignment conventions and is allocated by the caller with AllocateScratchBuffer (same as the ApplicableMatrixReduction::Columns path).
  • Launch geometry aims for a single wave of threads over the device while keeping at least four elements per thread. This avoids both failure modes: an idle device for few wide rows, and oversubscription (which measures 2-3x slower) for many rows.
  • Rows narrower than 128 columns keep the existing one-thread-per-row kernel; below that width a row cannot fill a warp and the serial kernel wins. The threshold was picked from a measured crossover sweep.
  • No model-specific constants: the dispatch depends only on the matrix shape and the device's thread capacity.

Semantics

The new kernel is bit-identical to the previous kernel for every input, including:

  • Ties: the lowest index wins, matching select_last_index == 0. The CUDA EP already falls back to CPU for select_last_index == 1 (ArgMaxOrArgMinNeedFallbackToCPU), so this is the only case to support.
  • NaN: NaN never wins a comparison, and the previous kernel seeded its accumulator with element 0, so a leading NaN yields index 0 for the whole row. That behavior is preserved explicitly.
  • Infinities: the reduction identity is -inf / +inf, not the lowest/highest finite value. This matters: with a finite identity, a row of [-inf, ..., lowest_finite, ...] would drop the real maximum. Rows that are entirely -inf, +inf or NaN return index 0, as before.
  • axis, keepdims, negative axes, opset variants and the int64 output are untouched, they are handled above this function.
  • All registered input types are covered (MLFloat16, float, double for ArgMax/ArgMin) and the fallbacks for non-last-axis reductions, n/m beyond int range and empty reductions are unchanged.

Results

H200 (sm90), CUDA 13.3, fp32, per call including the launch, measured with the benchmark added in this PR (ReductionFunctionsTest.DISABLED_ArgMinMaxLastAxisPerf), "before" measured with the same harness on the serial kernel:

rows cols before after speedup scratch
1 32000 695.6 us 5.6 us 125x 267 B
1 50257 1201.1 us 5.7 us 210x 411 B
1 128256 3009.7 us 5.9 us 511x 1019 B
1 151936 3833.8 us 6.2 us 620x 1203 B
1 200000 5037.1 us 6.1 us 821x 1579 B
1 202048 5056.5 us 6.2 us 818x 1595 B
1 262144 6335.5 us 6.2 us 1018x 2067 B
2 202048 4853.4 us 6.5 us 746x 3175 B
4 202048 4759.6 us 7.5 us 635x 6335 B
8 202048 5118.4 us 8.5 us 603x 8495 B
16 202048 5805.6 us 9.1 us 640x 8527 B
32 202048 7357.7 us 10.8 us 681x 8591 B
4096 128 18.8 us 5.0 us 3.75x 0
4096 1024 135.6 us 6.5 us 21x 0
4096 4096 552.2 us 20.8 us 27x 0
65536 256 70.7 us 28.3 us 2.50x 0
65536 1024 270.1 us 67.7 us 3.99x 0
4096 8 2.3 us 2.3 us 1.01x 0
4096 32 6.2 us 6.2 us 0.99x 0
4096 64 10.5 us 10.6 us 0.99x 0
65536 64 18.9 us 19.0 us 1.00x 0

Narrow rows are unchanged because they keep the existing kernel. half and double behave the same way ([1, 202048]: 2432 us -> 6.1 us for half, 6405 us -> 6.5 us for double).

Profile of [1, 202048] fp32 (Nsight Systems, occupancy from cudaOccupancyMaxActiveBlocksPerMultiprocessor):

before after
kernel launches 1 1 (+ one 4 byte memset)
GPU time ~5056 us 3.8 us kernel + 0.8 us memset
grid / block 1 x 256 (1 active thread) 197 x (32, 8)
registers / thread - 32
theoretical occupancy - 100%
scratch 0 1595 B

For reference on the same GPU and shape, cub::DeviceReduce::ArgMax needs 2 kernels, 2.74 us of GPU time and 42495 bytes of temporary storage. The kernel here is a segmented (per row) reduction that also has to serve the many-rows shapes, uses ~26x less scratch and one launch instead of two. Scratch is bounded by the device thread capacity (a multi-block launch only happens when rows < capacity / 256), so it stays in the low kilobytes for any shape.

Testing

  • onnxruntime/test/providers/cuda/test_cases/reduction_functions_test.cc: new ArgMinMaxLastAxis* cases covering every dispatch path (narrow / single block / multi block / more rows than grid rows), half/float/double, ArgMax and ArgMin, ties, NaN (leading, middle, all), +/-inf, all-equal rows, finite extremes, unaligned and undersized intermediate buffers, a dirtied intermediate buffer, and CUDA graph capture + repeated replay.
  • onnxruntime/test/providers/cpu/reduction/reduction_ops_test.cc: operator level ArgMax/ArgMin tests with a wide last axis ({3, 40000} and {2, 202048}, with keepdims on and off and a negative axis) so the CUDA EP scratch allocation path is covered end to end.
  • onnxruntime_provider_test --gtest_filter='*ArgMax*:*ArgMin*': 32/32 pass.
  • onnxruntime_provider_test --gtest_filter='*Reduce*': 376/376 pass.
  • CUDA_EP_Unittest.All (CUDA EP internal tests): 76/76 pass.
  • compute-sanitizer (memcheck, racecheck, synccheck) clean on the multi-block and row-loop paths.
  • Differential test against the previous kernel's exact sequential semantics over a dense width sweep around the dispatch threshold and around power-of-two boundaries, several row counts, all three dtypes, random data and a {+/-inf, NaN, +/-0.0, ...} alphabet, repeated with several simulated device capacities: no mismatches.

Review follow-up

  • The thread level scan originally advanced id in int. Since a row is admitted up to INT_MAX columns and the step reaches 4 * 65536 = 262144, that overflowed to negative positions for the widest rows, reading out of bounds and never terminating. Fixed by expressing the scan with differences (remaining = num_cols - id, delta = i * threads, positions only formed after delta < remaining), which keeps every value inside [0, num_cols). int64 positions were measured as the alternative and were 4-38% slower on the targeted shapes, so the difference based form was kept; no shape is dropped either way. New tests: [1, INT_MAX] fp16 (memory gated), widths straddling the widest scan step, and buffer sizing at INT_MAX.

Possible follow-up

The thread level scan uses scalar loads. Vectorized loads would shave roughly another microsecond off the very wide single row case; it was left out here to keep the alignment handling out of a generic kernel that has to serve half, float and double.

`arg_min_max_last_axis()` assigns one thread per row and scans the row
serially. That is a good fit for many narrow rows, but it collapses when
a model reduces a few very long rows: a `[1, 202048]` ArgMax over
sampler logits is computed by a single CUDA thread and takes ~5 ms on an
H200, leaving 99.9% of the device idle. The cost grows linearly with the
reduction length, so it hurts every large-vocabulary classification and
sampling graph, not one particular model.

Add a cooperative kernel that spreads a row over many warps and, when the
row is long enough, over many blocks. Partial (value, index) pairs are
merged with warp shuffles, then through shared memory, and finally - for
multi-block rows - through a small global buffer that the last block
arriving for the row reduces. This is the same `__threadfence()` +
per-row done counter structure that `reduce_matrix_columns()` already
uses in this file, so the intermediate buffer follows the existing
sizing/alignment conventions and is allocated by the caller through
`AllocateScratchBuffer`.

The launch geometry targets a single wave of threads over the device
while keeping at least four elements per thread, which avoids both the
idle-device case (few wide rows) and the oversubscription case (many
rows). Rows narrower than 128 columns keep the existing one-thread-per-row
kernel, which is still the better choice there, so no shape regresses.

Semantics are unchanged. The reduction identity is -/+infinity (not the
lowest/highest finite value) so a row containing -inf cannot lose against
an empty partial result, ties resolve to the lowest index
(`select_last_index == 0`; the CUDA EP already falls back to CPU when it
is 1), and a leading NaN still yields index 0, matching the sequential
kernel bit for bit for every input.

Measured on H200 (sm90, CUDA 13.3), fp32, per call including the launch:

| rows | cols   | before    | after    | speedup |
|------|--------|-----------|----------|---------|
| 1    | 32000  | 695.6 us  | 5.9 us   | 118x    |
| 1    | 128256 | 3009.7 us | 6.1 us   | 496x    |
| 1    | 202048 | 5056.5 us | 6.4 us   | 793x    |
| 1    | 262144 | 6335.5 us | 6.3 us   | 1005x   |
| 8    | 202048 | 5118.4 us | 8.7 us   | 591x    |
| 32   | 202048 | 7357.7 us | 10.7 us  | 688x    |
| 4096 | 128    | 18.8 us   | 5.0 us   | 3.7x    |
| 4096 | 4096   | 552.2 us  | 20.7 us  | 26.7x   |
| 65536| 1024   | 270.1 us  | 67.7 us  | 4.0x    |
| 4096 | 8      | 2.3 us    | 2.2 us   | 1.0x    |
| 65536| 64     | 18.9 us   | 18.9 us  | 1.0x    |

For `[1, 202048]` the launch is 197 blocks of 256 threads, 32 registers
per thread, 100% theoretical occupancy, 3.8 us of kernel time plus a
4 byte memset, and 1595 bytes of scratch. The scratch is bounded by the
device thread capacity, so it stays in the low kilobytes for any shape.

Tests: adds arg reduction coverage to the CUDA reduction function tests
(dispatch paths, dtypes, ties, NaN, infinities, buffer offsets, undersized
buffer, CUDA graph capture/replay) and wide last-axis ArgMax/ArgMin
operator tests, plus a disabled benchmark that prints the timing table
above.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
@justinchuby

Copy link
Copy Markdown
Contributor Author

Verification log for the numbers and claims in the description. H200 (sm90), CUDA 13.3, cuDNN 9.24, CMAKE_CUDA_ARCHITECTURES=90, Release.

CUDA reduction unit tests, including CUDA graph capture and replay
$ GTEST_FILTER='ReductionFunctionsTest.*' onnxruntime_provider_test --gtest_filter=CUDA_EP_Unittest.All
[       OK ] ReductionFunctionsTest.ReduceRowToScalar (23200 ms)
[       OK ] ReductionFunctionsTest.ReduceRowsToRow (130 ms)
[       OK ] ReductionFunctionsTest.ReduceColumnsToColumn (65 ms)
[       OK ] ReductionFunctionsTest.ReduceColumnsToColumnRepeated (3 ms)
[       OK ] ReductionFunctionsTest.ReduceSumNdMiddleAndMultipleAxes (0 ms)
[       OK ] ReductionFunctionsTest.ReduceSumNdIntegerSaturation (0 ms)
[       OK ] ReductionFunctionsTest.ReduceSumNdLargeReductionSmallOutput (1 ms)
[       OK ] ReductionFunctionsTest.ReduceSumNdInt64Cancellation (0 ms)
[       OK ] ReductionFunctionsTest.ReduceSumNdRank9 (0 ms)
[       OK ] ReductionFunctionsTest.BufferOffsets (295 ms)
[       OK ] ReductionFunctionsTest.InvalidBufferSize (9 ms)
[       OK ] ReductionFunctionsTest.ArgMinMaxLastAxisNarrowRows (9 ms)
[       OK ] ReductionFunctionsTest.ArgMinMaxLastAxisSingleBlockRows (62 ms)
[       OK ] ReductionFunctionsTest.ArgMinMaxLastAxisMultiBlockRows (82 ms)
[       OK ] ReductionFunctionsTest.ArgMinMaxLastAxisManyRows (511 ms)
[       OK ] ReductionFunctionsTest.ArgMinMaxLastAxisTypes (9 ms)
[       OK ] ReductionFunctionsTest.ArgMinMaxLastAxisSpecialValues (26 ms)
[       OK ] ReductionFunctionsTest.ArgMinMaxLastAxisEmptyRows (0 ms)
[       OK ] ReductionFunctionsTest.ArgMinMaxLastAxisInvalidBufferSize (0 ms)
[       OK ] ReductionFunctionsTest.ArgMinMaxLastAxisBufferOffsets (5 ms)
[       OK ] ReductionFunctionsTest.ArgMinMaxLastAxisCudaGraphCaptureAndReplay (2 ms)
[       OK ] ReductionFunctionsTest.GetApplicableMatrixReduction (0 ms)
[==========] 22 tests from 1 test suite ran. (24423 ms total)
[  PASSED  ] 22 tests.

ArgMinMaxLastAxisCudaGraphCaptureAndReplay captures the call on a non default stream (cudaStreamCaptureModeThreadLocal), instantiates the graph and replays it 3 times with the output poisoned before every replay. It passes because the launch is capture safe: no host synchronization, no allocation inside the call, stable buffers, and the block done counters are cleared by the captured cudaMemsetAsync node rather than by the host, so every replay starts from a clean state.

The first test to run in the module absorbs a one time ~23 s CUDA module load; the arg reduction tests themselves are 0-511 ms.

Operator level tests
$ onnxruntime_provider_test --gtest_filter='*ArgMax*:*ArgMin*'
[       OK ] ReductionOpTest.ArgMax_float_wide_last_axis (9 ms)
[       OK ] ReductionOpTest.ArgMin_float_wide_last_axis (10 ms)
[       OK ] ReductionOpTest.ArgMax_float_wide_last_axis_keepdims (12 ms)
...
[==========] 32 tests from 3 test suites ran.
[  PASSED  ] 32 tests.

$ onnxruntime_provider_test --gtest_filter='*Reduce*'
[==========] 376 tests from 4 test suites ran. (15257 ms total)
[  PASSED  ] 376 tests.

$ onnxruntime_provider_test --gtest_filter='CUDA_EP_Unittest.All'   # all CUDA EP internal tests
[  PASSED  ] 76 tests.
Benchmark, raw output of the added benchmark on both kernels

GTEST_ALSO_RUN_DISABLED_TESTS=1 GTEST_FILTER=ReductionFunctionsTest.DISABLED_ArgMinMaxLastAxisPerf onnxruntime_provider_test --gtest_filter=CUDA_EP_Unittest.All

The "before" column is the same benchmark with the cooperative path disabled, so both columns come from the same binary, the same harness and the same data.

rows,cols,scratch_bytes,us_before,us_after
1,32000,267,695.581,5.91488
1,50257,411,1201.12,5.82976
1,128256,1019,3009.67,6.0704
1,151936,1203,3833.83,6.328
1,200000,1579,5037.08,6.3424
1,202048,1595,5056.48,6.37408
1,262144,2067,6335.51,6.3024
2,202048,3175,4853.39,6.6048
4,202048,6335,4759.62,7.71392
8,202048,8495,5118.38,8.65728
16,202048,8527,5805.62,9.20768
32,202048,8591,7357.7,10.6861
4096,128,0,18.8093,5.03584
4096,1024,0,135.644,6.28224
4096,4096,0,552.168,20.7155
65536,256,0,70.689,28.0429
65536,1024,0,270.123,67.7437
4096,8,0,2.29088,2.23328
4096,32,0,6.18208,6.1936
4096,64,0,10.5034,10.5101
65536,64,0,18.9498,18.92
Kernel profile and launch geometry

Nsight Systems, [1, 202048] fp32, 100 iterations:

 Time (%)  Total Time (ns)  Instances  Avg (ns)  Min (ns)  Max (ns)   Name
    100.0           384894        100    3848.9      3744      4480   arg_min_max_last_axis_cooperative_kernel<float, true>

 [CUDA memset]  100 instances, avg 841 ns, 4 bytes each

So 1 kernel launch plus one 4 byte memset per call, 4.69 us of GPU time in total against ~5056 us before.

Launch geometry and occupancy (cudaFuncGetAttributes + cudaOccupancyMaxActiveBlocksPerMultiprocessor):

[1, 202048]     grid=(197,1)   block=(32,8)  regs=32  occupancy=100%  0.19 waves  scratch=1595 B
[8, 202048]     grid=(132,8)   block=(32,8)  regs=32  occupancy=100%  1.00 waves  scratch=8495 B
[4096, 1024]    grid=(1,4096)  block=(32,2)  regs=32  occupancy=100%  0.97 waves  scratch=0 B
[65536, 64]     one thread per row kernel (below the 128 column threshold)  scratch=0 B

Increasing the block count past the heuristic makes it slower rather than faster, which is why the geometry targets a single wave:

x=197 blocks/row : 3.83 us   (heuristic)
x=256 blocks/row : 3.83 us
x=512 blocks/row : 4.71 us
x=790 blocks/row : 5.91 us

The thread level scan advanced its position with `id += MAX_NUM_ELEMENTS_PER_THREAD *
num_threads_in_grid_row` and formed each load offset as `id + i *
num_threads_in_grid_row`, both in `int`. A row is admitted up to `INT_MAX`
columns and a grid row holds up to 256 blocks x 256 threads, so the step
reaches 262144 and those sums overflow to negative values once `id`
approaches `INT_MAX`. That reads far outside the tensor and, because a
negative position still satisfies `id < num_cols`, the loop never
terminates.

Reproduced on an H200 with a `[1, INT_MAX]` fp16 row: 12884901888 negative
positions computed and every one of the 65536 threads still looping when a
watchdog stopped them.

Write the scan with differences instead of sums. `remaining = num_cols - id`
and `delta = i * num_threads_in_grid_row` are both smaller than `num_cols`,
and a position is only formed as `id + delta` after `delta < remaining` has
been checked, so every value stays inside `[0, num_cols)` and no sum can
overflow. The guard `delta < remaining` is exactly the old `id + delta <
num_cols`, and the recorded index is unchanged, so results are identical for
every input.

int64 positions were measured as the alternative. They are correct as well
but cost 4% to 38% on the shapes this PR targets (for example [65536, 256]
28.0 us -> 38.7 us, [32, 202048] 10.7 us -> 13.2 us) because the loads lose
32 bit addressing, while the difference based form matches the previous
throughput ([1, 202048] 6.4 us -> 6.2 us) and needs 32 registers instead of
40 for double. No supported shape is dropped either way: the dispatch range
is unchanged.

Tests: a `[1, INT_MAX]` fp16 case (skipped when the device cannot hold the
~4 GiB allocation), widths straddling the widest per iteration scan step,
and buffer sizing at `INT_MAX` widths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
@justinchuby

Copy link
Copy Markdown
Contributor Author

Thanks, this is a real bug and it is now fixed in 0d85ddf. Details and evidence below. I am leaving the review thread open for you to close.

The bug

The thread level scan advanced with id += MAX_NUM_ELEMENTS_PER_THREAD * num_threads_in_grid_row and formed offsets as id + i * num_threads_in_grid_row, both int. A grid row holds up to 256 blocks x 256 threads, so the step reaches 4 * 65536 = 262144, and num_cols is admitted up to INT_MAX. Once id gets within one step of INT_MAX both sums overflow. Worse than a wrong index: a negative position still satisfies id < num_cols, so the loop never terminates.

Reproduced on H200 with a [1, INT_MAX] fp16 row (4.29 GiB, comfortably allocatable), with the out of bounds read replaced by a counter and an iteration watchdog so the repro cannot hang the machine:

num_cols=2147483647  grid=(256,1) block=(32,8)  threads/row=65536  step=262144

int position arithmetic (as submitted)
  time=327.26 ms
  negative (out of bounds) positions computed: 12884901888
  threads still looping when the watchdog fired: 65536 of 65536

So every thread would have spun forever, and 1.3e10 reads would have landed outside the tensor.

The fix

The scan is now expressed with differences instead of sums:

const int scan_step = MAX_NUM_ELEMENTS_PER_THREAD * num_threads_in_grid_row;
for (int id = tid_in_grid_row; id < num_cols;) {
  const int remaining = num_cols - id;          // 1 <= remaining <= num_cols
  ...
    const int delta = i * num_threads_in_grid_row;
    if (delta < remaining) { v[i] = row_data[id + delta]; }   // id + delta < num_cols
  ...
  if (remaining <= scan_step) break;
  id += scan_step;                              // scan_step < remaining, so id stays < num_cols
}

Every quantity is provably inside [0, num_cols]: remaining is a difference of two values in range, delta <= 3 * 65536, and a position is only formed after delta < remaining has been checked. delta < remaining is exactly the old id + delta < num_cols, and the recorded index is still id + delta, so the selected index is unchanged for every input.

Why not int64 positions

I implemented and measured that variant too, since it was the suggested fix. It is correct, but the loads lose 32 bit addressing and it costs 4% to 38% on exactly the shapes this PR is about, while the difference based form matches the previous throughput. Register use is also equal or better (double: 32 vs 40 registers, so it keeps a higher occupancy).

rows cols int (submitted, unsafe) int64 positions differences (merged fix)
1 32000 5.91 us 6.17 us 5.57 us
1 202048 6.37 us 6.66 us 6.18 us
8 202048 8.66 us 9.60 us 8.49 us
32 202048 10.69 us 13.22 us 10.81 us
4096 4096 20.72 us 24.41 us 20.77 us
65536 256 28.04 us 38.66 us 28.30 us
65536 1024 67.74 us 84.72 us 67.73 us

Registers, ptxas -v, sm90:

scan form float half double
int (submitted) 32 32 40
int64 positions 32 32 40
differences (merged fix) 32 32 32

No supported shape is narrowed: the dispatch conditions in reduction_ops.cc are untouched, so everything that reached the cooperative kernel before still does.

Primary shape, no regression

[1, 202048] fp32 went from 6.37 us to 6.18 us after the fix (the description table was regenerated from the post fix build).

New tests

  • ArgMinMaxLastAxisHugeWidth: a real [1, INT_MAX] fp16 row with duplicated extremes placed in the last scan iteration (num_cols - 3 and num_cols - 1 for ArgMax, 1234567890 and num_cols - 2 for ArgMin) so both the overflow arithmetic and the tie rule are exercised at the boundary. It queries cudaMemGetInfo and skips when the device cannot hold the ~4 GiB allocation, so it does not break smaller CI GPUs. Verified it actually runs (not skipped) on H200: [ OK ] ReductionFunctionsTest.ArgMinMaxLastAxisHugeWidth (21563 ms).
  • ArgMinMaxLastAxisScanStepBoundary: widths 262143 / 262144 / 262145 / 524287 / 524288 / 524289 straddle the widest per iteration step, so the loop termination arithmetic is covered without any large allocation (~1 MB each).
  • ArgMinMaxLastAxisBufferSizeExtremes: buffer sizing at INT_MAX, INT_MAX - 1, 2^30 and 2^20 widths stays non zero, below 1 MiB and free of overflow, and stays zero for many narrow rows.

Verification after the fix

ReductionFunctionsTest.*                          25/25 pass  (was 22, +3 new)
ReductionOpTest / *ArgMax*:*ArgMin*               32/32 pass
*Reduce* operator tests                          376/376 pass
CUDA_EP_Unittest.All (all CUDA EP internal)       79/79 pass
compute-sanitizer memcheck, arg reduction tests    0 errors
compute-sanitizer memcheck, [1, INT_MAX] test      0 errors   <- the case that produced 1.3e10 OOB positions before
compute-sanitizer racecheck                        0 hazards
compute-sanitizer synccheck                        0 errors

H200 (sm90), CUDA 13.3, Release, CMAKE_CUDA_ARCHITECTURES=90.

@justinchuby
Justin Chu (justinchuby) marked this pull request as ready for review August 14, 2026 20:43
@justinchuby

Copy link
Copy Markdown
Contributor Author

Tianlei Wu (@tianleiwu)

Copilot AI 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.

Pull request overview

Parallelizes CUDA ArgMax/ArgMin reductions over wide last axes while preserving existing semantics.

Changes:

  • Adds cooperative warp/block reduction with scratch-buffer support.
  • Integrates shape-aware dispatch into CUDA reduction operators.
  • Adds extensive kernel, operator, edge-case, and performance tests.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
reduction_functions.cu Implements cooperative CUDA reduction.
reduction_functions.h Exposes buffer sizing and updated API.
reduction_ops.cc Allocates scratch space and dispatches kernels.
reduction_functions_test.cc Adds CUDA kernel and benchmark coverage.
reduction_ops_test.cc Adds end-to-end wide-axis tests.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Justin Chu (justinchuby) added a commit to justinchuby/onnx-genai that referenced this pull request Aug 15, 2026
Design decision: no ORT custom-op registration. Three implementations
remain, and none of them needs a custom domain:

1. the native EP device-sampler kernel (#976, main-targeted),
2. generic upstream ORT CUDA ArgMax (microsoft/onnxruntime#32092),
3. the portable ONNX tiled rewrite here, for stock ORT versions that
   predate #32092.

Removes the custom op and everything that existed only to serve it: the
fused_argmax module and its session test, the CUDA-only custom-op domain
registration in Session::new, the prost dev-dependency that only that test
needed, the WideArgReduceLowering Fused variant with its fusable, fuse and
element_types helpers and FUSABLE_ELEMENT_TYPES, the ArgReduceRewrites fused
counter, the FUSED_DOMAIN and FUSED_OP constants and their re-export, the
opset-import injection for the custom domain, the availability probe in
islands, and the fused benchmark and test modules.

Direct and Tiled are untouched, and so is the version gate that chooses
between them, so a future ORT containing #32092 still gets the node as
authored. Coverage that was only reachable through the fused test modules is
preserved as a direct module rather than deleted.

On an H200 against stock ORT 1.28 the gate still reports direct-capable
false, and the surviving sweep measures direct 35538.28 us against tiled
155.34 us over 1 and 10 nodes, which is why the portable fallback stays.
The lowering parity test passes, so Direct and Tiled still agree on the
selected token.

engine 324 lib tests, ort 135 lib tests and the 3 ignored CUDA argmax tests
pass; cargo check clean for onnx-genai-ort with and without the cuda
feature and for onnx-genai-engine.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chuby <justinchuby@users.noreply.github.com>

@tianleiwu Tianlei Wu (tianleiwu) 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.

Reviewed the cooperative reduction, dispatch, scratch-buffer layout, and focused tests. The multi-block completion protocol is correctly ordered (partial write, device-wide fence, per-row atomic completion), tie-breaking preserves the lowest index, leading-NaN behavior matches the existing serial path, and the difference-based scan covers the admitted INT_MAX width without signed overflow. The graph replay, alignment, invalid-buffer, special-value, and width-boundary tests give this good coverage.

Compared with #31694, this PR should merge and #31694 should be closed as superseded. #31694's one-256-thread-block-per-row design is a useful improvement over the serial scan, but it still occupies only one SM for the motivating [1, vocab_size] workload. This PR adaptively uses multiple blocks per row while retaining the serial path for narrow rows, and both PRs modify the same launcher and overlapping tests. If benchmarks on other GPU generations later identify a middle-width range where one fixed block wins, that dispatch choice can be folded into this implementation rather than maintaining two competing paths.

The existing benchmark-command thread is documentation-only and does not block approval.

@justinchuby
Justin Chu (justinchuby) merged commit d4d792f into microsoft:main Aug 15, 2026
91 checks passed
@justinchuby
Justin Chu (justinchuby) deleted the justinchuby/optimize-wide-argmax branch August 15, 2026 14:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants