Decomposition performance - CyrilB1531/lodestar GitHub Wiki

Development build. This page describes main, not a released package. The latest published Lodestar.Decomposition is 0.3.0 — read its documentation.

Home › Decomposition

Performance — Lodestar.Decomposition

What Lodestar.Decomposition costs against the library a reader would otherwise reach for. How to read a row, and what this page leaves out: docs/guides/performance.md.

Truncated SVD and NMF against ML.NET 5.0.0's ProjectToPrincipalComponents

Machine: AMD Ryzen 7 8700G w/ Radeon 780M Graphics, 1 CPU, 16 logical and 8 physical cores (BenchmarkDotNet's own header), Ubuntu 26.04.1 LTS, .NET SDK 10.0.401, .NET 10.0.12 runtime, AVX-512. Window: BenchmarkDotNet 0.14.0, default job, 2026-09-14, one run per class. The class and its agreement check: bench/README.md section 15, and the decomposition class in section 16.

A 2,000 × 500 term-document matrix at 2% density, rank 20:

Row Mean Ratio
TruncatedSvd, over the sparse matrix 17.58 ms 1.00
Nmf, capped at 50 iterations 118.72 ms 6.76
ML.NET's centred PCA, over the dense twin 14.85 ms 0.85

ML.NET's PCA is 1.18× faster, and it is a different decomposition: centred and dense against uncentred and sparse. Section 16 of bench/README.md says what is checked instead of agreement, and decision 0004 why the gap that matters is the explained variance, which ML.NET does not report.

The variance principal components explain, against NumFlat (issue #701)

Full method and what the pair does and does not compare: bench/README.md.

Machine: AMD Ryzen 7 8700G w/ Radeon 780M Graphics, 1 CPU, 16 logical and 8 physical cores (BenchmarkDotNet's own header), Ubuntu 26.04.1 LTS, .NET SDK 10.0.401, .NET 10.0.12 runtime, AVX-512. Window: one BenchmarkDotNet run, default job, on 2026-09-13, 8 benchmarks. NumFlat 1.3.4. Both sides were checked to return the same spectrum before either was timed.

Shape PrincipalComponentVariance NumFlat PrincipalComponentAnalysis NumFlat / Lodestar Allocated, Lodestar Allocated, NumFlat
200 × 10 14.74 μs 15.35 μs 1.04 2.38 KB 1.94 KB
2,000 × 10 82.53 μs 111.83 μs 1.36 2.38 KB 1.94 KB
2,000 × 50 1,977.65 μs 1,634.81 μs 0.83 42.07 KB 40.06 KB
100 × 200 7,834.02 μs 9,306.55 μs 1.19 318.27 KB 628.54 KB

This package is faster on three shapes of four and slower on one, while NumFlat's row also computes the eigenvectors and the mean. The one it loses is the one where the eigen solve dominates: 50 × 50 is where a one-sided Jacobi, several sweeps of O(p³), falls behind a tridiagonal solver. At 10 columns the Gram matrix is most of the work, and centring each row into a buffer of one row's width keeps it in cache.

The wide block is solved through the 100 × 100 Gram matrix of its rows rather than the 200 × 200 one of its columns, which is where the 1.19 and half the allocation come from.

The path to these numbers is in decision 0003: a Jacobi solve over the whole centred block measured 13× slower than NumFlat at 2,000 × 50 before the Gram route replaced it. Below net8.0 the second row is Meta.Numerics: NumFlat does not install there and ML.NET reports no eigenvalue, and its section has the numbers.

The factorization applied to unseen rows (issue #1124)

Full method and why both losses are run: bench/README.md. Machine: the AMD Ryzen 7 8700G named above, on 2026-09-23. BenchmarkDotNet 0.14.0, default job; 2,000 × 500 at 2% density for the fit, 200 unseen rows for the transform.

There is no .NET incumbent: neither ML.NET nor NumFlat publishes a non-negative factorization, so the row that matters is what a batch of unseen rows costs against the fit it is scored on.

operation cost against the fit
TruncatedSvd.Fit, rank 20 11.527 ms —
Nmf.Fit, rank 20, 50 updates 66.647 ms 1.00
Nmf.Transform, 200 unseen rows 2.102 ms 0.032

A batch of unseen rows costs about a thirtieth of the fit, which is the number a caller sizing a scoring loop needs. It iterates — it is a factorization with H held fixed, not a projection — so it is not free the way TruncatedSvd.Transform's multiply is; it is also not another fit.

Against scikit-learn 1.9.1 through compare-nmf-transform, one run of each side, milliseconds per operation, best of five, solver='mu' and tol=0.0 pinned so both compute the same thing:

unseen rows loss Lodestar scikit-learn, wall / cpu ratio, wall ratio, cpu
100 Frobenius 1.264 ms 0.345 / 0.345 ms 0.27 0.27
500 Frobenius 4.054 ms 0.705 / 0.705 ms 0.17 0.17
2,000 Frobenius 14.708 ms 2.567 / 40.553 ms 0.17 2.75
100 Kullback-Leibler 0.732 ms 7.400 / 7.400 ms 10.11 10.11
500 Kullback-Leibler 3.299 ms 16.913 / 16.912 ms 5.13 5.13
2,000 Kullback-Leibler 13.015 ms 72.064 / 1,007.735 ms 5.54 77.22

The two losses are two different computations, and the table is two different answers. The Frobenius update is three dense products; scikit-learn hands them to a parallel BLAS, which is why it is 5.9× ahead on elapsed time at 2,000 rows and 2.75× behind on processor time — it spends 40.6 ms of CPU to finish in 2.6 ms. This runs on one thread and spends 14.7 ms of CPU to finish in 14.7 ms.

The Kullback-Leibler update is where the sparse representation pays. The ratio X / WH is needed only where X is stored, which is 2% of the cells here; scikit-learn densifies W H and pays for all of them. That is 5.5× on elapsed time and 77× on processor time at 2,000 rows — it burns a full second of CPU for 72 ms of wall clock.

A caller choosing between the two losses on a sparse corpus should know that the one this package is faster at is also the one a term-document matrix wants: Kullback-Leibler fits a Poisson noise model, which is what counts are.