perf(kernel): avoid per-element allocation and exploit kernel symmetry - #451
Open
mysma-9403 wants to merge 1 commit into
Open
perf(kernel): avoid per-element allocation and exploit kernel symmetry#451mysma-9403 wants to merge 1 commit into
mysma-9403 wants to merge 1 commit into
Conversation
Dense kernel construction did roughly twice the necessary work, and the inner product allocated once per matrix entry. `KernelMethod::distance` used `a.mul(&b).sum()` for the Linear and Polynomial kernels. `a.mul(&b)` builds a fresh `Array1` for every one of the n^2 entries of the kernel matrix, which is why the Linear kernel was several times slower than the Gaussian one despite doing strictly less arithmetic. `a.dot(&b)` computes the same value without allocating. `dense_from_fn` evaluated the full n x n matrix even though every `KernelMethod` is symmetric in its two arguments: Gaussian sums `(x - y)^2`, Linear is `<a, b>` and Polynomial is `(<a, b> + c)^d`. Evaluating only the upper triangle and mirroring it halves the work. The matrix was also initialised with `Array2::eye`, whose values were then entirely overwritten by the loop, so it is now `Array2::zeros`. Both changes are value-preserving; the added `dense_from_fn_is_symmetric` test guards the symmetry property the second one relies on. linfa-kernel had no benchmarks, so this also adds a criterion bench following the guidelines in CONTRIBUTE.md. Sample sizes stay below the suggested defaults because a dense kernel is quadratic in memory: 20_000 samples would already need ~3.2 GB for the matrix alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4uUK6s1CWFwx6u7Rx28go
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #451 +/- ##
==========================================
+ Coverage 77.53% 77.90% +0.36%
==========================================
Files 106 104 -2
Lines 7585 7526 -59
==========================================
- Hits 5881 5863 -18
+ Misses 1704 1663 -41 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Dense kernel construction was doing roughly twice the necessary work, and the inner product was allocating once per matrix entry.
1.
a.mul(&b).sum()→a.dot(&b)KernelMethod::distanceuseda.mul(&b).sum()for theLinearandPolynomialkernels.a.mul(&b)builds a freshArray1for every one of the n² entries of the kernel matrix. That is why theLinearkernel — which does strictly less arithmetic thanGaussian, noexp— was measurably slower than it.a.dot(&b)computes the same value without allocating.2.
dense_from_fnonly needs the upper triangleEvery
KernelMethodis symmetric in its two arguments:Gaussian(eps)exp(-Σ(xᵢ-yᵢ)² / eps)Linear⟨a, b⟩Polynomial(c, d)(⟨a, b⟩ + c)^dso evaluating only
j >= iand mirroring halves the work. The matrix was also initialised withArray2::eye, whose values are then entirely overwritten by the loop, so that is nowArray2::zeros.Both changes are value-preserving. The new
dense_from_fn_is_symmetrictest guards the symmetry property that the second one relies on, for all three methods.3. Benchmarks
linfa-kernelhad no benchmarks, so this adds a criterion bench following CONTRIBUTE.md (config::set_default_benchmark_configs, pprof profiler, constant seed, data passed as an argument).One deliberate deviation from the guidelines: sample sizes are
[1_000, 2_000, 4_000]rather than[1_000, 10_000, 20_000], because a dense kernel is quadratic in memory — 20 000 samples would need ~3.2 GB for the matrix alone. This is noted in a comment in the bench.Benchmark results
Context
lowpowermode 0)Bench command run
Restricted to the
2000samplessubset to keep the A/B run bounded; the committed bench covers the full grid.Summary at n = 2000:
Gaussiangains the least because it never usedmul()— only the symmetry change applies to it.End-to-end effect on
Svm::fitSince
linfa-svmbuilds a dense kernel on everyfit, measured with a separate throwaway harness (not part of this PR), accuracy identical in every case:Numerical equivalence
a.dot(&b)anda.mul(&b).sum()both use ndarray's unrolled accumulation, and I measured a max absolute difference of exactly0.0between the old and new kernel matrices acrossn ∈ {500, 1000, 2000, 4000}andd ∈ {8, 50, 200}for all three methods. Bit-identity is not something either API contract promises, so I would not state it as a guarantee — but there is no reassociation being introduced here beyond what ndarray already does internally.Checks
cargo fmt --all -- --check— cleancargo clippy --workspace --all-targets -- -D warnings— cleancargo test --release --workspace— no failuresWhile looking at this I also measured a follow-up that is not in this PR: because the dense kernel is row-major and symmetric,
Kernel::column(i)is a stride-ngather where the equivalentrow(i)is contiguous. Switching the SMO solver to read rows is worth another ~1.7–2.1× onSvm::fitand looks relevant to #308. Happy to open that separately if it is of interest.🤖 Generated with Claude Code
https://claude.ai/code/session_01L4uUK6s1CWFwx6u7Rx28go