Skip to content

Compute pairwise_distance with matrix multiplications - #1365

Draft
Billyzhang1229 wants to merge 1 commit into
sgkit-dev:mainfrom
Billyzhang1229:pairwise-distance
Draft

Billyzhang1229 wants to merge 1 commit into
sgkit-dev:mainfrom
Billyzhang1229:pairwise-distance

Conversation

@Billyzhang1229

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 builds on #1364, which removes the Dask pin. Its two commits are included here, so only the last commit is new.

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 the floating point type of the input, or float64 for other types, and for integer input the reported dtype is now float64 before the result is computed.
  • The diagonal is exactly zero, or nan where the correlation is undefined.
  • It needs Dask 2024.12 or later, because the blockwise fusion in earlier releases computed wrong correlations from these expressions. Relax Dask, Sphinx and stale dependency pins #1364 already requires Dask 2025.2.0.

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.
  • The GPU workflow only runs when dispatched manually (GPU build is failing #1270), so it hasn't run on this branch. 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], the package the workflow installs. It gave the same results as the current code on all three, but it would still be good to dispatch the workflow to run the GPU tests on its T4.

Benchmarks

pairwise_distance before and after this PR, on an Intel i9-12900K (16 threads under WSL2) and an NVIDIA RTX 5090. The times before don't include the recompilation from #1267, so the speedups come from the new computation alone:

pairwise_distance runtime before and after this PR by input shape, for euclidean and correlation, on the CPU in float64 and on the GPU in float32 and float64 speedup-light

The speedup grows with the input. On the CPU at 4k × 40k, euclidean goes from 75 s to 9.3 s (8.0×) and correlation from 311 s to 17 s (18×). On the GPU at 8k × 80k, float32 is 21× and 16× faster. For the smallest input, whose 16 chunks are 125 × 1250, overhead dominates: euclidean on the CPU is only 1.2× faster, and correlation on the GPU in float32 is slower (112 ms before, 174 ms after), with 1101 tasks in the new graph instead of 241.

In float64 the RTX 5090 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. To check that this comes from the GPU and not from the code, I ran the GPU benchmarks on an A100 and an H100 on Modal, whose float64 matrix products are as fast as their float32 ones (18.6 and 58.5 TFLOPS). There, float64 at 8k × 80k is 23× and 15× faster on the A100, and 31× and 28× faster on the H100:

gpus-light

The CPU times are with default settings, where Dask's threaded scheduler runs a task on every core and OpenBLAS also uses every core for each matrix product. With OPENBLAS_NUM_THREADS=1, which Dask's distributed workers set by default, the new code is faster again and varies much less between runs: 4k × 40k takes 5.0 s (15×) and 9.6 s (33×). The old code doesn't use BLAS, and its times barely change (within 3% at 2k × 20k).

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 large input in a new process is slower than these medians while CuPy's memory pool grows, for example 1.7 s instead of 0.71 s for euclidean in float32 at 8k × 80k.
  • Before: the current implementation, with Numba gufuncs on the CPU and Numba CUDA kernels on the GPU. With Dask 2024.8.1 or later, which Relax Dask, Sphinx and stale dependency pins #1364 allows, it also recompiles its gufuncs on every call ( Dask 2024.8.1 and later is very slow #1267), which adds about 0.45 s (euclidean) and 3.9 s (correlation) to each call on the CPU, at every input size. The times here leave that out: they use the current code with its closures replaced by module-level functions, so nothing is recompiled. That is as fast as the current code with Dask 2024.8.0, which main pins (1.11 s and 4.70 s at 1k × 10k).
  • Locally, both versions ran in the same environment: 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: Modal 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], numba-cuda[cu12]), 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. The results of both versions agree there too.

CPU, float64:

M × N euclidean before after speedup correlation before after speedup
500 × 5k 148 ms 121 ms 1.2× 623 ms 216 ms 2.9×
1k × 10k 1.11 s 558 ms 2.0× 4.79 s 1.07 s 4.5×
2k × 20k 9.12 s 1.91 s 4.8× 38.5 s 3.07 s 13×
4k × 40k 74.7 s 9.29 s 8.0× 311 s 17.2 s 18×

CPU, float64, with OPENBLAS_NUM_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 148 ms 121 ms (1.2×) 71 ms (2.1×) 623 ms 216 ms (2.9×) 136 ms (4.6×)
1k × 10k 1.11 s 558 ms (2.0×) 134 ms (8.3×) 4.79 s 1.07 s (4.5×) 238 ms (20×)
2k × 20k 9.12 s 1.91 s (4.8×) 672 ms (14×) 38.5 s 3.07 s (13×) 1.25 s (31×)
4k × 40k 74.7 s 9.29 s (8.0×) 4.99 s (15×) 311 s 17.2 s (18×) 9.58 s (33×)

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

input dtype euclidean before after speedup correlation before after speedup
float64 9.12 s 1.91 s 4.8× 38.5 s 3.07 s 13×
float32 10.4 s 1.36 s 7.6× 37.1 s 1.95 s 19×
int8 8.64 s 1.82 s 4.8× 32.8 s 3.10 s 11×

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):

float32, euclidean:

M × N RTX 5090 A100 H100
500 × 5k 106 ms → 96 ms (1.1×) 173 ms → 144 ms (1.2×) 160 ms → 107 ms (1.5×)
1k × 10k 164 ms → 98 ms (1.7×) 212 ms → 138 ms (1.5×) 246 ms → 117 ms (2.1×)
2k × 20k 349 ms → 107 ms (3.3×) 608 ms → 150 ms (4.1×) 536 ms → 122 ms (4.4×)
4k × 40k 1.62 s → 151 ms (11×) 3.39 s → 288 ms (12×) 2.69 s → 174 ms (15×)
8k × 80k 15.0 s → 709 ms (21×) 38.8 s → 1.54 s (25×) 24.6 s → 754 ms (33×)

float32, correlation:

M × N RTX 5090 A100 H100
500 × 5k 112 ms → 174 ms (0.6×) 187 ms → 235 ms (0.8×) 145 ms → 197 ms (0.7×)
1k × 10k 220 ms → 164 ms (1.3×) 286 ms → 233 ms (1.2×) 265 ms → 186 ms (1.4×)
2k × 20k 500 ms → 170 ms (2.9×) 678 ms → 234 ms (2.9×) 581 ms → 195 ms (3.0×)
4k × 40k 2.15 s → 228 ms (9.4×) 4.18 s → 449 ms (9.3×) 3.26 s → 250 ms (13×)
8k × 80k 17.2 s → 1.09 s (16×) 41.1 s → 2.73 s (15×) 28.2 s → 1.16 s (24×)

float64, euclidean:

M × N RTX 5090 A100 H100
500 × 5k 164 ms → 98 ms (1.7×) 189 ms → 133 ms (1.4×) 142 ms → 106 ms (1.3×)
1k × 10k 301 ms → 103 ms (2.9×) 289 ms → 145 ms (2.0×) 266 ms → 108 ms (2.5×)
2k × 20k 983 ms → 303 ms (3.2×) 939 ms → 157 ms (6.0×) 775 ms → 128 ms (6.0×)
4k × 40k 4.73 s → 1.85 s (2.6×) 6.16 s → 334 ms (18×) 6.75 s → 282 ms (24×)
8k × 80k 32.6 s → 14.7 s (2.2×) 43.0 s → 1.83 s (23×) 29.8 s → 971 ms (31×)

float64, correlation:

M × N RTX 5090 A100 H100
500 × 5k 230 ms → 159 ms (1.4×) 189 ms → 227 ms (0.8×) 180 ms → 186 ms (1.0×)
1k × 10k 509 ms → 164 ms (3.1×) 342 ms → 245 ms (1.4×) 548 ms → 180 ms (3.0×)
2k × 20k 1.71 s → 551 ms (3.1×) 1.23 s → 229 ms (5.4×) 1.03 s → 186 ms (5.5×)
4k × 40k 9.13 s → 3.51 s (2.6×) 7.39 s → 496 ms (15×) 7.64 s → 312 ms (24×)
8k × 80k 62.4 s → 26.3 s (2.4×) 46.5 s → 3.01 s (15×) 36.1 s → 1.30 s (28×)

@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 !

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 the floating point type of the input, or
float64 for other types, and integer input now reports the float64
dtype of the result before it is computed. 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.
@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.

This branch has not been deployed

No deployments
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