Skip to content

WP2: a cheaper near field, a cheaper partition assembly, and a metric that was lying - #62

Merged
TobiBu merged 4 commits into
perf/svgd-kernel-cutofffrom
perf/svgd-cheaper-gradient
Sep 5, 2026
Merged

WP2: a cheaper near field, a cheaper partition assembly, and a metric that was lying#62
TobiBu merged 4 commits into
perf/svgd-kernel-cutofffrom
perf/svgd-cheaper-gradient

Conversation

@TobiBu

@TobiBu TobiBu commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Stacked on #60.

Four changes to the Stein update's cost under differentiation:

  1. One kernel evaluation per unordered near pair. The walk emits every near leaf pair in
    both directions and the old accumulation evaluated both, though the kernel value is shared.
    Symmetry of the near list was already a precondition — without it a particle in B never
    receives from A — so it is now checked rather than assumed.
  2. Rematerialised chunks. The (pairs, ml, ml, d) tensor was built whole and stored by
    reverse mode. It is now a lax.scan under jax.checkpoint, peak memory one chunk.
  3. The far-entry expansion vectorised out of its np.arange-per-pair loop: 22.8 → 8.3 ms
    on the host half at N = 10⁴.
  4. The expansion moved on device where it is large enough to pay — 1091 → 48 ms at
    N = 10⁵ — with the crossover measured (_DEVICE_FAR_EXPANSION_MIN_ENTRIES), because below
    ~10⁶ entries the dispatch costs more than the copy it avoids.

The chunk default is 256 MiB, and the story of how it got there matters. 64 MiB — what the
plan specified — produced the best forward-to-gradient ratio in the sweep (1.72) entirely by
making the forward 4.6× slower, at nearly double the absolute cost. That is the same failure
mode as [D-016]: a ratio improved by degrading its denominator. The table is in the constant's
docstring.

Ratio at N = 10⁴ falls 12.79 → 2.80 and absolute forward-plus-gradient falls 33.0 → 14.6 ms.
The forward regressed 2.58 → 5.20 ms, which is the honest cost of the scan, and is why later
PRs in this stack revisit it.

Evidence: reports/YGGDRAX_perf_report.md §5, §5.1, §5.4.

🤖 Generated with Claude Code

Three changes to the near field, which Gate 0 says is where the Stein update's
time actually is (the far field is 0.45 % of the pair terms).

**One evaluation per unordered pair.** The walk emits every near leaf pair in
both directions and the old accumulation evaluated both, although the kernel
value is shared: the contribution to i from j is k*s_j + k*(x_i - x_j)/h^2 and
to j from i is k*s_i - k*(x_i - x_j)/h^2, one exp for both. The partition now
keeps one entry per unordered pair and the accumulation scatters both
directions from one evaluation. num_near_leaf_pairs on the topology still
reports the *directed* count so the bench numbers stay comparable across work
packages. Symmetry of the walk's near list is a precondition of the old code
too -- without it a particle in B never receives from A -- so it is now checked
and raised on rather than assumed.

**Rematerialised chunks.** The (pairs, ml, ml, d) tensor was built whole and
stored by reverse mode, which is why the gradient costs 13x the forward at
N = 1e4 (Gate 0, float64). It is now a lax.scan over chunks under
jax.checkpoint: peak memory is one chunk, capped at 64 MB by default, and the
backward pass recomputes rather than reads. chunk_pairs is a static parameter
if the default is wrong for a problem.

**The far-entry expansion is vectorised.** One np.arange per far pair in a
Python list comprehension was 136 ms of a 236 ms build at N = 2e4 -- more than
the entire device half -- and grew superlinearly. cumsum/repeat gives the same
array; checked against the loop on 500 random cases including the empty one.

Verified: at theta = 0 the update still equals the exact update to 3e-16, and
the answer is invariant to chunk_pairs (1, 7, and one-chunk agree to 9e-17).
…umber

The 64 MiB default made the forward pass 4.6x slower at N = 1e4 (10.12 ms
against 2.19 ms at 256 MiB) and in exchange produced the best-looking
forward-to-gradient ratio in the sweep, 1.72. That ratio is Gate 2's metric,
and it was being met by making the denominator worse -- the absolute
forward-plus-gradient cost at 64 MiB is 17.46 ms against 9.97 ms at 256 MiB.

256 MiB is the measured optimum: best forward, near-best total, and it still
bounds memory, which one chunk does not (4996 MiB by N = 3e4). The table is in
the constant's docstring so the next person does not have to re-derive it.
M is by far the largest array in the partition -- 89,555,008 entries at
N = 1e5 -- and building it on the host cost 1074 ms of a 1438 ms build,
nearly all of it allocating ~2 GB of numpy and copying it back. Gate 2 made
that the largest single cost anywhere in this plan.

The expansion now runs on device. Only two scalars cross to the host: the kept
far-pair count and M, both needed to give the device arrays a shape. Everything
else -- the tag filter, the cumsum, the two repeats -- stays where the data is.

The leaf blocks and the near-pair rows stay on the host: they are O(L) and O(P)
and were never the problem.
Assembling M on device is a 3.6x win on the whole build at N = 1e5 (1456 ->
409 ms, its host half 1091 -> 48 ms) and a 17 % *loss* at N = 1e4 (83 -> 96 ms),
because the device path pays ~10 eager dispatches and two synchronisations
whatever the size while the host path pays per entry.

So neither is right everywhere. The path is chosen by the actual entry count --
which costs nothing to know, since node_ranges is already on the host for the
leaf blocks -- with the threshold at 2^21 entries, where the two costs cross.
Both branches produce the same arrays.
@TobiBu
TobiBu force-pushed the perf/svgd-cheaper-gradient branch from fd2eff9 to 27f3c97 Compare September 5, 2026 19:36
@TobiBu
TobiBu merged commit 7dc4feb into main Sep 5, 2026
8 checks passed
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