Development¶
Native kernels (for example the libFM-style ALS solver) are written in Rust under rust/ and built
by maturin as skrecsys._core, so development needs a Rust toolchain
(rustup or your package manager). uv sync rebuilds the extension when Rust sources change.
The Rust side is a two-crate workspace. rust/kernels (skrecsys-kernels) holds the kernels and
knows nothing about Python: its functions take borrowed slices and the sparse types in
sparse.rs. rust/src/lib.rs is the skrecsys._core extension module, which validates the numpy
arrays that come in, converts them and releases the GIL around a kernel call. The split is what
lets the benchmarks link the kernels while the extension stays LTO'd; see [lib] crate-type below.
git clone https://github.com/mrk-andreev/skrecsys.git
cd skrecsys
uv sync
uv run pytest
uv run pytest -m benchmark # quality benchmarks on real datasets (downloads data)
LIBFM_BIN=/path/to/libfm/bin/libFM uv run pytest -m benchmark -k libfm # ALS vs libFM
uv run --group reference pytest -m benchmark -k implicit # BM25 vs implicit
uv run pytest -m benchmark -k rectools # EASE vs RecTools (needs network on first run)
uv run pytest -m benchmark -k dacrema # RP3Beta and SLIMElasticNet vs their reference framework (needs network)
uv run pytest -m benchmark -k cornac # BayesianPersonalizedRanking vs Cornac (needs network on first run)
uv run ruff check
uv run ty check
cargo test
cargo clippy --all-targets -- -D warnings
cargo bench -- --test # run every benchmark once, without measuring
The website is built from the same README.md.j2 and
docs/*.md.j2 sources as this README, one page per included fragment, and published to GitHub
Pages by .github/workflows/docs.yml on every push to main. To preview it:
uv sync --group docs
uv run python scripts/build_site.py # renders the pages into build/docs
uv run properdocs serve # re-run the first command after editing a fragment
The release profile uses fat LTO and a single codegen unit. [lib] crate-type deliberately
lists only cdylib: adding rlib back would make Cargo drop LTO silently, because it cannot
link-time-optimize a unit that also emits an rlib. The kernels live in their own crate so that
the benchmarks have an rlib to link against without the extension needing one; cargo build
--release -v shows -C lto=fat on the cdylib and -C linker-plugin-lto on the kernels, so
LTO still crosses the crate boundary.
Benchmarks¶
The tables in this README are generated, and so is the README itself: README.md.j2 is
the source, with one docs/*.md.j2 fragment per section, and
python scripts/render_readme.py fills in its tables, and the sentences around them, from the results stored in benchmarks/results. Edit the template, not
README.md; a test fails when the two disagree.
What the benchmarks run is configuration rather than code. benchmarks/config/
holds one file per report — leaderboard.json, sequential.json, reranking.json,
indexes.json — each
listing its entries, a model or an index named by package and cls with its params,
a version and a comment, and the datasets it runs on with the settings each is timed
under; datasets.json defines the datasets they share, and may replace a dataset's own split
with a splitter such as ColdStartSplit. The sequential and index reports take their models
from the leaderboard's, so a model is defined once. A reranking entry names a builder in
benchmarks/pipelines.py rather than a class, because its feature tables come from the
dataset. Every result is
keyed on a hash of everything its entry and its dataset say, which makes the workflow:
- Adding a model is adding an entry to
leaderboard.jsonand runningpython benchmarks/run.py run leaderboard. That measures the new model, and nothing else. - Editing a parameter or a setting re-runs exactly the results it touches, with no version to remember to bump.
- Bumping a
versionre-runs an entry after a change the config cannot see — a rewritten kernel, a fixed bug. That is why the quantized index is atv2.
status lists what is out of date. A missing result was never measured; a stale one
was measured under a config that has since changed. A stale result is still reported,
with a note under its table's caption, because an outdated measurement is still a
measurement: it is only the run that treats it as work to do.
uv run python benchmarks/run.py status # fresh, stale and missing results
uv run python benchmarks/run.py run indexes --dry-run # what a run would measure
uv run python benchmarks/run.py run indexes --dataset movielens-100k --index quantized-flat-8
uv run python benchmarks/run.py run leaderboard # measure what is out of date, re-render README.md
uv run python benchmarks/run.py run leaderboard --dataset amazon-books --only ALS --force
uv run python benchmarks/run.py run leaderboard --no-store --set repeat=20 --set rank_repeat=5000
uv run python scripts/render_readme.py --check # exit 1 if README.md is out of date
--no-store measures without keeping the result, which suits tighter timing runs like the
one above; to change what the README reports, change the settings in
benchmarks/config/leaderboard.json instead.
Each result records the host it was measured on. A run re-measures some rows and not others, so one table can hold rows from two machines; the caption under it then says which ran where, since a timing only compares against one taken on the same host.
rust/kernels/benches measures the kernels with criterion,
grouped into sparse (the transpose, the parallel row builder, the accumulator), similarity
(item-item kNN, cosine, RP3beta, pruning), ranking (dense and fused top-k selection), encode
(id factorization, COO to CSR) and fit (EASE, SLIM, FM-ALS, BPR).
cargo bench # every group, roughly six minutes
cargo bench --bench similarity # one group
cargo bench -- 'knn::' # benchmarks whose id matches a regex
cargo bench -- --save-baseline main # record the current numbers under a name
cargo bench -- --baseline main # compare the working tree against that baseline
Criterion reports the change against the previous run -- or against a saved baseline, which is the
reliable way to compare two commits -- and says whether it is statistically significant. The
--save-baseline form on a clean checkout of main, then --baseline main on the branch, is the
way to check a change that was meant to be faster. HTML reports with the distributions land in
target/criterion/report/index.html.
Every input is generated from a fixed seed, and the rayon pool is pinned to four threads (or fewer
on a smaller machine) so that two runs are comparable; SKRECSYS_BENCH_THREADS=n overrides it, and
scaling numbers come from varying it deliberately. Comparisons are only meaningful between runs on
the same machine, since the benchmarks inherit the release profile and the kernels are
memory-bound.
python scripts/pgo.py rebuilds the extension with profile-guided optimization, training on a
short leaderboard run. It is opt-in and not part of uv sync or the release workflow: an
interleaved A/B on an Apple M4 Pro put the kernels within a few percent either way for a 3%
smaller binary and roughly triple the build time, which is not a trade worth making by default.
Tight numeric loops with predictable branches give PGO little to work with, but that result is
specific to this workload and micro-architecture, so the script is there to re-measure with.
Micro-batching under load¶
python benchmarks/batching.py run answers when AsyncRecommender pays off. It serves one
ItemKNNRecommender (20,000 users and items, 1,000,000 synthetic interactions) from a bare
asyncio HTTP server, once with a recommend call per request and once through
AsyncRecommender, both in the same one-thread executor, so batching is the only difference.
The load comes from benchmarks/loadgen, a small Rust program, for two reasons. A Python
generator would share the machine and the interpreter with the service it measures. And it
is open-loop: it sends on a schedule whether or not the service keeps up, and charges each
request from the moment it was due. A closed-loop generator slows down together with the
service and so hides the queue that batching exists to drain.
These numbers are for this model on this machine, not for yours. How long one
recommend call takes depends on the model, the catalog size, n_recommendations, filters
such as exclude_interactions, and the hardware, and so do the loads at which batching
starts to matter. Here a call takes about 0.19 ms, which puts the knee at 5,000 to 6,000
req/s; a model that takes 5 ms per user would hit its limit near 200 req/s, and a much
cheaper one far above 6,000. As a rule of thumb, the direct service saturates near
1 / (time of one call), so measure that for your model first, then run the sweep with
--rps values around it. Do not expect your break-even load, or the size of the gain, to
match the table.
Measured on an Apple M4 Pro, 10 s per load after a 2 s warm-up, requests that took more than 5 s counted as failed:
| offered req/s | mode | served req/s | failed | p50 ms | p95 ms | p99 ms | requests per call |
|---|---|---|---|---|---|---|---|
| 500 | direct | 500 | 0 | 1.25 | 1.80 | 1.96 | 1.0 |
| 500 | batched | 500 | 0 | 1.26 | 1.83 | 2.11 | 1.0 |
| 2000 | direct | 2000 | 0 | 1.37 | 2.13 | 2.29 | 1.0 |
| 2000 | batched | 2000 | 0 | 1.72 | 2.57 | 2.79 | 2.2 |
| 4000 | direct | 4000 | 0 | 1.47 | 2.17 | 2.42 | 1.0 |
| 4000 | batched | 4000 | 0 | 1.69 | 2.71 | 3.07 | 4.1 |
| 6000 | direct | 6000 | 0 | 1.80 | 88.96 | 147.19 | 1.0 |
| 6000 | batched | 6000 | 0 | 1.78 | 2.78 | 3.00 | 6.5 |
| 8000 | direct | 5743 | 22570 | 1337.62 | 4168.70 | 4668.07 | 1.0 |
| 8000 | batched | 8000 | 0 | 1.66 | 2.77 | 3.15 | 7.0 |
| 12000 | direct | 215 | 117846 | 6894.31 | 8305.04 | 8510.88 | 1.0 |
| 12000 | batched | 12000 | 0 | 1.72 | 2.80 | 57.89 | 10.3 |
| 16000 | direct | 112 | 158877 | 5656.59 | 6590.11 | 7682.52 | 1.0 |
| 16000 | batched | 16000 | 0 | 1.80 | 3.05 | 3.62 | 14.2 |
What the table shows:
- Below the model's capacity, batching buys nothing and costs a little. At 500 req/s a request almost never finds another in flight, so each call carries one user and the only difference is the batcher's own overhead: a future and one more hop through the event loop. In the table it is about 0 to 0.5 ms at the median; a shorter rerun showed 0.6 ms at 500 req/s, so treat the low-load rows as a range, not a number.
- At the capacity of one call per request, it is the difference between serving and not.
One user costs about 0.19 ms in
recommend, so the direct service can do at most about 5,000 to 6,000 req/s on its one thread. It starts queueing at 6,000 (p95 89 ms against 2.8 ms batched) and falls over at 8,000, where requests wait seconds and most of the load times out. - Batches grow with the load, and that is the point. Requests that arrive while a call runs share the next one, so the batch is 6.5 requests at 6,000 req/s and 14 at 16,000. A batch of 128 users costs about 1.5 ms, 0.011 ms per user against 0.19 ms alone, which is why the batched service holds a flat 1.7 to 1.8 ms median up to 16,000 req/s, the highest load measured, with the generator and the Python server taking the rest of the machine.
So batching pays when the offered load approaches what one recommend per request can
serve. That depends on your model's execution time, and so does where this table's knee
falls for you: the dearer a single call, the lower the load at which batching starts to
matter. It costs a fraction of a millisecond when the load is low. The numbers
come from one machine and one synthetic model, so read them as the shape of the curve and
re-measure with your own: --users, --items, --interactions and --max-batch-size
change the model and the cap, and --rps the loads. The p99 of a batched row can show a
single slow batch, such as the 57.89 ms at 12,000 req/s; repeat a load before reading
anything into one tail value.