Skip to content

Read ScatterElements indices in place instead of materializing them - #32055

Open
Alexander Novikov (novikov-alexander) wants to merge 3 commits into
microsoft:mainfrom
novikov-alexander:scatter-index-no-materialize
Open

Read ScatterElements indices in place instead of materializing them#32055
Alexander Novikov (novikov-alexander) wants to merge 3 commits into
microsoft:mainfrom
novikov-alexander:scatter-index-no-materialize

Conversation

@novikov-alexander

Copy link
Copy Markdown

Stacked on #32026. The diff shown against main includes that PR's commit. Review only the second commit, Read ScatterElements indices in place instead of materializing them; I'll rebase once #32026 lands.

Description

GetIndices normalized every index into a std::vector<int64_t> before any scatter work began. For a [4096, 1024] indices tensor that is a 33 MB allocation plus a full write pass, and the scatter then reads 8 bytes per element back rather than the 4 an int32 indices tensor would have cost.

This validates the indices in place and lets the scatter read the indices tensor directly, normalizing negatives at the point of use. Validation still runs up front, so an out-of-range index is still reported before the output is touched — the error path and its message are unchanged.

Why a reader rather than a template parameter

The obvious implementation templates ScatterData on the index type. I built that first and measured it: it instantiates the entire scatter — including the contiguous fast path — once per index type.

object size for scatter.cc
templated on index type 2.20 MB
index type behind a reader 1.12 MB

The templated version was about 7% faster on the largest shape. Spending 1.08 MB of object code for that seemed like the wrong trade for a runtime that ships to mobile and has a minimal-build mode, so the element type lives in a small ScatterIndices struct instead. The branch it introduces is loop-invariant in practice, and on the fast path only one index is read per row anyway.

Happy to switch to the templated version if maintainers weigh binary size differently — it's a small change either way.

Motivation and Context

After #32026 the scatter loop itself is no longer the bottleneck for row-broadcast indices; the up-front index materialization is. This removes it.

Measured on an M3 Pro, Release, 12 threads, timing the whole node through an InferenceSession (axis=0, float, reduction='add', row-broadcast indices):

shape #32026 + this PR speedup
N=4096, C=1024 2.52 ms 1.91 ms 1.3x
N=4096, C=256 0.35 ms 0.27 ms 1.3x
N=8192, C=64 0.20 ms 0.13 ms 1.6x
N=65536, C=32 1.42 ms 1.26 ms 1.1x

Cumulatively against current main, measured in the same session so the numbers are comparable:

shape main #32026 + this total
N=4096, C=1024 14.14 ms 1.91 ms 7.4x
N=4096, C=256 1.10 ms 0.27 ms 4.1x
N=8192, C=64 0.58 ms 0.13 ms 4.5x
N=65536, C=32 5.14 ms 1.26 ms 4.1x

The allocation removal also matters on its own: peak memory for the node drops by 8 bytes x indices count, which for the first row above is 33 MB that no longer has to be reserved, written and read back.

Tests

Existing coverage already exercises negative indices heavily — the RunTest helper deliberately generates them — and that now goes through the new normalize-on-read path.

Added two cases for the interaction between negative indices and the contiguous path, which is new:

  • RowBroadcastIndicesNegative — a row of all -3 addressing the same row as all 1, on the fast path.
  • RowBroadcastIndicesMixedSignFallsBack — a slice mixing -3 and 1. These are the same destination row but differ by raw value, so the constancy check rejects the slice and it must fall back to the generic path and still land correctly.

GatherElementsGrad shares this code and its caller is updated accordingly; note it is behind ENABLE_TRAINING_OPS, which my local build does not compile, so that path is compile-checked by review and CI rather than by me.

Full onnxruntime_provider_test sweep: 5642 tests, 5454 passed, 0 failures, unchanged from baseline.

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

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

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>
GetIndices normalized every index into a std::vector<int64_t> before any
scatter work began. For [4096, 1024] indices that is a 33 MB allocation and a
full write pass, and the scatter then reads those 8 bytes per element back
rather than the 4 an int32 indices tensor would have cost.

Validate the indices in place instead, and let the scatter read the indices
tensor directly, normalizing negatives where they are used. Validation still
happens up front so an out-of-range index is still reported before the output
is touched.

The index element type is carried by a small reader rather than a template
parameter on ScatterData. Templating would have been marginally faster but
instantiates the whole scatter, including the contiguous fast path, once per
index type: measured on this file that is 2.20 MB of object code against
1.12 MB for the reader, and the branch it replaces is loop-invariant in
practice. The 1.08 MB seemed like the wrong thing to spend for roughly 7% on
the largest shape.

Measured on an M3 Pro, Release, 12 threads, timing the whole node through an
InferenceSession, axis=0 float reduction='add' with row-broadcast indices:

                    before   after   speedup
  N=4096  C=1024    2.52 ms  1.91 ms   1.3x
  N=4096  C=256     0.35 ms  0.27 ms   1.3x
  N=8192  C=64      0.20 ms  0.13 ms   1.6x
  N=65536 C=32      1.42 ms  1.26 ms   1.1x

Adds tests for negative indices on the contiguous path, and for a slice that
mixes the two spellings of one row, which is not constant by raw value and so
has to fall back to the generic path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant