Skip to content

Compute pairwise_distance with matrix multiplications - #1365

Merged
jeromekelleher merged 1 commit into
sgkit-dev:mainfrom
Billyzhang1229:pairwise-distance
Oct 29, 2026
Merged

jeromekelleher merged 1 commit into
sgkit-dev:mainfrom
Billyzhang1229:pairwise-distance

Conversation

@Billyzhang1229

@Billyzhang1229 Billyzhang1229 commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

pairwise_distance is the last function affected by #1267. It passes locally defined functions to da.blockwise and da.reduction, and newer Dask tokenizes them by pickling, so the Numba gufuncs they reference are rebuilt and recompiled on every call (comment). #1275 fixed the other functions, but this one needed a different approach.

This PR:

  • Writes both metrics as matrix products of the values, with missing entries set to zero, and of the mask of observed entries. For example, the squared Euclidean distance over the entries observed in both rows is sum(x_i^2) + sum(x_j^2) - 2 * sum(x_i * x_j), where each sum is a matrix product.
  • Builds them from Dask array operations instead of Numba gufuncs, CUDA kernels and hand-written blockwise and reduction steps, so no functions are passed to Dask to tokenize. Each metric is a function in sgkit.distance.metrics, looked up by name in pairwise_distance, so new metrics can be added there.
  • Uses CuPy on the GPU instead of Numba's CUDA support. Arrays with CuPy chunks are computed on the GPU, and device="gpu" moves the chunks to the GPU and the result back to NumPy. This fixes wrong results for int8 input on the GPU, where the partial sums were stored in an int8 array.
  • Installs CuPy for CUDA 12 in the GPU workflow, with the CUDA libraries it uses from PyPI, instead of the CUDA toolkit from conda. The workflow checks that CuPy can use the GPU before running the tests, which are skipped otherwise.

Other changes in behavior:

  • device="gpu" requires CuPy instead of Numba's CUDA support.
  • The distances are computed in float64 and returned in the floating point type of the input, or float64 for other types, so for integer input the reported dtype is now float64 before the result is computed. See Precision below for why, and for what this costs on GPUs with slow float64.
  • It uses more memory, since each chunk is converted to float64 values and a float64 mask of the observed entries, instead of being used as it is. See Memory below.
  • Infinite values are missing, like negative and nan values. In the matrix products an infinite value times zero is nan, which made every distance of its row nan.
  • The diagonal is exactly zero, or nan where the correlation is undefined, and the correlation distance is clipped to [0, 2].
  • It needs Dask 2024.12 or later, because the blockwise fusion in earlier releases computed wrong correlations from these expressions. Since Relax Dask, Sphinx and stale dependency pins #1364, sgkit requires Dask 2025.2.0 or later.

With this, sgkit uses CuPy for pairwise_distance on the GPU, so the CuPy mention that #579 questioned now applies.

Fixes #1267.

Testing:

  • Build, Windows, Cubed and Check docs passed on my fork.
  • To check that nothing is recompiled, I counted calls to Numba's GUFunc._rebuild while running the full test suite with Dask 2026.8.0: 98 before this change, all in test_distance.py, and none after.
  • New tests check that copies of a row of integers are exactly zero apart, and cover nearly identical float32 rows, float16 input and infinite values. Apart from the first, they fail when the distances are computed in the floating point type of the input.
  • The GPU workflow only runs when dispatched manually (GPU build is failing #1270), so it hasn't run on this branch. I ran test_distance.py on a T4 on Modal, with cupy-cuda12x[ctk] as the workflow installs it, and all 88 tests passed, including the 40 GPU tests. The benchmarks below ran the GPU path on an RTX 5090 with CuPy for CUDA 13, and on an A100 and an H100 with cupy-cuda12x[ctk]. It would still be good to dispatch the workflow to run the GPU tests on its T4.

Precision

Both metrics are differences of large sums, such as sum(x_i^2) + sum(x_j^2) - 2 * sum(x_i * x_j), so a small distance loses precision to rounding, and in float32 most of it. I checked this with six rows of float32 dosages (0 to 2, with 2% missing), where two rows are copies and two others differ in five entries by 0.05, with 100k, 1M and 10M variants in chunks of 100k, on an M4 Pro:

computed in float32 computed in float64
euclidean between copies (exact 0) 0 9.0e-5 to 8.4e-4, and 0 on a T4
euclidean, the pair 0.11 apart 0 correct to 5 digits
correlation between copies (exact 0) about -6e-5 about 1e-13
correlation, the pair 1.6e-7 to 1.6e-9 apart about -6e-5 correct to 4 digits

On a T4, computing in float32 gave 0.15 for the pair 0.11 apart with 100k variants, and 0 with 1M and 10M. With int8 calls both were exact: integer input was already computed in float64, where the sums of small integers are exact.

So the distances are now computed in float64, as scikit-learn does for float32 input in euclidean_distances, and returned in the floating point type of the input. The denominator of the correlation is sqrt(var_i * var_j) rather than sqrt(var_i) * sqrt(var_j): sqrt(v * v) is exactly v, so copies of a row of integers are exactly zero apart, while the product of square roots gave ±2.2e-16. A distance much smaller than the values it is computed from can still be wrong in float64, for example 0 instead of 1 between [1e8, 1e8] and [1e8, 1e8 + 1], which the docstring now mentions.

float32 input now takes about as long as float64 input on the CPU, which is still 23× and 63× faster than before this PR at 2k × 20k on the M4 Pro, and on GPUs where float64 is much slower than float32. At 4k × 40k on Modal, median of 3, where the arrows go from float32 input computed in float32 to computed in float64:

GPU euclidean, float32 input euclidean, float64 input correlation, float32 input correlation, float64 input
T4 0.75 s → 13.9 s 14.1 s 1.30 s → 27.4 s 27.6 s
A100 40GB 0.43 s → 0.38 s 0.51 s 0.59 s → 0.56 s 0.67 s

I haven't rerun the RTX 5090 benchmarks below, but there float32 input should now take about as long as float64 input. At 8k × 80k, that's about 14.7 s for euclidean and 26.3 s for correlation, against 15.0 s and 17.2 s for float32 before this PR, so on GPUs like these float32 correlation is now slower than before. The A100 and the H100 compute float64 about as fast as float32, so there float32 input is still as fast as before: at 8k × 80k the A100 takes 1.50 s and 2.65 s, against 1.54 s and 2.73 s when float32 input was computed in float32.

Memory

The old code computed on the chunks as they were, for example on int8 calls, while the new code converts each chunk to float64 values with the missing entries set to zero, a float64 mask of the observed entries and the squares of the values, so it uses more memory. The peak memory of a call above the memory before it, with 1000 rows of int8 calls (0, 1 and 2, with 5% missing) in chunks of 1000 × 10k, on an M4 Pro with Dask's default 14 threads:

variants euclidean before after correlation before after
100k 0.13 GiB 2.7 GiB 0.96 GiB 2.4 GiB
500k 0.25 GiB 3.2 GiB 1.5 GiB 2.6 GiB

At 500k variants, the new code also takes 2.2 s instead of 41 s for euclidean, and 4.0 s instead of 163 s for correlation.

Its peak doesn't grow with the number of variants, since the chunks are converted as the matrix products use them: from 250k to 2M variants it stays between 3.2 and 3.4 GiB for euclidean, and between 2.6 and 2.8 GiB for correlation. It grows with the number of threads and the size of the chunks instead, by about 0.2 GiB for each thread with chunks of 1000 × 10k, so fewer threads or smaller chunks bring it down. At 500k variants:

euclidean correlation
1 thread 0.42 GiB 0.56 GiB
4 threads 1.0 GiB 1.3 GiB
14 threads 3.2 GiB 2.6 GiB
14 threads, chunks of 1000 × 2500 1.2 GiB 1.1 GiB

Computing float32 input in float64 also doubles its memory. With float32 input at 100k variants, the peak is 2.9 GiB for euclidean and 2.6 GiB for correlation, against 1.4 GiB and 1.2 GiB when it was computed in float32, and 0.06 GiB and 0.48 GiB before this PR.

I haven't measured the memory on the GPU, where the CuPy chunks are converted in the same way.

Setup
  • Measured: the peak resident set size during pairwise_distance(x, metric=metric).compute(), from ru_maxrss, minus the resident set size just before it, in a new process for each run, after a warm-up call on a small array. The input was an in-memory NumPy array, generated row by row so that the call had no freed memory to reuse.
  • Both versions ran in the same environment: Python 3.12, NumPy 2.4.6, Dask 2026.8.0 and Numba 0.68.0 on macOS. Before: the current code with its closures replaced by module-level functions, as in the benchmarks below.

Benchmarks

pairwise_distance before and after this PR, on an Apple M4 Pro and an NVIDIA A100 on Modal. The times before don't include the recompilation from #1267, so the speedups come from the new computation alone:

pairwise_distance_runtime_light pairwise_distance_speedup_light

The speedup grows with the input. On the CPU at 4k × 40k, euclidean goes from 72.5 s to 3.05 s (24×) and correlation from 498 s to 6.29 s (79×). Main's float64 correlation is unusually slow on the M4 Pro at 2k × 20k (123 s, against 47.9 s in float32, and 134 s in an earlier run), so the speedup there is 164×. On the A100 at 8k × 80k, float32 is 26× and 15× faster, and float64 24× and 16×. For the smallest input, whose 16 chunks are 125 × 1250, overhead dominates on the GPU, where correlation on the A100 is slower (179 ms before, 245 ms after in float32). For correlation, the new graph has 1113 tasks instead of 241. On the CPU, the smallest input is still 3.7× and 13× faster.

GPUs that compute float64 much more slowly than float32 gain much less. On an RTX 5090, float64 is only 2.2× and 2.4× faster at 8k × 80k. It computes float64 at 1/64 of its float32 rate, and the new code spends its time in matrix products: an 8192 × 8192 product with CuPy runs at 1.7 TFLOPS in float64 and 76 TFLOPS in float32. The A100 and the H100 compute float64 matrix products as fast as float32 ones (18.6 and 58.5 TFLOPS), and at 8k × 80k float64 is 24× and 16× faster on the A100, and 35× and 30× faster on the H100. float32 input is now computed in float64 too, so on the RTX 5090 it would be about as slow as float64. I haven't rerun the RTX 5090, so it only appears in float64 here:

pairwise_distance_gpus_light

The CPU times are with default settings, where Dask's threaded scheduler runs a task on every core and Accelerate, the BLAS that NumPy uses on macOS, also uses several cores for each matrix product. Limiting Accelerate to one thread with VECLIB_MAXIMUM_THREADS=1 makes the new code at most 8% faster. OpenBLAS is different: on an i9-12900K under WSL2, OPENBLAS_NUM_THREADS=1, which Dask's distributed workers set by default, made the new code about twice as fast in float64 at 4k × 40k (9.29 s → 4.99 s for euclidean and 17.2 s → 9.58 s for correlation). Dask's workers don't set VECLIB_MAXIMUM_THREADS. The old code doesn't use BLAS.

Setup and all timings
  • Input: an M × N NumPy array of 0, 1 and 2 with 5% of the entries missing (nan, or -1 for int8), in a Dask array with 4 × 4 chunks. N is 10 M.
  • Timed: pairwise_distance(x, metric=metric, device=device).compute(), including building the graph, with Dask's threaded scheduler. Each time is the median of 2 to 23 runs, after a warm-up call on a small array that compiles the Numba functions and the CUDA and CuPy kernels. On the GPU the input starts in NumPy chunks, so the times include copying the chunks to the GPU and the result back. The first call on a larger input is slower than these medians while CuPy's memory pool grows, for example 1.23 s instead of 0.63 s for euclidean in float32 at 8k × 80k on the H100. The A100, H100 and M4 Pro times leave out the first call at each size when it takes less than 5 s.
  • Before: the current implementation, with Numba gufuncs on the CPU and Numba CUDA kernels on the GPU. Since Relax Dask, Sphinx and stale dependency pins #1364 removed the Dask pin, it also recompiles its gufuncs on every call ( Dask 2024.8.1 and later is very slow #1267), which adds about 0.3 s (euclidean) and 2.3 s (correlation) to each call on the M4 Pro. The times here leave that out: they use the current code with its closures replaced by module-level functions, so nothing is recompiled, and repeated calls on a small input take 5 ms instead.
  • CPU: an Apple M4 Pro (10 performance and 4 efficiency cores, 48 GB) with Python 3.12, NumPy 2.4.6 with Accelerate, Dask 2026.8.0 and Numba 0.68.0, as on the GPUs. Main and this PR ran in separate processes that took turns, one call each, so that both saw the same background load, and only while no other jobs were running.
  • RTX 5090: the float64 times are from an earlier run on a PC with an i9-12900K under WSL2, with Python 3.12, NumPy 2.4.6 with OpenBLAS, Dask 2026.8.0, Numba 0.68.0, numba-cuda 0.30.4 and CuPy 14.2.0 for CUDA 13. NumPy is older than 2.5 because numba-cuda 0.30.4 doesn't work with NumPy 2.5.
  • Both versions give the same results for every input up to 2k × 20k: the largest difference is 4.4e-16 in float64 and 2.4e-7 in float32.
  • A100 and H100: rerun on Modal after the change to compute float32 input in float64, in containers with 16 vCPUs and 64 GiB of memory, NVIDIA driver 610.57, and the same packages except for CuPy and numba-cuda for CUDA 12.9 (cupy-cuda12x[ctk] 14.2.0, numba-cuda[cu12] 0.30.4), since the driver is older than the local CUDA 13.4 wheels. Dask used 16 threads, as on the local machine. The A100 is the SXM4 80GB model and the H100 the SXM5 80GB model.

CPU, float64:

M × N euclidean before after speedup correlation before after speedup
500 × 5k 121 ms 32 ms 3.7× 721 ms 55 ms 13×
1k × 10k 938 ms 69 ms 14× 5.59 s 132 ms 42×
2k × 20k 8.83 s 415 ms 21× 123 s 749 ms 164×
4k × 40k 72.5 s 3.05 s 24× 498 s 6.29 s 79×

CPU, float64, with VECLIB_MAXIMUM_THREADS=1 for the new code (speedup over before in parentheses):

M × N euclidean before after after, 1 BLAS thread correlation before after after, 1 BLAS thread
500 × 5k 121 ms 32 ms (3.7×) 32 ms (3.8×) 721 ms 55 ms (13×) 56 ms (13×)
1k × 10k 938 ms 69 ms (14×) 67 ms (14×) 5.59 s 132 ms (42×) 129 ms (43×)
2k × 20k 8.83 s 415 ms (21×) 385 ms (23×) 123 s 749 ms (164×) 715 ms (172×)
4k × 40k 72.5 s 3.05 s (24×) 3.02 s (24×) 498 s 6.29 s (79×) 5.90 s (84×)

Other input types on the CPU, at 2k × 20k:

input dtype euclidean before after speedup correlation before after speedup
float64 8.83 s 415 ms 21× 123 s 749 ms 164×
float32 8.87 s 392 ms 23× 47.9 s 763 ms 63×
int8 6.44 s 416 ms 15× 31.2 s 809 ms 39×

Matrix product throughput, 8192 × 8192 with CuPy (no TF32):

GPU float32 float64
RTX 5090 75.8 TFLOPS 1.70 TFLOPS
A100 80GB 18.5 TFLOPS 18.6 TFLOPS
H100 80GB 52.0 TFLOPS 58.5 TFLOPS

GPU times on the three GPUs, before → after (speedup). The RTX 5090 wasn't rerun after the change to compute float32 input in float64, so it only has float64 times:

float32, euclidean:

M × N A100 H100
500 × 5k 178 ms → 153 ms (1.2×) 149 ms → 119 ms (1.2×)
1k × 10k 209 ms → 155 ms (1.3×) 273 ms → 120 ms (2.3×)
2k × 20k 619 ms → 162 ms (3.8×) 579 ms → 121 ms (4.8×)
4k × 40k 3.32 s → 277 ms (12×) 2.76 s → 152 ms (18×)
8k × 80k 38.5 s → 1.50 s (26×) 24.7 s → 630 ms (39×)

float32, correlation:

M × N A100 H100
500 × 5k 179 ms → 245 ms (0.7×) 156 ms → 204 ms (0.8×)
1k × 10k 261 ms → 249 ms (1.0×) 282 ms → 200 ms (1.4×)
2k × 20k 714 ms → 263 ms (2.7×) 716 ms → 207 ms (3.5×)
4k × 40k 4.98 s → 463 ms (11×) 3.56 s → 230 ms (15×)
8k × 80k 40.8 s → 2.65 s (15×) 29.0 s → 1.02 s (28×)

float64, euclidean:

M × N RTX 5090 A100 H100
500 × 5k 164 ms → 98 ms (1.7×) 189 ms → 141 ms (1.3×) 207 ms → 118 ms (1.8×)
1k × 10k 301 ms → 103 ms (2.9×) 274 ms → 150 ms (1.8×) 316 ms → 120 ms (2.6×)
2k × 20k 983 ms → 303 ms (3.2×) 925 ms → 164 ms (5.6×) 928 ms → 132 ms (7.0×)
4k × 40k 4.73 s → 1.85 s (2.6×) 6.21 s → 328 ms (19×) 5.03 s → 193 ms (26×)
8k × 80k 32.6 s → 14.7 s (2.2×) 42.5 s → 1.78 s (24×) 28.9 s → 828 ms (35×)

float64, correlation:

M × N RTX 5090 A100 H100
500 × 5k 230 ms → 159 ms (1.4×) 190 ms → 242 ms (0.8×) 183 ms → 189 ms (1.0×)
1k × 10k 509 ms → 164 ms (3.1×) 335 ms → 251 ms (1.3×) 443 ms → 196 ms (2.3×)
2k × 20k 1.71 s → 551 ms (3.1×) 1.19 s → 263 ms (4.5×) 1.19 s → 206 ms (5.8×)
4k × 40k 9.13 s → 3.51 s (2.6×) 7.12 s → 506 ms (14×) 6.93 s → 275 ms (25×)
8k × 80k 62.4 s → 26.3 s (2.4×) 46.9 s → 2.95 s (16×) 36.1 s → 1.21 s (30×)

@jeromekelleher jeromekelleher left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like a straightforward win to me - nice job @Billyzhang1229 !

@Billyzhang1229
Billyzhang1229 marked this pull request as ready for review October 27, 2026 12:01
@Billyzhang1229
Billyzhang1229 marked this pull request as draft October 27, 2026 12:40
@Billyzhang1229

Billyzhang1229 commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks, there is something goes wrong about floating point precision (when numbers are small). I'm checking these.

@jeromekelleher

Copy link
Copy Markdown
Member

Looks like all tests are passing though?

@Billyzhang1229

Copy link
Copy Markdown
Contributor Author

Looks like all tests are passing though?

The existing tests don't cover this. The old code computed the euclidean distance from (x_i - x_j)^2 directly for each pair, so it didn't have this problem. The new code expands it into sum(x_i^2) + sum(x_j^2) - 2 * sum(x_i * x_j) to use matrix products, and in float32 that subtraction loses small distances: identical or nearly identical rows can come out with the wrong distance. I've fixed it the way scikit-learn does, by computing in float64, and added tests for these cases. The old correlation code already used such sums, computed in float32 for float32 input, so float32 correlation is now more accurate than before. The cost is that float32 input is slower on GPUs with slow float64, like consumer cards.

@jeromekelleher

Copy link
Copy Markdown
Member

That sounds good. Perfectly fine to just say "possible loss of precision with float32" too though.

@Billyzhang1229
Billyzhang1229 marked this pull request as ready for review October 28, 2026 20:13
@Billyzhang1229

Copy link
Copy Markdown
Contributor Author

That sounds good. Perfectly fine to just say "possible loss of precision with float32" too though.

Thanks! I've mentioned it in the docstring. It also needs more memory, since the chunks are converted to float64 (details in the description), but I think that's a good trade-off for the speedup.

@jeromekelleher jeromekelleher left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM - one comment about CI

Comment thread .github/workflows/build-gpu.yml Resolved
Write both metrics as matrix products of the values, with missing
entries set to zero, and the mask of observed entries. For example, the
squared Euclidean distance over the entries observed in both rows is
sum(x_i^2) + sum(x_j^2) - 2 * sum(x_i * x_j), where each sum is a
matrix product. Build them from Dask array operations instead of Numba
gufuncs, CUDA kernels and hand-written blockwise and reduction steps,
with each metric as a function in sgkit.distance.metrics, looked up by
name in pairwise_distance, so that new metrics can be added there.

This is several times faster on the CPU, and no longer passes any
functions for Dask to tokenize (see issue 1267).

The GPU path now uses CuPy instead of Numba's CUDA support: arrays with
CuPy chunks are computed on the GPU, and device="gpu" moves the chunks
to the GPU and the result back to NumPy. This fixes wrong results for
int8 input on the GPU, where the partial sums were stored in an int8
array. The GPU workflow now installs CuPy for CUDA 12, with the CUDA
libraries it uses from PyPI, instead of the CUDA toolkit from conda,
which needs NVIDIA driver 525 or later on the runner, and checks that
CuPy can use the GPU before running the tests, which are skipped
otherwise.

The distances are computed in float64, and returned in the floating
point type of the input, or float64 for other types, so integer input
now reports the float64 dtype of the result before it is computed.
Subtracting the sums of squares and products loses small distances in
float32: with float32 dosages, two rows 0.11 apart could come out 0
apart, and copies of a row could have a negative correlation distance.
In float64 these sums are exact for small integers, such as allele
counts. On GPUs with slow float64, such as the T4 that the GPU workflow
runs on, float32 input now takes as long as float64 input.

Infinite values are now missing, like negative and nan values, since an
infinite value times zero is nan in the matrix products, which made
every distance of its row nan. The correlation distance is clipped to
[0, 2], and its denominator is sqrt(var_i * var_j), which unlike
sqrt(var_i) * sqrt(var_j) keeps copies of a row of integers exactly
zero apart. The diagonal is now exactly zero, or nan where the
correlation is undefined.

This needs Dask 2024.12 or later: the blockwise fusion in earlier
releases computed wrong correlations from these expressions.
@jeromekelleher
jeromekelleher merged commit e18c206 into sgkit-dev:main Oct 29, 2026
20 of 21 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.

Dask 2024.8.1 and later is very slow

2 participants