Skip to content

WP5: a segmented near field, because float32 cannot afford a scatter - #63

Merged
TobiBu merged 2 commits into
perf/svgd-cheaper-gradientfrom
perf/svgd-gather-accumulation
Sep 5, 2026
Merged

WP5: a segmented near field, because float32 cannot afford a scatter#63
TobiBu merged 2 commits into
perf/svgd-cheaper-gradientfrom
perf/svgd-gather-accumulation

Conversation

@TobiBu

@TobiBu TobiBu commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Stacked on #62. Its diff also shows #61's three tree-core commits until #61 merges; they
are not new here.

Localising the 5× float32 penalty that survived #62. Timing the near field's parts separately
at N = 10⁵ on an A100:

gather + arithmetic the scatter alone
float64 22.60 ms 5.99 ms
float32 20.47 ms 258.48 ms

A dense kernel of the same shape with no indexing is 0.19 / 0.39 ms — the expected 2:1. So the
arithmetic is fine in both and the whole float32 penalty is one phi.at[slots].add(...):
the indices repeat ~62× on average, so float32 lowers to a contended atomicAdd while float64
takes another path. This is a scatter problem, not a float32 problem — float64 pays 21 % of
its near field for the same line.

So accumulate= gets a second option: leaves tile [0, n) disjointly, so summing each target
leaf's directed pairs into (L, ml, d) and placing it is a permutation, and no atomic is
needed anywhere. float32 forward 586 → 321 ms at N = 10⁵.

The default stays "scatter", deliberately. The segmented path is slower under reverse
mode
— 2392 → 3172 ms at N = 10⁵ — because the transpose of a gather is a scatter: autodiff
reintroduces exactly the operation the forward removed, and doubles the arithmetic besides.
"auto" picks by dtype for forward-only callers; a caller that differentiates should leave the
default alone. The table is in the function's docstring so the trade cannot be lost.

Also here: exact_phi contracts to two matmuls instead of an (n, n, d) tensor, so the
reference baseline reaches the same N the tree does instead of stopping at 2·10⁴.

Evidence: reports/YGGDRAX_perf_report.md §5.2, §5.3.

🤖 Generated with Claude Code

…catter

Splitting the near field into its parts at N=1e5 on an A100 says the arithmetic
was never the problem:

                       gather+arithmetic    the scatter alone
    float64                    22.60 ms              5.99 ms
    float32                    20.47 ms            258.48 ms

float32 is *faster* than float64 at gather and arithmetic, as it should be -- a
dense kernel of the same shape is 0.19 vs 0.39 ms. The whole penalty is one
.at[].add(): the scatter indices repeat ~62x on average, so float32 lowers to a
contended atomicAdd where float64 takes another path.

Leaves tile [0, n) disjointly, so summing each target leaf's directed pairs into
(L, ml, d) and then *placing* it is a permutation -- no atomics anywhere. Five
strategies measured; the two that survive are the two that ship:

                                          float64     float32
    halved pairs, two scatters            28.58 ms   278.94 ms
    directed pairs, segment_sum, chunked  56.15 ms    32.48 ms

Neither wins in both columns, so accumulate= names the choice: "scatter"
(default), "segment", or "auto" (segment for float32). Rejected on the way: the
sorted-index hint (helps float32 6.4x, costs float64 4.5x) and the per-leaf
gather (worse in both -- neighbour counts are skewed mean 621 / max 3124, so
padding wastes 5x).

**The default is deliberately not the fastest forward.** "segment" is 4.0x
faster forward in float32 at N=1e4 (11.64 -> 2.93 ms) and slower under reverse
mode (fwd+grad 2392 -> 3172 ms at 1e5), because the transpose of a gather is a
scatter: reverse mode reintroduces exactly what the forward removed, and the
directed list evaluates each kernel twice besides. So svgd_phi_from_topology
keeps the gradient-safe path and only the forward-only entry points ask for
"auto". Nothing regresses; the win is available where it is real.

float64 is untouched: 5.72 ms forward at N=1e4 and 112.67 at 1e5, as before.
This is the baseline the tree update is measured against, so a weak one
flatters the tree -- and the old one was weak in two ways at once. It built an
(n, n, d) tensor of per-pair terms, which is d times the memory and runs at
elementwise rather than GEMM throughput, and it simply could not be evaluated
beyond N ~ 2e4, which is why every crossover claim so far rests on N <= 2e4.

The sum contracts:

    phi_i = [ (K S)_i + ( x_i (K 1)_i - (K X)_i ) / h^2 ] / N

with K_ij = exp(-|x_i - x_j|^2 / 2h^2). One kernel matrix and two matmuls. An
optional block_size caps the kernel matrix at (block_size, n) via lax.map, so
large N costs bounded memory instead of being impossible.

Verified against the materialised form at n = 7, 64, 300 and 1001 for block
sizes None, 1, 13, 64 and 4096: max relative difference 5.0e-15.

TB, 2026-09-04: efficient implementations, not only working ones -- and that
has to include the thing we compare against.
@TobiBu
TobiBu force-pushed the perf/svgd-gather-accumulation branch from 5631c21 to f8afdeb 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