[CoreML EP] Support Gather with scalar 'indices' (#28278)
### Description
The CoreML \`GatherOpBuilder\` rejected rank-0 (scalar) \`indices\`
because CoreML's \`gather\` requires rank-1+ indices and the obvious
workaround would change the output rank — see the \`// Don't allow
scalar 'indices' input.\` comment at \`gather_op_builder.cc:90\`. This
PR performs the workaround internally:
\`\`\`
reshape(indices, shape=[1]) -> indices_1d
gather(data, indices_1d, axis) -> data_shape with the gather axis = 1
squeeze(., axes=[axis]) -> ONNX gather output shape
\`\`\`
…in both the MLProgram and NeuralNetwork emitters. The squeeze restores
the original ONNX output rank, so caller-visible Gather semantics are
unchanged. \`reshape\` is used rather than \`expand_dims\` because
CoreML internally pads scalars and \`expand_dims\` on the padded tensor
can push the apparent rank past the rank-5 limit on high-rank \`data\`.
Restrictions:
- \`data\` must have a fully static shape — we claim a static
intermediate shape between gather and squeeze.
- \`data\` rank capped at 4. The rank-5 case still trips CoreML's
compiler with \`Invalid rank: 6\`, so we keep the conservative bound.
Dynamic-shape and rank-5+ scalar Gather still falls back to CPU
(preserves the existing \`GatherWithScalarIndices\` test, whose data is
dynamic-shape).
Fixes #28180.
### Motivation
StyleGAN-family generators (StyleGAN, StyleGAN2, GFPGAN, …) select
per-layer style codes with a scalar-index Gather. The resulting graph
alternates between Gather and the rest of the generator, splitting the
CoreML subgraph repeatedly.
On GFPGAN-1024 (`[1, 3, 512, 512]`), this PR moves all 16 scalar Gathers
off CPU and the model lands on a single CoreML partition.
**M3 Max, MLProgram, batch 1, 3 × 100-iter steady-state runs (n=300):**
| | Partitions | Mean | StdDev | P50 | P95 | P99 | Max |
|---|---|---|---|---|---|---|---|
| origin/main | 2 | 89.68 ms | 3.67 | 87.82 | 96.71 | 105.00 | 108.01 |
| **this PR** | **1** | **81.77 ms** | **1.85** | **80.97** | **85.98**
| **87.24** | **88.03** |
**Mean −8.8%, stddev −50%, P99 −17%, max −18.5%** — eliminating the
CPU↔CoreML round-trip on every scalar Gather both speeds up the steady
state and tightens the tail.
Striking secondary effect: the worst-case run with the fix (**88.03
ms**) is faster than the *mean* run without it (**89.68 ms**). Every
single fixed inference over n=300 lands below the unfixed average.
### Tests
Six new tests in
\`onnxruntime/test/providers/coreml/coreml_basic_test.cc\` covering
distinct code paths, exercised on both NeuralNetwork and MLProgram
emitters where the dtype is supported:
- \`GatherScalarIndicesAxis1\` — axis=1, mid-rank squeeze.
- \`GatherScalarIndicesAxis0\` — axis=0, leading-axis squeeze.
- \`GatherScalarIndicesNegativeAxis\` — axis=-1, exercises
\`HandleNegativeAxis\`.
- \`GatherScalarIndicesFloat16\` — fp16 data (MLProgram only, as per
\`HasSupportedInputsImpl\`).
- \`GatherScalarIndicesInt64Data\` — int64 data, both formats.
- \`GatherScalarIndicesRank4Data\` — rank-4 data, exercises the
supported maximum.
Each verifies CoreML output against the CPU EP reference and asserts
\`ExpectedEPNodeAssignment::All\`. The existing
\`GatherWithScalarIndices\` test (dynamic-shape data) is updated only in
its comment to reflect the new precise condition; it still exercises the
CPU fall-back as before.
All pass locally on macOS 26.3 / M3 Max.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>