Add a contiguous fast path to the CPU ScatterElements kernel - #32026
Add a contiguous fast path to the CPU ScatterElements kernel#32026Alexander Novikov (novikov-alexander) wants to merge 2 commits into
Conversation
The generic loop hands each work unit a single inner index, so a work unit walks one column of the updates with a stride of inner_size. For the very common axis=0 rank-2 case that means outer_size is 1, there are only C work units, and each one traverses a row-major buffer column-wise. When the indices repeat across the dimensions after the axis, each update slice lands on a single contiguous run of the output, so the strided walk becomes a sequential one the compiler can vectorize. Those indices are what an Expand of an [N, 1] index tensor to [N, C] produces, which is how a row scatter is spelled in PyTorch (Tensor.scatter_ requires index and src to have the same rank), and it is also what GatherElementsGrad sees. Detection is at runtime from the indices tensor, so it does not depend on the Expand having been constant-folded. The scan bails on the first mismatch, which is the first slice for ordinary indices. Results are unchanged, bit for bit. Each work unit still owns a distinct (outer, inner-range) region of the output, and within a unit the axis is walked in ascending order, so a destination receiving several updates accumulates them in the same sequence as before. Splitting the run into blocks rather than parallelizing over update rows is what preserves that. Two guards matter. The run is contiguous only when the updates and the data agree on every dimension after the axis; ScatterElements permits the updates to be smaller there, and an inner coordinate then maps to different offsets in the two tensors. And runs shorter than about a cache line are skipped, since the generic loop's stride is already local and the fast path would trade away parallelism for nothing. Measured on an M3 Pro, Release, 12 threads, timing the whole node through an InferenceSession, axis=0 float reduction='add' with row-broadcast indices: N=4096 C=1024 13.42 ms -> 3.07 ms 4.4x N=4096 C=256 0.94 ms -> 0.38 ms 2.5x N=8192 C=64 0.37 ms -> 0.22 ms 1.7x N=65536 C=32 4.97 ms -> 1.95 ms 2.5x The scatter loop itself speeds up considerably more than that; index validation, which materializes a normalized int64 per element, dominates what is left. Tests cover the fast path for 'add' and rank 3, a duplicate-destination case under reduction='none' that fails if the axis is visited in any other order, and updates narrower than the data in the last dimension, which must stay on the generic path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
Pull request overview
This PR adds a CPU ScatterElements optimization that detects row-broadcast (inner-constant) indices at runtime and, when safe, switches from a strided per-element loop to a contiguous-run loop that is friendlier to vectorization—while preserving bit-exact update order semantics (including for reduction="add" and reduction="none" cases with duplicates).
Changes:
- Added a runtime detector for “indices constant along inner dimensions” and a contiguous fast path in the CPU
ScatterElementskernel when trailing dims match. - Introduced new CPU provider tests covering the fast path, order sensitivity for
reduction="none", higher-rank behavior, and a guard case where trailing dims differ.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| onnxruntime/core/providers/cpu/tensor/scatter.cc | Adds indices-pattern detection and a contiguous-blocked parallel fast path when safe. |
| onnxruntime/test/providers/cpu/tensor/scatter_op_test.cc | Adds targeted tests to exercise the fast path and its safety/ordering guards. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // True when, inside every slice of `inner_size` consecutive indices, all of them are equal. | ||
| // That is the layout an Expand of an [N, 1] index tensor to [N, C] produces, and it means the | ||
| // scatter can move whole contiguous runs instead of individual strided elements. | ||
| // Bails out on the first mismatch, so the ordinary case costs one slice's worth of reads. |
CI showed two of the new tests failing on providers they were never meant to say anything about. RowBroadcastIndicesNoneKeepsLastUpdate asserts that the later update wins when several target one element. ONNX leaves that unspecified, so it is a property of this kernel rather than of the operator, and providers that apply updates concurrently pick an arbitrary winner. It failed on the CUDA plugin EP and on WebGPU for that reason. RowBroadcastIndicesNarrowerTrailingDim checks which internal path the CPU kernel picks for a shape the fast path has to decline; it failed on QNN, which returns zeros for that shape. Both now run on CPU only, which is what they were always testing. Also reworded the comment on the row-broadcast detector. It claimed the scan bails out on the first mismatch and costs one slice, which overstates what a parallel loop does: other workers stop at their next slice boundary, so the bound is small but not one slice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — that's a fair catch on the comment, and CI turned up two real problems with my tests. Both are now fixed in 693c978. The comment. You're right that it over-promised. With The CI failures, since they're worth being explicit about: all six were my tests, not the kernel. No CPU pipeline failed.
On the second one: QNN produced a wrong value rather than declining the node, on The two dependent PRs (#32055, #32059) have been rebased onto this. |
Two groups of the new tests assert things that are not properties of the operator, and would fail on providers that were never the subject. AddReduction_MLFloat16_RoundsAfterEachUpdate pins the accumulation precision. ONNX does not specify the intermediate precision, so rounding after every update is a property of this kernel; a provider that accumulated in float and rounded once would be equally valid and would produce 1026 instead of 1024. The bfloat16 tests would fail on the CUDA plugin EP for an unrelated reason: that kernel selects its compute type by element size, so it treats bfloat16 as float16 and computes 'add' and 'mul' on misread bits (microsoft#32061). Scoped conservatively, including min and max, since I have no CUDA hardware to confirm which of them survive the misinterpretation. Same mistake was found by CI on microsoft#32026, where a test asserting update ordering ran on every provider; fixing these before they get there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Description
The generic scatter loop gives each work unit a single inner index, so a work unit walks one column of the updates with a stride of
inner_size. For the very commonaxis=0rank-2 case that meansouter_size == 1, there are onlyCwork units, and each traverses a row-major buffer column-wise.When the indices repeat across the dimensions after the axis, each update slice lands on a single contiguous run of the output, so that strided walk becomes a sequential one the compiler can vectorize. This adds that path, detected at runtime from the indices tensor.
Detection bails on the first mismatch, so ordinary indices pay for one slice's worth of reads.
Results are unchanged, bit for bit. Each work unit still owns a distinct
(outer, inner-range)region of the output, so no two touch the same element; and within a unit the axis is walked in ascending order, which is the order the generic loop applies updates in. A destination receiving several updates therefore accumulates them in exactly the same sequence — which matters for floatadd. Splitting the contiguous run into blocks, rather than parallelizing over update rows, is what preserves that.Two guards are load-bearing:
ScatterElementspermits the updates to be smaller there, in which case an inner coordinate maps to different offsets in the two tensors. There is a test for this.Motivation and Context
Row-broadcast indices are not an exotic case — they are how a row scatter is spelled in PyTorch, because
Tensor.scatter_requiresindexandsrcto have the same rank as the destination:That exports as
Expand → ScatterElements, producing exactlyindices[i][j] == indices[i][0]. The same shape shows up in scatter-add pooling, MoE dispatch/combine, KV-cache reorder, and inGatherElementsGradwhenever the forward was thetorch.gatherrow idiom.Because detection is at runtime rather than at graph level, it fires whether or not the
Expandwas constant-folded, and whether the row ids came from an initializer or from a runtime op.Measured on an M3 Pro, Release, 12 threads, timing the whole node through an
InferenceSession(axis=0, float,reduction='add', row-broadcast indices):To be straight about where the remaining time goes: the scatter loop itself speeds up considerably more than these node-level numbers suggest.
GetIndicesmaterializes a normalizedint64_tfor every element up front, and after this change that dominates. Narrowing it looks like a worthwhile follow-up, and it would compound with this.Tests
RowBroadcastIndicesAdd— the fast path forreduction='add', with two update rows landing on one destination row.RowBroadcastIndicesNoneKeepsLastUpdate— duplicate destinations underreduction='none'. Fails if the axis is visited in any order other than ascending, which is the property the bit-exactness argument rests on.RowBroadcastIndicesRank3— rank 3, confirming the run spans all dimensions after the axis.RowBroadcastIndicesNarrowerTrailingDim— updates narrower than the data in the last dimension. Must stay on the generic path; taking the fast path here would write the second half of each slice at the wrong offset.I confirmed by temporary instrumentation that the first three tests actually reach the fast path and the fourth does not — the existing suite would not have exercised it, since its
RunTesthelper fills indices per element withstd::rand().Full
onnxruntime_provider_testsweep: 5640 tests, 5452 passed, 0 failures, unchanged from baseline. That includesGatherElements, which sharesScatterData.