|
cppqed-multidiagonal 0.4.0
Multidiagonal sparse operator algebra for C++QED
|
This page records what the benchmarks show about applying a MultiDiagonal to a state, why the library is currently slower than it could be, and what it can and cannot gain over a general sparse matrix. It is meant as an honest baseline: where the library doesn't have the right loop yet, the figures for "what is possible" come from hand-written loops over the library's data in benchmarks/kernels.cc, not from the library itself.
Short version (reference machine, default build of both libraries):
-O3 -march=native, single thread.performance governor). Each run is pinned to a core that handles no device interrupts.nonZeros() of the equivalent Eigen matrix. "In L2" means that operator and state together fit in 256 KB, "in L3" in 8 MB; "out of cache" means beyond that.Earlier figures, measured on a laptop (Intel Core i7-1260P under WSL2, GCC 13.3, Eigen 3.4.0), are in the history of this page. They agree qualitatively, with two differences:
Figures marked "laptop" below come from there. The Eigen version matters less than the processor: on the reference machine, Eigen 3.4.0 and 5.0.1 differ by 0.8–1.2× in Eigen's own speed, and leave the library unchanged.
To reproduce (pin the runs to one core, e.g. with taskset -c 1):
kernels.cc cases:
| case | what is applied |
|---|---|
| A | rank 1, five diagonals (offsets −2…+2), N = 256 (in L2), 4096 (in L3), 262144 (out of cache) |
| B | rank-2 Jaynes–Cummings Hamiltonian, mode on axis 0, qubit on axis 1; state 512 (in L2), 8192 (in L3), 524288 (out of cache) |
| C | a five-diagonal operator of dimension n = 32 on one axis of an n⁴ state (10⁶ amplitudes), applied to the whole state at once |
| D | the same operator applied slice by slice, as C++QED's Broadcaster does; n = 32 and n = 30 |
| E | tridiagonal ⊗ tridiagonal on extents {n₀, 8192/n₀}, for an axis 0 of n₀ = 1 … 32 elements |
By default both GCC and Clang implement std::complex<double> multiplication according to C99 Annex G: every product checks for NaN and falls back to a call to __muldc3. The check stops the loop from vectorizing. Up to 2026-11-01 it more than halved the library's speed, and -fcx-limited-range was needed to remove it.
Since 2026-11-02 (#23) the kernel multiplies with textbookProduct, i.e. \((ac-bd)+i(ad+bc)\) written out in real arithmetic. It omits only the recovery of infinite results, which a physical state never needs. The speed no longer depends on the flag:
| case A, in cache (N = 256 / 4096) | default | -fcx-limited-range |
|---|---|---|
| library before #23 | 1.50 / 1.58 | 0.64 / 0.78 |
| library now | 0.65 / 0.83 | 0.65 / 0.81 |
| Eigen sparse | 1.80 / 1.95 | 1.62 / 1.77 |
With the flag, before and after agree; in the default build, the library is now 1.9–2.3× faster than before. The flag also speeds up Eigen:
Eigen's sparse product gathers through column indices, so it doesn't vectorize, but the check still costs it. The hand-written loops in kernels.cc use textbookProduct too, so the flag now changes only Eigen's figures.
The other loops that multiply complex numbers were checked the same way (laptop). Composition, addition and construction (compose1, composeJC, construct1, constructJC) are dominated by index arithmetic, allocation and the map. The flag changes them by nothing beyond the noise, so they keep the standard multiplication.
All further figures are from the default build. With the flag, Eigen's would be 10–17% lower.
Up to v0.4.0, operator() stepped a multi-index through each diagonal, last axis fastest, and computed the address of every element from all its indices. At rank 1 this cost little, but at higher rank it dominated.
Since 2026-11-01 (#1), operator() works as follows:
This is the "hoisted" loop of kernels.cc, generalized to strided views:
| v0.4.0 | before #23 | now | hand-written hoisted loop | Eigen sparse | |
|---|---|---|---|---|---|
| B: Jaynes–Cummings, in cache (state 512 / 8192) | 6.95 / 7.24 | 1.27 / 1.63 | 0.59 / 0.96 | 0.58 / 0.96 | 1.96 / 2.18 |
| B: Jaynes–Cummings, out of cache (state 524288) | 7.77 | 4.34 | 3.34 | 3.34 | 3.05 |
| C: rank-4 operator built as \(\mathbb{1}\otimes\mathbb{1}\otimes A\otimes\mathbb{1}\), \(A\) on axis 0 / axis 2 (out of cache) | 47.7 / 46.6 | 3.37 / 3.25 | 2.64 / 2.31 | — | 2.58 / 2.61 |
The 2026-10 review had flipped the loop order alone and saw no effect beyond noise. That is consistent: memory order only pays once the per-element address computation is gone.
cppqed-multidiagonal-benchmarks applies the Jaynes–Cummings Hamiltonian in both axis orders, for mode dimensions N from 16 to 1048576 (state 2N), and a five-diagonal rank-1 operator of dimension N. The table gives the time of Eigen sparse divided by the time of MultiDiagonal; above 1 means MultiDiagonal is faster. Up to N = 1024, operator and state fit in L2, and up to N = 16384 in L3. For apply1, N = 65536 (about 7 MB) sits at the edge of L3:
| v0.4.0, N ≤ 16384 | before #23, N ≤ 16384 | now, N ≤ 16384 | now, N = 65536 / 262144 / 1048576 | |
|---|---|---|---|---|
applyJC_modefast: mode on axis 0 | 0.25–0.29 | 1.35–1.58 | 2.14–3.39 | 1.72 / 1.39 / 0.82 |
applyJC_qbitfast: qubit on axis 0 | 0.22–0.28 | 1.04–1.19 | 1.50–2.18 | 1.18 / 0.81 / 0.61 |
apply1: rank 1 | 1.04–1.30 | 1.30–1.75 | 1.92–3.22 | 1.08 / 1.46 / 0.69 |
A spot check with Clang 18 (laptop) gave the same picture, with smaller gains from #23, because Clang's code was faster than GCC's before #23 as well.
With the qubit on axis 0, as in C++QED's jc_qjmc, the loop over axis 0 has one or two elements; the σ± diagonals have extent 1 along the qubit. Setting up a vectorized loop then costs more than the work in it. With #23's multiplication alone, the mode-first case became about twice as fast, but the qubit-first case up to 1.4× slower (laptop).
Since 2026-11-02, operator() therefore dispatches on the length of axis 0 once per diagonal. For up to four elements, the innermost level of the loop nest is unrolled at compile time. The loop over axis 1 then becomes the innermost one, and the compiler vectorizes that instead.
Case E shows where the two shapes cross over (ns per nonzero):
| length of axis 0 | 1 | 2 | 3 | 4 | 5 | 6 | 8 | 16 | 32 |
|---|---|---|---|---|---|---|---|---|---|
| library (unrolled up to 4) | 0.93 | 1.34 | 1.29 | 1.32 | 1.27 | 1.24 | 1.14 | 0.99 | 0.92 |
| hand-written hoisted loop (always loops over axis 0) | 3.72 | 2.98 | 2.16 | 1.80 | 1.38 | 1.31 | 1.18 | 1.03 | 0.94 |
| Eigen sparse | 2.43 | 1.94 | 1.89 | 1.87 | 1.86 | 2.00 | 1.84 | 1.88 | 1.85 |
Up to four elements unrolling wins, by up to 4×; from five on, the loop over axis 0 is as good.
The loop writes dpsidt once per diagonal and element. So its speed depends on where psi lies relative to dpsidt within a 4 KB page (#25). The processor compares only the low 12 address bits of a load with the stores still in flight. For some placements it then holds loads from psi back behind stores to dpsidt that they don't depend on ("4K aliasing").
A standalone probe placed psi at every 16-byte offset of a page relative to dpsidt (rank 1, five diagonals; ns per application):
| loop | N | best | median | worst | offsets more than 5% above the best |
|---|---|---|---|---|---|
library operator(): one diagonal at a time | 1024 | 2976 | 3165 | 3524 | 142 / 256 |
| all diagonals of a row summed in a register | 1024 | 10971 | 10986 | 11004 | 0 / 256 |
library operator() | 4096 | 13507 | 14059 | 16448 | 47 / 256 |
| all diagonals of a row summed in a register | 4096 | 44166 | 44197 | 44320 | 0 / 256 |
psi lies a little below dpsidt, and the default allocation puts the two arrays exactly there.apply1, but 3.4 ns inside the main suite, with the same in-page offsets. The physical pages there come from a long allocation history. Huge pages make at most a 5% difference.This is why every figure on this page is a median over separate processes.
When the operator is as large as the state, all variants stream their data from main memory. Case A at N = 262144 (29 MB):
| ns per nonzero | |
|---|---|
| library | 1.56 |
| hand-written hoisted loop | 1.56 |
| row-blocked loop: all diagonals per block of rows | 1.20 |
Eigen sparse (with -fcx-limited-range) | 2.37 (2.19) |
The memory traffic per nonzero:
| bytes per nonzero | |
|---|---|
| stored diagonal value | 16 |
| CSR value plus column index | 20 |
| one-diagonal-at-a-time sweep: value, plus x read, plus y read and written | 64 |
The one-diagonal-at-a-time sweep is the library's current loop. Blocking the rows, so that all diagonals are applied while a block of y is in cache, brings the traffic down to the value plus a share of x and y. Once both sides are limited by bandwidth, the best possible gain of the format over CSR is therefore about 20%.
On the reference machine a single thread does not reach that limit with Eigen's product. The row-blocked loop moves about as many bytes per nonzero in half the time. So out of cache, the library is 1.5× faster than Eigen in case A, and the row-blocked loop 2×. In case B (state 524288) the library is 1.1× slower. At N = 1048576 in the main suite it is up to 1.45× slower, which is the placement effect above.
With several threads, or a machine with less bandwidth per core, the 20% bound applies. Doing better would require not storing the values at all, e.g. generating √n on the fly, and that is outside the library's scope.
In C++QED, a term of the Hamiltonian is a MultiDiagonal over its own few axes, e.g. one mode with dimension 32. C++QED's Broadcaster applies it to every slice of the full state along the remaining ("dummy") axes. The MultiDiagonal never sees the larger array; that is a design decision of C++QED. In this setting:
The honest competitor for the format is therefore a CSR matrix of the term's own space, applied to the same slices. In the tables this is "term CSR": Eigen with a strided map. A CSR matrix of the whole system, flattened from the Kronecker product as QuTiP does, answers a different question: it compares architectures, not formats.
Case D, n⁴ state, operator on axis 0 (contiguous slices) or axis 2 (stride n²). All values are with the slices taken in memory order, first dummy axis fastest. Eigen's figures with -fcx-limited-range are in brackets:
| n=32, axis 0 | n=30, axis 0 | n=32, axis 2 | n=30, axis 2 | |
|---|---|---|---|---|
library operator() | 2.53 | 0.78 | 5.95 | 1.54 |
| one diagonal at a time, addresses hoisted | 2.54 | 0.80 | 5.94 | 1.58 |
| all diagonals per output row | 2.33 | 2.08 | 3.80 | 2.58 |
| term CSR | 2.81 (2.48) | 2.32 (1.69) | 4.09 (3.69) | 2.64 (2.16) |
| CSR of the whole system | 2.45 (2.26) | 2.46 (2.28) | 2.52 (2.38) | 2.47 (2.32) |
The library's operator() runs the same loop as the hand-written "one diagonal at a time" kernel.
Three findings:
SliceIterator produces the slices last dummy axis fastest, so consecutive slices lie far apart in memory. In that order every method is 1.1–1.4× slower on axis 0 and 1.3–2.3× slower on axis 2. For example, the library takes 7.72 instead of 5.95 for n = 32 on axis 2, and term CSR 6.16 instead of 2.64 for n = 30. Fixing the order belongs to C++QED, not to the library (cppqed#90).The best loop shape depends on the slice length and the stride.
The kernel can choose between the shapes from the view's strides and extents. That is the library's business, since it only concerns operator() on a strided view (#1).
Broadcaster handing a term blocks of adjacent slices. This is noted here, not proposed.Not measured, but relevant: a slice of rank ≥ 2 whose axes are not adjacent in memory, e.g. a coupling on axes 0 and 2 of a rank-3 system, is not uniformly strided. A flattened CSR matrix then has to translate each column index into a memory address, from the multi-index or from a precomputed table, or it has to copy the slice into a contiguous buffer and back. A MultiDiagonal applies per-axis strides directly.
In C++QED's hot loop, the interaction picture is applied by updating the operator with its frequencies and then applying it. No transformation of the state takes place around the application. The physics layer keeps a pair (coefficients \(\alpha\), frequencies \(\delta\)) with the same diagonals, and before each right-hand-side evaluation writes \(\alpha\,e^{\delta\tau}\) into the operator to be applied. The library itself carries no frequencies.
Structural facts:
| spectrum | factor along one diagonal | data needed |
|---|---|---|
| linear in the quanta (Mode, Qbit, Spin, and sums of these) | constant | one scalar per diagonal |
| quadratic (Kerr, particle kinetic energy) | geometric sequence | scale and ratio (a recurrence, refreshed periodically) |
| higher polynomial, or arbitrary levels | — | one frequency per element |
A polynomial spectrum loses one degree along a diagonal.
What picture1 measures (rank 1, operator as large as the state; time relative to applying with no picture at all, N = 256 / 4096 / 262144):
| variant | relative time |
|---|---|
| one scalar per diagonal | 0.96 / 1.00 / 1.02 |
| one exponential per element | 8.9 / 7.8 / 6.2 |
| CSR, one exponential per nonzero | 9.3 / 8.1 / 6.5 |
picture1 also times a transformation of the state around a static product, which C++QED does not use.
Because the operator is as large as the state here, the per-element update looks far more expensive than it would in C++QED's setting. What is missing:
From cppqed-multidiagonal-benchmarks, time of Eigen sparse divided by time of MultiDiagonal (above 1 means MultiDiagonal is faster):
| N = 16 | 1024 | 262144 | |
|---|---|---|---|
compose1: rank-1 product of two tridiagonals | 3.1 | 6.7 | 5.2 |
composeJC: Jaynes–Cummings squared | 0.89 | 1.5 | 1.8 |
construct1: rank-1, five diagonals | 1.6 | 1.7 | 2.4 |
constructJC: Jaynes–Cummings via ⊗, + and scalars | 0.61 | 1.1 | 1.1 |
Composition never meets the symbolic pass and sorting of a sparse product: the offsets of the result are known from the offsets of the factors. In C++QED, however, composition and construction happen when the system is set up, e.g. assembling \(H_\text{eff}=H-\tfrac{i}{2}\sum_k J_k^\dagger J_k\), not in the time-stepping loop. They matter little for run time.
| today | with the remaining fixes | |
|---|---|---|
| rank 1, in cache | 1.9–3.2× faster than Eigen | same |
| several axes, in cache, long axis 0 (mode first) | 2.1–3.4× faster (v0.4.0: 3.4–4× slower) | same |
| several axes, in cache, short axis 0 (qubit first) | 1.5–2.2× faster (v0.4.0: 3.6–4.5× slower) | same |
| operator as large as the state, out of cache | from 1.5× faster to 1.45× slower | 2× faster at rank 1 (case A, row-blocked); 20% at most where bandwidth bound |
| slice by slice, contiguous slices, n = 30 | 2.2–3.0× faster than term CSR | same |
| slice by slice, strided slices, n = 30 | 1.4–1.7× faster than term CSR | same |
| slice by slice, n = 32 (power of two) | parity (contiguous) to 1.6× slower (strided) | parity (strided, row-oriented); contiguous open |
| interaction picture | — | not yet measured systematically |
-fcx-limited-range build.kernels.cc stand in for them.The format is not a large speed-up for every application, and should not be presented as one. Its advantages are:
Next steps, in order:
operator() rewritten with hoisted addresses and the innermost loop over axis 0 (#1).picture1 call the library;In C++QED: produce slices in memory order (cppqed#90), and benchmark the interaction picture end to end (cppqed#91).