← All notes

Efficient sampling ·

Faster von Mises–Fisher sampling with one reflection

Replacing a dense QR rotation with a single Householder reflection makes high-dimensional sampling dramatically cheaper.

Sampling from a von Mises–Fisher distribution has two parts: draw around the first coordinate axis, then map that axis onto the requested mean direction. The second step looks harmless, but constructing and applying a dense rotation becomes the dominant cost in high dimension.

What changed

The original SciPy implementation does two expensive things in its rotation path: it builds a dense orthogonal matrix with QR, then uses the general-purpose einsum contraction machinery to apply that matrix to every sample.

Original SciPy rotation pathsrc/vmf_scipy.py
embedded = np.concatenate([
    self.mu[None, :],
    np.zeros((self.dim - 1, self.dim), dtype=self.mu.dtype),
])
self.rotmatrix, _ = la.qr(np.transpose(embedded))

samples = np.einsum("ij,...j->...i", self.rotmatrix, samples) * self.rotsign
dense QR constructiongeneral tensor contraction

einsum is flexible, but it is not the best primitive for this repeated dense matrix–vector operation. A direct matrix multiplication expresses the workload more clearly and can dispatch to optimized linear-algebra kernels without the general contraction path.

Change 1 · keep QR

Use the vectorized NumPy path

The NumPy QR control keeps the same dense map, but uses numpy.linalg.qr and batched matmul. A substantial speedup is already available before changing the mathematics.

rotmatrix, _ = np.linalg.qr(embedded.T)
samples = (rotmatrix @ samples.T).T * rotsign

Change 2 · remove QR

Apply one Householder reflection

Because vMF is rotationally symmetric, we can drop the dense rotation entirely and store only the reflection vector u.

u = (e1 - mu) / np.linalg.norm(e1 - mu)
samples -= 2 * np.outer(samples @ u, u)

Why one reflection is enough

Comparison of a QR rotation and a Householder reflection mapping the first basis vector to the mean direction.
Any orthogonal map sending e₁ to μ is valid because vMF is rotationally symmetric around μ.

A single Householder reflection

H = I − 2uuT,   u = (e₁ − μ) / ‖e₁ − μ‖

maps e₁ to μ and preserves the distribution. It replaces a dense d × d matrix with one length-d vector and can be applied to a complete sample batch in place.

Implementations compared

The benchmark separates the array library from the orthogonal map. This gives QR and reflection implementations in both NumPy and PyTorch, alongside the original SciPy behaviour.

Reference baseline

SciPy QR

This path stays close to the original implementation: scipy.linalg.qr, batch rotation with numpy.einsum, and the legacy RandomState stream.

Same geometry, modern NumPy

NumPy QR

The dense QR map is unchanged, but the operations use numpy.linalg.qr, batched matmul, and a per-sampler Generator.

Proposed map

Householder reflection

The NumPy and PyTorch reflection variants remove QR. The in-place implementations reuse the sample output and avoid a second output-sized allocation.

Device-native sampling

PyTorch QR and reflection

The PyTorch paths keep batches row-major, normalize and scale owned tensors in place, and fuse the rank-one update with addmm. Samples stay on the selected CPU or GPU in float16, bfloat16, float32, or float64.

The measurements below use CPU float64 so all implementations can be compared directly. The PyTorch versions are included because practical workloads often need samples directly on an accelerator or in lower precision.

Throughput

Float64 sampling throughput for SciPy, NumPy, and PyTorch implementations across dimension.
Mean throughput over three seeded runs. Dimensions 2 and 3 use dedicated closed-form samplers.
DimensionNumPy QRNumPy reflectionSciPyPyTorch QRPyTorch reflection
310.7 M/s9.71 M/s8.29 M/s13.9 M/s13.6 M/s
162.23 M/s2.20 M/s1.34 M/s1.74 M/s1.76 M/s
128366 k/s371 k/s112 k/s281 k/s293 k/s
102436.5 k/s47.8 k/s2.00 k/s30.2 k/s38.5 k/s
4096549/s12.3 k/s97/s1.15 k/s9.57 k/s

Speedup over SciPy

126.97×maximum measured NumPy speedup
99.01×maximum measured PyTorch speedup
End-to-end speedup of the in-place NumPy and PyTorch reflection implementations over SciPy.
End-to-end throughput ratios against the actual SciPy reference, not against the separate NumPy or PyTorch QR controls.
ImplementationBaselineGeometric meanMaximum measured
NumPy reflection, in-placeSciPy QR6.28×126.97×
PyTorch reflection, in-placeSciPy QR5.01×99.01×

The practical crossover is simple: random variate generation and dispatch dominate at tiny dimensions. As d grows, avoiding QR setup and dense matrix multiplication matters increasingly.

Complexity and random state

AreaReflection pathQR reference
Map e₁ → μOne Householder reflectionDense QR rotation
Map constructionO(d)O(d³)
Rotation storageO(d)O(d²)
Batch applicationO(nd)O(nd²)
NumPy RNGPer-instance PCG64DXSMLegacy RandomState in SciPy
PyTorch RNGPer-device generatorPer-device generator

Benchmark details

  • CPU: AMD EPYC 7742
  • Workload: float64, κ = 50, dimensions 2–4096
  • Measurements: 273 raw timing rows
  • Timing: calibrated batches with a two-second window
  • Repetitions: three seeded runs
  • Allocation: 12 CPUs, one task at a time, no GPU

The implementation, raw measurements, and machine-readable tables are available on GitHub.

Appendix

Generalization to an older CPU

The headline benchmark uses the newer AMD EPYC 7742. To check whether the conclusion generalizes, the same CPU-only float64 sweep was repeated on an older Intel Xeon E5-2690 v2 with the same code, 12 allocated CPUs, and three seeds.

The older processor does not support AVX2, so PyTorch selects its generic CPU kernel path and is substantially slower there. These are hardware-generalization results rather than the primary performance numbers.

CPU-only generalization comparison between AMD EPYC 7742 and Intel Xeon E5-2690 v2.
DimensionAMD reflectionAMD vs SciPyIntel reflectionIntel vs SciPy
128371 k/s3.31×230 k/s2.93×
51294.9 k/s11.85×60.3 k/s8.50×
204824.0 k/s59.86×15.2 k/s41.78×
409612.3 k/s126.97×7.86 k/s86.45×

Full Intel Xeon E5-2690 v2 measurements

Throughput is reported as samples per second. Both speedup columns use the SciPy implementation on the same CPU as their baseline.

DimensionSciPy QRNumPy QRNumPy reflectionNumPy vs SciPyPyTorch QRPyTorch reflectionPyTorch vs SciPy
25.21 M/s5.61 M/s5.60 M/s1.08×1.36 M/s1.56 M/s0.30×
35.27 M/s7.43 M/s6.40 M/s1.21×2.50 M/s2.50 M/s0.47×
42.24 M/s3.30 M/s3.19 M/s1.42×1.26 M/s1.27 M/s0.57×
81.57 M/s2.33 M/s2.26 M/s1.44×656 k/s657 k/s0.42×
16915 k/s1.45 M/s1.41 M/s1.54×328 k/s330 k/s0.36×
32460 k/s864 k/s840 k/s1.82×165 k/s166 k/s0.36×
64206 k/s442 k/s457 k/s2.21×82.5 k/s83.5 k/s0.40×
12878.4 k/s218 k/s230 k/s2.93×40.9 k/s41.8 k/s0.53×
25623.5 k/s100 k/s120 k/s5.08×19.9 k/s20.8 k/s0.89×
5127.09 k/s39.7 k/s60.3 k/s8.50×9.50 k/s10.4 k/s1.47×
10241.88 k/s14.0 k/s30.2 k/s16.02×4.26 k/s5.21 k/s2.77×
2048363/s3.85 k/s15.2 k/s41.78×1.67 k/s2.57 k/s7.08×
409691/s712/s7.86 k/s86.45×526/s1.26 k/s13.86×