Performance: Semi-discrete OT
Geogram ( Bruno Lévy's exploragram, OptimalTransportMap2d / 3d ) solves the same problem with the damped Newton method of Kitagawa, Mérigot and Thibert ( KMT ). sdot is a damped Newton method too, but not KMT. In short:
- a log phase: the first iterations write the Newton system and the merit of the step on
log( mass / target ), so a nearly empty cell asks for a fraction of its gap only ( half the diagrams on the hard 2D cases ); oncemax |mass - target| / target <= 2, the solver switches to the plain difference, the true Newton, which converges quadratically; - the step is not halved from 1 at each iteration: the trials restart from the last accepted step, and the step comes from the limits at which the cells along the direction would be crushed;
- continuation: when cells start empty, the target density is first smoothed, then sharpened stage by stage (
continuation); the Lebesgue cases below do not need it; - seeds too close for the weights to separate are merged, then split again.
Here it gets the same clouds, the same box, the same stopping criterion and the same cores:
Cycles per solve: the time times the clock, the median of the clocks measured on the machine at that thread count ( the cores' on the CPU, the SMs' on a card ): one run's sample is too noisy. Log-log; hover a point for its time and clock.
On all the cores, sdot solves 1e5 uniform diracs in 0.73 s in 2D and 1.0 s in 3D; its best times are on one socket, 64 threads: 0.52 s and 0.75 s ( see below ). On the clustered 2D cloud, geogram does not reach the tolerance ( see below ). An H100 solves the 2D case in 67 ms, a 2080 Ti in 0.11 s; the 3D case in 0.11 s and 0.36 s. The image and gaussian targets of the tables include a width continuation ( 16 to 22 smoothing stages ), which explains why they cost more than the Lebesgue cases.
How geogram was run:
- geogram's own Newton loop (
optimize_full_Newton) was kept as it is, with one change: it stops on sdot'smax |mass - target| / target <= 1e-6instead of its own criterion, a relative RMS error of 1 %. Its default use, a multilevel L-BFGS to 1 %, solves an easier problem and is not timed here. - Its linear solver is the default one: a conjugate gradient with a Jacobi preconditioner, tolerance 1e-3, at most 1000 iterations. It reaches the cap at every step: in 2D the linear solves take most of the time, in 3D the diagrams do. A tighter tolerance does not change the time or the number of steps.
- The box is integrated as a mesh of simplices. Its cells match sdot's to 1e-10.
- On the 2D clustered cloud, geogram's step control stalls: it reaches the tolerance in 2 runs of 9 ( one per thread count ), and after 100 steps the residual of the others is between 4e-6 and 5e-3. Lévy's multilevel scheme, run with a Newton solver, stops after the first level: interpolating the weights to the next level leaves cells empty, and exploragram's Newton stops on an empty cell. The
equalclouds have coincident seeds, which geogram does not merge.
Scaling with the threads
A Newton solve alternates diagrams and linear solves. In 3D the diagrams take most of the time on one thread, and the solve gains 15× on 64 threads ( 25× on the clustered cloud ); what is left is in the linear solves and the tree. In 2D the cells are cheap and the linear solves take a larger share: on several threads they go through a multigrid, which is limited by the memory bandwidth, and the solve gains about 6× ( on one thread, a sparse Cholesky is used instead ). Past 64 threads the second socket adds memory traffic between the sockets and the solve gets slower again; on this node the time of the linear solves also varies by about 2× from one run to the next, whatever the number of threads. geogram gains about 2.4× in 3D and nothing in 2D.
Scaling with the number of diracs
Time per dirac, a whole solve: flat is linear in the number of diracs. Log-log; hover a point for its time.
The number of Newton iterations barely depends on n. The total cost therefore follows the cost of one diagram plus one linear solve, which are both close to linear in n. On the CPU, 1e7 diracs take 183 s in 2D and 89 s in 3D on 128 cores; in 2D the linear solves are two thirds of it ( a uniform cloud of 1e7 seeds has close pairs, solved as groups, Iterative( duplicates = "split" ) ). A solve of 1e7 diracs takes 2.4 s in 2D and 16 s in 3D on an H100 ( an eager call, timed by its kernels: its wall time also counts python's loop ). It does not fit on a 2080 Ti ( 11 GB ).
geogram solves 1e7 diracs in 3D in 1674 s ( 6 steps ). In 2D its number of steps grows with n ( 7 at 1e5, 26 at 1e6 ), as its conjugate gradient stops at the cap before solving the system; the 1e7 solve was stopped after 5 hours without having converged, so the chart has no point there.
At 1e7 diracs in 3D, the float kernel can no longer resolve the cells ( each cell is about 4.6e-3 wide in a unit box ), so the solve switches to the double kernel. On cards with slow FP64 this switch costs half of the total time. GPU memory is about 900 bytes per dirac in 2D and 1300 in 3D at 1e6. A solve that does not fit raises a MemoryError before it starts.
Scaling with the dimension
Time, n = 1e4, all the cores: semi-log, the dimension along a linear axis. Hover a point for its time.
Newton is not affected by the dimension: the iteration count and the cost of the linear solves stay the same up to 6D ( the table ). The whole cost is in the cells. A Laguerre cell of a uniform cloud has a number of vertices that grows very fast with d ( about 6 in 2D, 25 in 3D, 120 in 4D, 670 in 5D, 3300 in 6D ), and the cell is cut, measured and triangulated vertex by vertex. At 1e4 diracs, a diagram costs about 6 times more in 3D than in 2D, then each added dimension multiplies it by 12 ( 4D ), 25 ( 5D ) and 45 ( 6D ).
In practice, 4D is comfortable up to a few 10⁵ diracs and 5D up to about 10⁵. 6D is limited to about 10⁴ diracs. Beyond that, the cost comes from the geometry of the cells, not from the solver.
The tables
Newton
| CPU | GPU | speed-up | ||
|---|---|---|---|---|
| 128 threads | iterations / diagrams | RTX 2080 Ti / H100 | RTX 2080 Ti / H100 | |
| 2D uniform, 1e5 | 0.734 s | 6 / 7 | 0.113 s / 0.0669 s | 6.5× / 11× |
| 2D uniform, 1e6 | – | – | 0.705 s / 0.215 s | – / – |
| 2D clustered ( 5 lines ), 1e5 | 1.99 s | 12 / 19 | 0.365 s / 0.234 s | 5.4× / 8.5× |
| 2D 512² image, 2e4 | 7.25 s | 95 / 117 | 0.906 s / 0.751 s | 8.0× / 9.7× |
| 2D 4 gaussians ( σ = 0.02 ), 1e5 | 20.7 s | 133 / 208 | 7.37 s / 2.93 s | 2.8× / 7.1× |
| 3D uniform, 1e5 | 1.04 s | 5 / 6 | 0.356 s / 0.111 s | 2.9× / 9.4× |
| 3D uniform, 1e6 | – | – | 3.59 s / 0.799 s | – / – |
| 3D clustered ( 4 planes ), 1e5 | 2.10 s | 9 / 17 | 1.10 s / 0.343 s | 1.9× / 6.1× |
A whole solve, cold start, tree included, `max |mass - target| / target <= 1e-6`. CPU: all the physical cores, double. GPU: jitted, `mixed`; speed-up: against the CPU.
| total | per dirac | |
|---|---|---|
| RTX 2080 Ti / H100 | RTX 2080 Ti / H100 | |
| 2D, 1e5 | 0.113 s / 0.0669 s | 1.1 µs / 0.67 µs |
| 2D, 1e6 | 0.705 s / 0.215 s | 0.71 µs / 0.22 µs |
| 2D, 1e7 | – / 2.45 s | – / 0.24 µs |
| 3D, 1e5 | 0.356 s / 0.111 s | 3.6 µs / 1.1 µs |
| 3D, 1e6 | 3.59 s / 0.799 s | 3.6 µs / 0.80 µs |
| 3D, 1e7 | – / 16.4 s | – / 1.6 µs |
A whole solve on the card, `mixed`, uniform diracs, Lebesgue. 1e7 ( `--full` ): an eager call, timed by its kernels.
sdot and geogram
| sdot | geogram | |||||
|---|---|---|---|---|---|---|
| 1 / 32 / 128 threads | RTX 2080 Ti / H100 | iterations / diagrams | 1 / 32 / 128 threads | iterations / diagrams | linear solves | |
| 2D uniform | 3.30 s / 0.530 s / 0.734 s | 0.113 s / 0.0669 s | 6 / 7 | 11.0 s / 12.1 s / 15.0 s | 6 / 8 | 13.7 s |
| 2D clustered ( 5 lines ) | 9.20 s / 1.50 s / 1.99 s | 0.365 s / 0.234 s | 12 / 19 | no conv. ( 271 s ) / 252 s / no conv. ( 298 s ) | 100 / 719 | 215 s |
| 3D uniform | 11.2 s / 0.863 s / 1.04 s | 0.356 s / 0.111 s | 5 / 6 | 15.7 s / 6.90 s / 9.60 s | 5 / 8 | 3.00 s |
| 3D clustered ( 4 planes ) | 39.9 s / 2.10 s / 2.10 s | 1.10 s / 0.343 s | 9 / 17 | 50.2 s / 18.4 s / 33.3 s | 13 / 27 | 12.1 s |
A whole semi-discrete solve, Lebesgue on the unit box, n = 1e5, cold start ( zero weights ), double ( GPU: jitted, `mixed` ), stopped on `max |mass - target| / target <= 1e-6` ( geogram's residual measured again by sdot ). Shaded: the fastest on the CPU, and on the GPU.
Dimension
| total | iterations / diagrams | one diagram, per dirac | |
|---|---|---|---|
| 2D | 0.214 s | 5 / 6 | 0.18 µs |
| 3D | 0.292 s | 5 / 6 | 1.1 µs |
| 4D | 1.04 s | 5 / 6 | 13 µs |
| 5D | 20.6 s | 5 / 6 | 336 µs |
| 6D | 754 s | 4 / 5 | 15 ms |
Lebesgue on the unit hypercube, n = 1e4, cold start, all the physical cores, double, one run. 6D: `--full`.
The machines and the protocol ( cold starts, repetitions, clocks ) are on the overview.