Skip to content

Parallelize the ScatterElements input-to-output copy - #32059

Open
Alexander Novikov (novikov-alexander) wants to merge 4 commits into
microsoft:mainfrom
novikov-alexander:scatter-parallel-copy-stacked
Open

Parallelize the ScatterElements input-to-output copy#32059
Alexander Novikov (novikov-alexander) wants to merge 4 commits into
microsoft:mainfrom
novikov-alexander:scatter-parallel-copy-stacked

Conversation

@novikov-alexander

Copy link
Copy Markdown

Stacked on #32026 and #32055. The diff against main includes both. Review only the last commit, Parallelize the ScatterElements input-to-output copy — a ~25 line change. I'll rebase as those land.

Description

ScatterData copies the entire data input to the output before applying any updates, and does it with a single memcpy while the operator thread pool sits idle. For a 16 MB data tensor that's roughly a millisecond on one core.

This splits the copy across the pool when the tensor is large enough to be worth it. Small tensors keep the plain memcpy — sharding them costs more than it saves — and the std::string path is untouched.

Motivation and Context

Measured on an M3 Pro, Release, 12 threads, timing the whole node through an InferenceSession (axis=0, float, reduction='add'), A/B back to back:

shape before after speedup
N=4096, C=1024 (16.8 MB data) 2.25 ms 1.61 ms 1.40x
N=4096, C=256 0.23 ms 0.20 ms 1.16x
N=8192, C=64 0.15 ms 0.13 ms 1.16x
N=65536, C=32 1.00 ms 0.99 ms 1.02x

Being straight about the sequencing: measured against main on its own, this change shows no improvement beyond noise — 11.72 ms vs 11.47 ms on the first shape. That's expected rather than surprising. On main the strided scatter loop dominates the node and the copy is only about a tenth of it, so making the copy faster is invisible. Once #32026 and #32055 remove that loop overhead, the copy becomes the largest remaining component and parallelizing it is worth 1.4x.

So this is only worth landing after those two. I'm raising it now so it isn't lost, not to jump the queue — happy for it to sit until they're in.

One thing I could not measure: this should help more on typical x86 servers than on Apple Silicon, where a single core already reaches a large fraction of total memory bandwidth. I have no x86 machine to check that on, so treat it as a hypothesis rather than a claim.

Tests

No behaviour change — the copy produces identical bytes whether it runs on one thread or twelve, and the existing suite covers the copy on every Scatter/ScatterElements path. 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>
ScatterData copies the whole data input to the output before applying any
updates, and does it with a single memcpy while the operator thread pool sits
idle. For a 16 MB data tensor that is around a millisecond on one core.

Split the copy across the pool when the tensor is large enough to be worth it.
Small tensors keep the plain memcpy, where sharding would cost more than it
saves, and the string path is untouched.

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

                    before   after   speedup
  N=4096  C=1024    2.25 ms  1.61 ms   1.40x
  N=4096  C=256     0.23 ms  0.20 ms   1.16x
  N=8192  C=64      0.15 ms  0.13 ms   1.16x
  N=65536 C=32      1.00 ms  0.99 ms   1.02x

Against main on its own the same measurement shows no change beyond noise
(11.72 ms vs 11.47 ms on the first shape). That is expected: the strided
scatter loop dominates there and the copy is only about a tenth of the node.
It is worth landing once the loop cost is out of the way.

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