Skip to content

GPUs, One or Many ​

Download notebookThe same code, ready to run in Jupyter on a machine with an NVIDIA GPU.

With CUDA.jl loaded, PATHPACK.jl computes the star on NVIDIA GPUs. The CPU threads factorize the many small parts of the graph while the GPU factorizes the large ones; then the GPU computes every row of the result. With several GPUs, each one computes a block of rows. This page shows the calls. The code here is not run when the site is built, since the build machine has no GPU.

0.139 sall 15.9 billion distances of the US road network (126,146 intersections), one NVIDIA B200
0.389 sall 189.8 billion distances of Colorado's roads (435,666 intersections), eight B200s
3.1×faster than ROME, geometric mean over ROME's own 16 test graphs; faster on all 16

Installation ​

Add CUDA.jl next to PATHPACK.jl:

pkg> add https://github.com/AlgebraicJulia/PATHPACK.jl
pkg> add CUDA

Loading both loads the GPU extension, and PATHPACK.GPU then has methods:

julia
using PATHPACK, CUDA
using PATHPACK.CPU: MinPlus, MaxMin, PlusProd, mstar
using PATHPACK.GPU: apsp_gpu, apsp_gpu!, apsp_plan

Start Julia with several threads (julia --threads auto): the CPU part of the factorization runs on them.

A First Call ​

apsp_gpu(A) takes the same sparse matrix as mstar, with A[i, j] the weight of the arc i → j, and returns the star in the min-plus semiring: all shortest-path distances.

julia
using SparseArrays, Random

Random.seed!(1)
n = 5000
I = rand(1:n, 2n); J = rand(1:n, 2n); keep = I .!= J
W = Float32.(rand(1:100, count(keep)))     # integers: exact in Float32
A = sparse([I[keep]; J[keep]], [J[keep]; I[keep]], [W; W], n, n, min)

D = apsp_gpu(A)                     # a CuMatrix{Float32}, on the GPU
Array(D) == mstar(MinPlus(), A)     # true: the CPU star, bit for bit

Where the Result Goes ​

The result has entries, so where it lands matters as much as how fast it is computed.

CallResult
apsp_gpu(A)a CuMatrix on the GPU, the default (output = :device)
apsp_gpu(A; output = :host)a Matrix in CPU memory
apsp_gpu!(D, A)into a CuMatrix you allocated, on the current device
apsp_gpu!(H, A)into a Matrix you allocated

Keep the result on the GPU when the next step runs there too: an eigendecomposition for Isomap, a reduction for closeness, or a transfer to PyTorch. Copying numbers to the host takes time of its own, and so does touching the pages of a fresh host matrix. When you call the solver many times, allocate the host matrix once and reuse it with apsp_gpu!(H, A).

Only Some Rows ​

apsp_gpu(A, sources) returns only the rows of the given sources, a length(sources) × n matrix. Use it when only some sources matter, or when the whole star does not fit: call it for blocks of sources and process each block in turn.

julia
for block in Iterators.partition(1:n, 10_000)
    R = apsp_gpu(A, collect(block))    # R[t, j]: from block[t] to j

... reduce R, or write it to disk, before the next block

julia
end

Several GPUs ​

Pass several devices and the rows are split over them. Each GPU holds only its own block, so the star can be larger than one GPU's memory.

julia
gpus = collect(CUDA.devices())

H = apsp_gpu(A; devices = gpus, output = :host)    # one Matrix on the CPU
blocks = apsp_gpu(A; devices = gpus)                # one block per GPU

With output = :device the result is a vector of pairs (sources_g, D_g), and block g stays on GPU g: D_g[t, j] is the distance from sources_g[t] to j. The work is balanced between the GPUs: over our multi-GPU runs, the slowest GPU took 2.2% longer than the mean in the median case, and 13.3% longer at most.

Every pair of intersections of eight road networks, measured on NVIDIA B200s with the GPU counts shown:

seconds for every pair billions of distances in brackets
Luxembourg (13.1), 1 GPU0.093 sUnited States (15.9), 1 GPU0.139 sNew York City (69.9), 2 GPUs0.298 sFrance (88.3), 8 GPUs0.343 sBay Area (103.2), 5 GPUs0.293 sBrazil (104.6), 8 GPUs0.392 sJapan (125.1), 8 GPUs0.464 sColorado (189.8), 8 GPUs0.389 s
Networks too large for one GPU's memory split their rows over several GPUs.

New Weights, Same Graph ​

Many applications solve one graph again and again with different weights: travel times at different hours, resistances under different scenarios, diffusion with different parameters. A plan does once all the work that depends only on the graph's structure (the ordering, the symbolic factorization, the schedules) and keeps it on the GPU.

julia
P = apsp_plan(A)               # once per graph
for hour in 1:24
    Aₕ = travel_times(hour)    # the pattern of A, new weights
    D = apsp_gpu(P, Aₕ)        # only the numeric work is repeated

...

julia
end

A matrix with a different pattern is an ArgumentError. With a host matrix, apsp_gpu!(H, P, Aₕ) writes into it.

Semirings, Types, Graphs ​

Pass another semiring with the semiring keyword, as in apsp_gpu(A; semiring = MaxMin()).

SemiringProblemElement types on the GPU
MinPlus (default)shortest pathsFloat32, Float64
MaxPluslongest pathsFloat32, Float64
MaxMinwidest pathsFloat32, Float64, Int32
MinMaxbottleneck pathsFloat32, Float64
PlusProdsums over walksFloat64

Integer weights are not accepted for min-plus, since the integer infinity overflows. Float32 represents integers exactly only up to ; when the distances could exceed that, the solver warns once, and Float64 weights give exact integer distances.

The GPU solver handles undirected graphs, and directed graphs whose strongly connected components do not reach one another (for example, a strongly connected graph). For any other directed graph it throws an ArgumentError; use the CPU solver, mstar, which handles every graph.

Memory ​

The star of a graph with vertices has entries: bytes in Float32 and in Float64.

VerticesEntriesFloat32Float64
10,000100 million0.4 GB0.8 GB
50,0002.5 billion10 GB20 GB
126,14615.9 billion64 GB127 GB
435,666189.8 billion759 GB1.5 TB

A result that does not fit in GPU memory is an error that says so before any work starts. Then split the rows over several GPUs, or compute blocks of rows with apsp_gpu(A, sources).

Measured ​

Wall-clock time from the graph in memory to the full answer in memory, on NVIDIA B200 GPUs, all pairs. Every code returns the same distances.

One GPU, against other GPU codes on the same GPU:

GraphVerticesPATHPACK.jlROMEcuGraphFast-APSP
US road network126,1460.139 s0.750 s18.05 s105.5 s
Stanford bunny mesh34,83455.6 ms0.109 s1.70 s
164k-vertex cortex164k0.90 s9.46 s
land-cover grid67,4110.102 s0.353 s10.13 s
swiss roll kNN graph10,00022.1 ms25.0 ms285.7 ms

Over ROME's own 16 test graphs PATHPACK.jl is faster on all 16, by 3.1× in geometric mean. cuGraph is 6 to 1,600 times slower across our graphs.

Eight GPUs, every pair of vertices:

GraphVerticesDistancesTime
France roads297,14488.3 billion0.343 s
Brazil roads323,474104.6 billion0.392 s
Japan roads353,713125.1 billion0.464 s
Colorado roads435,666189.8 billion0.389 s
Stanford dragon mesh436,846190.8 billion0.699 s

More results, including other semirings and CPU comparisons, are on the Benchmarks page.

Released under the MIT License.