knot-static scale bench: findings (layer 1, end to end) #
harness: benches/scale/ (cargo bench --bench scale, env in its header). raw rows and tables:
macos-small.{jsonl,md}: this mac (m-series, apfs), small preset, nginx 1.30.6, release binary.linux-big.{jsonl,md}: chernobog (12 cores, ext4, nixos), big preset,nix build .#knot-static, nginx 1.30.5.linux-kernel.{jsonl,md}: chernobog, torvalds/linux master (12M objects, 6.6 GB pack).
chernobog is a desktop: both chernobog runs were taken inside
systemd-run --user --scope -p MemoryMax=6G -p MemorySwapMax=0 -p CPUWeight=20 -p IOWeight=20 -- nice -n 19,
so their wall times include yielding to the desktop; cpu and rss don't. an earlier unfenced kernel
push swapped the box and isn't used for any number here.
every axis is varied alone from the base point (100 commits, 4 refs, no tickets, 1 repo). sequence
per point: import, cold generate, activate, nginx -t, a ref moved in the hosted repo then
generate --repo + activate + reload, a push over git-ssh, a createRecord ticket, a putRecord
on ticket 1. seeding is outside the measurement: history and refs by one git fast-import, cobs
signed in-process with knot2's CobStore through tests/common/cobs.rs (5000 tickets in 14 s).
how to read the tags: [measured] = numbers from these runs; [read] = from reading the code;
[timers] = from temporary [trace] timers inside write_smart_artifacts (not committed, run on
the mac commits axis), added because the smart phase is one opaque 10-step span.
bench artefacts, not knot-static costs: activate and the owner write cli poll
https://<hostname> for up to 10 s before they return (crawl::announce), and a push waits up to
2 s in spindle::tell (wait_served, even with no spindle). the bench knot never serves its own
hostname, so those are fixed 10 s / 2 s floors here: writes are timed until they answer
(answered s) and stopped there; activation's own clock is in the jsonl (phases.activation_s).
hot spots, worst scaling first #
1. canned fetch replies are rebuilt whole on every generation, by a packer 10x slower than git #
- [measured] push wall at 1e3 / 1e4 / 1e5 commits (chernobog): 0.16 / 0.98 / 17.7 s of export, of
which smart artifacts 0.13 / 0.92 / 17.2 s. bytes written per push 1.25 / 13 / 177 MB: the full
upload-pack+fetch-done+shallow+shallow-headare rewritten (changed) every time, 2.6x the repo's pack per push. - [measured] with cobs in the repo it gets much worse: cob-put at 1e4 commits 2.6 s / 16 cpu-s, at 1e5 commits 45 s, 292 cpu-s, 1.33 GB rss (cob-create 1.38 GB; a push before any cob: 20 s, 463 MB).
- [timers] at 1e4 commits (mac): git's full v0 and v2 packs 0.11 s each; knot-pack's
budgetfull pack 1.1 s (2.0 s once cobs exist) and it is only used for its length; eachhave-N/have-N-donereply 20 ms without cobs, ~0.95 s with cobs (the cob tips aren't reachable from a base, so knot-pack walks all history per reply); 3 bases x 2 replies. - [read]
git_export::write_fetch_replies:budget = reply(full want, done).len()then 2 knot-pack runs per candidate base (up to 8), all from scratch;write_smart_artifactsclearssmart/first. - fix: size the budget from git's
fetch-done(already built) instead of a knot-pack run; buildhave-Nwithgit upload-pack(same request bytes) or fix knot-pack's reachability walk to stop at the haves; key every canned reply by a hash of its request and the refs it reads and hardlink it from the previous release when unchanged (cob-put already leavesupload-packidentical, 4.9 MB rewritten for nothing). per push: O(history) knot-pack walks x (1 + 2 bases) -> one O(history) git pack (or O(new objects) with pack reuse) + O(new objects) per base.
2. a ref fetch per 100 refs: export is O(refs^2) #
- [measured] cold generate fetch phase vs refs (chernobog): 1e2 0.04 s, 1e3 0.61 s, 1e4 28.6 s (cold generate 51 s, 21 s of it smart artifacts). cob refs count too: 5000 tickets -> 9.7 s cold fetch, and every push/write at 5000 tickets spends ~7.5 s of untraced export in the delta fetch (git_export 8.8 s, traced parts 1.3 s).
- [read]
git_export::fetch_refsruns onegit fetchper 100 refspecs; each one makes the source advertise all R refs and negotiate: R/100 x O(R). - fix: one
git fetch --stdinwith every refspec (or fetchrefs/*and drop what isn't exported). O(R^2/100) -> O(R).
3. a new cob (or any new ref) rebuilds the export from scratch #
- [measured] cob-create vs cob-put: createRecord takes the cache-miss path (fetch all, prune, fsck) and writes a whole new objects pack each time: at 1e5 commits 62 s and 270 MB written vs 45 s / 177 MB for putRecord; at 1e4 commits +6.3 MB new pack per ticket.
- [read]
refs_fast_forwardedreturns false when a ref is missing from the prior export, so an added ref (every new ticket, comment, pull, branch, tag) can never take the delta path. - fix: treat added refs as fast-forwards from nothing (fetch only their objects onto the hardlinked prior entry); keep the full path for deletions and rewrites. O(history) -> O(new objects).
4. every write re-reads every cob and rewrites every record file #
- [measured] the build's unreported remainder ("atproto" column: cob state, chain settle, record
and blob files) per push/write: tickets 100 / 1000 / 5000 -> 0.07 / 0.60 / 3.0 s; versions 10 /
100 (10 tickets) -> 0.03 / 0.21 s; repos 50 / 200 -> 0.07 / 0.26 s. a push at 5000 tickets
is 14.6 s in all. a git-only push at 1000
tickets rewrites 1.35 MB of byte-identical atproto files (lists 0.84, versions 0.30, records
0.21 MB) and changes the 1.23 MB records map and the 0.47 MB
.complete.json; at 5000 tickets 6.8 MB identical, records map 6.2 MB,.complete.json2.3 MB. eachhave-Nreply also carries every cob (0.56 MB at 1000 tickets, 2.8 MB at 5000), since cob tips are never in a base. - [read]
atproto::cob_statereplayschanges_offor every social entry of the captured repo;read_back/whole_treewalks the whole chain of every repo, carried ones included;emitwrites every repo's car, record, list and version file into the new generation without linking the unchanged ones;.complete.jsoninventories every file. - fix: cache each cob's state by its ref oid in the repo store and settle only changed keys; emit content-addressed record/list/version files and hardlink them from the previous generation; carry a carried repo's atproto dir like its git mount. per write O(tickets x versions + all repos' records) -> O(changed cobs).
5. old generations, releases and export entries are never removed #
- [measured] base knot: 0.8 MB after the first generate, +0.6 MB per write, 5.8 MB after 9 generations; 1e4 commits 25.9 -> 85.7 MB after 4 operations; 1e5 commits 363 MB -> 1.17 GB; 5000 tickets 258 -> 538 MB. linux kernel: see below.
- [read] nothing under
generations/,repos/*/releasesor the export store is ever deleted (README says so). - fix: after activation, keep the active generation, the previous K for rollback, and the releases/store entries they reference; delete the rest. disk O(generations x repo) -> O(K x repo).
6. import scans every small blob with its own git process #
- [measured] import of a bare repo: 1e3 / 1e4 / 1e5 commits = 1.5 / 15 / 154 s on chernobog, 10 / 102 s on the mac, cpu almost all in git spawns. the kernel (millions of small blobs) would take hours, so the linux run hardlinks the clone's pack into a created repo instead.
- [read]
provision::detect_lfs_pointers:cat-file --batch-all-objectsthen onegit cat-file blob <oid>per blob <= 4 KiB (the worktree grep runs first but falls through when it finds nothing). - fix: one
git cat-file --batchfed the candidate oids (or--batch-all-objects --batchwith a size filter). O(blobs) processes -> 1.
7. a depth-1 regex that nginx can't parse past ~95 refs (correctness) #
- [measured] any repo with >= 100 public refs fails activation:
nginx -tsays "too long parameter" for theshallowrow of$ks_git_reply(knot-static.conf:69). the refs axis stops at activate for 100, 1000 and 10000 refs, on both machines. - [read]
nginx::fetch_mapputs every public tip in one alternation(?:tip1|tip2|...)\ninside a single map key; nginx caps a config token at 4 KiB, which is ~95 sha1 tips. - [read] the
have-Nrows have the same shape (one lookahead per tip of a base in one key), so a base with ~80+ independent branch tips would hit the same limit. - fix: leave a row out when its key passes nginx's token limit (that fetch falls back to the full pack, which is still right), or split the tip test across chained maps. O(tips) token -> bounded.
8. the linux kernel: a cold generate doesn't fit in 6 GiB (canned replies buffered in memory) #
- [measured] fenced at 6 GiB with no swap, the cold generate of linux master spent 358 s in
export: fetch public refs (cache miss), writing a second 6.7 GB copy of the pack into the export (fetch overfile://re-packs and re-indexes every object instead of linking), then stalled at the cap inexport: smart artifacts: the scope held 6.3 GB anon, knot-static 3.7 GB and git pack-objects 2.5 GB, during the first full v0upload-pack, reclaiming instead of progressing; i stopped it after 564 s wall, 645 cpu-s, peak single-process rss 6.2 GiB. (unfenced, the same step reached knot-static 4.4 GiB + pack-objects ~5 GiB and swapped 10 GiB.) - [read]
git_export::upload_packruns git withCommand::output(), so each canned reply is collected in a Vec before it is written: the full v0 pack (6.6 GB for linux) is held in knot-static's memory, andwrite_smart_artifactskeeps that Vec alive whilewrite_v2_artifactsbuildsfetch-done, a second full pack. peak ~2x the repo's pack. - fix: stream each reply straight into its file (stdout = the staged file), and seed the export
from the hosted repo's packs by hardlink (
git clone --local-style orobjects/info/alternatesresolved at export time) rather than afile://fetch. memory O(pack) -> O(1), cold export disk and time O(pack) of copying -> O(1) links + the fsck walk. - the import path couldn't be measured on the kernel at all (#6), and nothing after the cold generate was reached.
suspects that the numbers refute or bound #
- index rebuilt per cob write: [measured]
bootstrap: index rebuildis 1-2 ms, 16 ms at 200 repos. - nginx map size vs
nginx -t/ reload: [measured] 6 MB conf (5000 tickets) ->nginx -t0.06-0.12 s, reload (SIGHUP until the new rev is served) 0.056 s; 200 repos 952 KB conf -> 0.03 s. linear but small. activation itself (tombstone, -t, symlink, records) is <= 0.11 s everywhere. - repos per knot: [measured] an incremental generate for one of 200 repos is 0.45 s (carried repos aren't re-exported); what grows is #4 (atproto 0.26 s) and the conf.
- generate memory: [measured] cold generate peak rss 27 / 86 / 330 MB at 1e3 / 1e4 / 1e5 commits; the peaks come from the smart phase (#1, #8): 1.38 GB for a cob-create at 1e5 commits, and the kernel doesn't fit in 6 GiB.