Skip to content

Performance

The expensive part is almost never the measure. It is the pass underneath it, and the whole library is arranged so you pay for that once.

Nearly everything here needs the same thing: every point’s distance to every other point, in both spaces, ranked. That is O(N2logN)O(N^2 \log N), and it is the only significant cost in the library.

So analyze() sweeps the pairs once and accumulates everything at the same time — rank statistics, distance moments, Sammon and CCA terms, density radii, triplet inversions. Afterwards each measure is arithmetic on numbers that already exist:

const a = analyze(hd, ld, { localK: [20], densityK: 20, triplets: true });
trustworthiness(a.coRanking, 20); // O(1)
continuity(a.coRanking, 20); // O(1)
stress(a.moments).value; // O(1)
tripletAccuracy(a.structure).value; // O(1)

Eight neighbourhood measures, six distance measures and two structure measures cost one sweep between them.

A naive implementation computes trustworthiness at one kk. Computing the curve over all kk then looks like NN separate computations.

It is not. A pair with ranks ss in the projection and rr in the data contributes to trustworthiness at kk exactly when sk<rs \le k < r, and contributes rkr - k:

penalty(k)  =  (i,j)sijk<rij(rijk)\text{penalty}(k) \;=\; \sum_{\substack{(i,j) \\ s_{ij} \,\le\, k \,<\, r_{ij}}} (r_{ij} - k)

So each pair adds a term that is linear in kk over the contiguous range k[s,r1]k \in [s, r-1] — a range update. Two difference arrays, one for the constant part and one for the coefficient of kk, turn each pair into O(1)O(1) writes; a single prefix sum at the end yields every kk at once. The whole curve costs what one value costs, in O(N)O(N) extra memory.

The co-ranking matrix itself is never materialised. It is N×NN \times N — 400 MB at N=10000N = 10\,000 — while the difference arrays are O(N)O(N): two Float64Arrays of length NN, about 160 KB at N=10000N = 10\,000.

stagecostnotes
analyze / coRankingO(N2logN)O(N^2 \log N)the one expensive thing
any neighbourhood measureO(1)O(1)read-out
any distance measure from the passO(1)O(1)read-out
spearmanRho, nonMetricStressO(N2logN)O(N^2 \log N), O(N2)O(N^2) memorymaterialise every pair
silhouette, dunnIndex, and most label measuresO(N2D)O(N^2 D)
calinskiHarabaszO(ND)O(N D)centroids only
gabrielClassificationErrorO(NlogN)O(N \log N) graph, O(N2D)O(N^2 D) weighting
sncO(N2)O(N^2) time and memorycapped at 6 000 points
nervPassO(N2D)O(N^2 D), plus a bisection per pointthe slowest pass
topologicalH0O(N2D)O(N^2 D)it is an MST
topologicalH1O(N3)O(N^3)capped at 200 points
scagnosticsO(NlogN)O(N \log N)after binning

Four measures will refuse rather than exhaust your memory, each with an option to override deliberately:

  • topologicalH1maxPoints: 200. It enumerates (N3)\binom{N}{3} triangles; at N=500N = 500 that is 20 million. Subsample instead.
  • sncmaxPoints: 6000.
  • spearmanRho, nonMetricStressmaxPairs: 60e6, about 800 MB at N=10000N = 10\,000.

Refusing with a message that names the option is deliberate. Silently sampling, or silently allocating 8 GB, are both worse.

Past roughly 5 000 points, move the pass to workers:

const a = await analyzeAsync(hd, ld, { localK: [20] });
const p = await nervAsync(hd, ld);

The drivers fall back to the synchronous kernel when workers are unavailable, the dataset is small, or one worker was requested, so they are always safe to call.

Splitting rows across workers and summing the results gives exactly the same bits as a single-threaded run, for any worker count. That is a stronger guarantee than “close enough”, and it took work to get.

The reason it holds: a partial pass over a row range is a monoid, and every accumulator is either an integer count — which a double holds exactly and which sums associatively — or a per-row value written to a slot no other row touches. Floating point sums are not associative, so no float is ever accumulated across a worker boundary; the per-row values come back untouched and are summed once, in row order, in the reducer.

What the test suite actually asserts (test/parallel.test.ts): with 4 workers, the co-ranking pass, the fused analyze pass and nervPass each come back deepEqual-identical to the synchronous run — penalty arrays, per-point locals, raw accumulators and the derived scores. Across 2, 3, 5 and 8 workers the check is narrower: a single scalar (trustworthiness at k=20k = 20, nerv) must be exactly equal to the synchronous value. workers: 1 is not a parallel run at all — the planner returns no plan and the driver falls back to the synchronous kernel — so it is not covered by these tests.

The published bundle carries its worker inlined as a string and spawns it from a Blob URL in the browser, or new Worker(src, { eval: true }) in Node. That sidesteps the one thing a bundler cannot do — rewrite a worker URL it could not statically pattern-match — so analyzeAsync works under Vite, webpack and a plain <script> tag alike.

If you import from src/ directly rather than the built package, the worker is a real file behind a runtime-built URL that no bundler can match, and you need to supply a workerFactory. test/browser/worker.browser.test.ts has one to copy.

A recorded profiling observation, noted in src/core/sort.ts, puts sorting at 61–88% of the pass’s runtime for D50D \le 50 — which covers essentially every projection (the low-dimensional side is always 2-D) and most high-dimensional inputs. No profiling harness is committed, so treat that range as the note it is rather than as a reproducible measurement.

So the argsort is a radix sort on the IEEE-754 bit patterns of the squared distances — valid because for non-negative doubles the bit pattern is monotone in the value — with a fixup pass for the rare keys sharing a high word. The source records it as ~2.7× faster than the introsort it replaces; that figure is a note, not a committed benchmark. Both sorts break ties by point index, so they are intended to be interchangeable — but no test compares them directly.

Ranks use squared distances throughout; the square root is only taken where a distance value is actually reported.

One run of pnpm bench on an Intel Core Ultra 9 185H, Node v24.3.0, Windows 11. Each row is coRanking with per-point locals at k{10,25}k \in \{10, 25\}, plus aucLogRnx and the full trustworthinessCurve — single-threaded:

ND (high)time
5005051 ms
1 00050129 ms
2 00050538 ms
4 000502.03 s
8 0005012.16 s
4 00021.28 s
4 0002007.09 s

pnpm bench:parallel, same machine (16 logical cores), same pass at k=10k = 10:

ND (high)sync2 workers4 workers8 workers
2 00050670 ms433 ms323 ms326 ms
4 000503.20 s1.70 s1.02 s763 ms
8 0005013.05 s7.60 s4.52 s2.86 s

So roughly 2× at 2 workers and 4–4.6× at 8, with the win growing with NN and vanishing on small inputs where the pool costs more than it saves.

Treat every number above as an order of magnitude, not a specification. These are single runs on one machine with no warm-up and no repetition: re-running the same benchmark on the same laptop moved the small-NN rows by up to 40% (500 points came back at 51 ms once and 72 ms the next time), while the large-NN rows, where the O(N2)O(N^2) work dominates the noise, repeated to within a few percent. What is stable here is the shape — quadratic growth in NN, linear in DD — not the milliseconds. Run pnpm bench and pnpm bench:parallel to get figures for your own machine.