Skip to content

lore: speed up indexing on many-CPU hosts - #112

Open
vladwing wants to merge 3 commits into
facebookexperimental:mainfrom
vladwing:lore-insert-throughput
Open

vladwing wants to merge 3 commits into
facebookexperimental:mainfrom
vladwing:lore-insert-throughput

Conversation

@vladwing

Copy link
Copy Markdown
Contributor

On a 316-CPU host, semcode-index --lore inserted about 85 batches a minute. With these three commits it inserts 400–660; the memory and thread-pool costs are listed after the commits. Each commit removes one bottleneck:

  • Append indexed commit SHAs. insert_lore_indexed_commits() used merge_insert, which hash-joined every batch against the whole lore_indexed_commits table. That table has no index, so the join grew with every batch. The SHAs are already filtered against get_indexed_lore_commits() before parsing, so a plain table.add() is enough.
  • Check for duplicate message_ids in memory. The per-batch message_id IN (...) query was planned once per fragment and scanned every fragment appended since the last index build. The message_ids are now loaded once per archive and shared by that archive's inserters, which claim ids under a lock so concurrent batches cannot store the same email twice.
  • Cap tokio workers and lore parser threads at 32. One thread per CPU meant over 600 threads waiting on two inserters, and most CPU time went to futex and spinlock contention. TOKIO_WORKER_THREADS still overrides the cap. Hosts with 32 CPUs or fewer see no change.

The speedup has three costs:

  • The message_id map keeps every stored message_id in memory, roughly 100 bytes per email: about 0.5 GB for all of LKML.
  • Lance builds FTS indices on tokio workers, so a full lore FTS rebuild on a large host now uses 32 workers unless TOKIO_WORKER_THREADS is raised.
  • Code-index inserters block a tokio worker while they wait on their channel, so --db-threads of 32 or more could now starve the runtime. The default of 2 is unaffected.

Testing: on top of f6ba491, each commit passes cargo check --all-targets, and the branch passes cargo fmt --check, cargo clippy --all-targets -- -D warnings and cargo test (510 passed, 0 failed).

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 10, 2026
@vladwing

Copy link
Copy Markdown
Contributor Author

Stress-test results for #112 and #113 together, against main: on a production-sized lore DB (10 archives, 1,039,746 commits), an hourly refresh (--lore, then --vectors) drops from 13–14.5 minutes to 27–28 s, and a cold build from 2.2 hours to under 8 minutes, with the same lore rows (every non-vector column matches; vector values were not compared). Kernel source indexing is 6–8% faster on cold runs, about the same on --git ranges, and uses 5–8x less system CPU; one kernel table, call_edges, I cannot fully clear (see Correctness). #112 can merge on its own: alone it gives the kernel CPU cut and takes a cold two-archive --lore from 55 s to 14.5 s, and it slows hourly lore growth there from about 240 fragments per run to 64 without stopping it, which is #113's job. #113 has one open problem, a compaction warning at production scale, described in my comment on #113.

Lore. Wall seconds, medians where n > 1. The two-archive DB is io-uring/0 + linux-nvme/0: 105,669 commits indexed cold, with the last 6,000 commits of each archive held back and then published 125 per archive per run (3,000 per archive for the day-sized run). The 10-archive DB holds 960,744 distinct message-ids after the cold build, with 25 commits per archive published per run.

Workload n main #112 + #113
Cold --lore, two archives 3 55.0 14.6
Cold --vectors, two archives 3 38.1 23.7
Hourly --lore + --vectors, run 1 3 21.8 + 26.5 1.7 + 1.5
Hourly --lore + --vectors, run 24 1 288.9 + 387.6 1.8 + 1.4
Day-sized --lore + --vectors after 24 hourly runs 1 919 + 1,150 2.6 + 2.7
No-op --lore + --vectors 3 1.0 + 1.4 0.9 + 1.3
10-archive cold --lore 1 3,502 160
10-archive cold --vectors 1 4,431 303
10-archive hourly --lore + --vectors, two runs 2 441 + 333, 455 + 415 23.2 + 4.0, 23.7 + 4.1

After the 24 hourly runs the two-archive DB is 6.9 GB on main and 1.0 GB on the PR build. Main's FTS skip check (more than 100 _indices entries) fired after hourly run 16, and by the day-sized run 7,554 of 114,921 rows were outside its FTS indices. Unindexed rows still fall back to a scan, and the four semcode lore queries I compared on the 24-run databases returned the same result sets on both builds (a fifth, lore -b nvme_tcp_queue --limit 0, panicked in sort_emails_by_date(), which neither PR touches; its comparator is not a total order, and the same query panics on a database built by main). The PR build had no unindexed FTS rows at any point. Peak RSS of the 10-archive cold --lore went from 123.6 GiB to 15.5 GiB. Cold --vectors peaks higher on the PR build for the two-archive DB (8.7 GiB against 4.2 GiB) and lower for the 10-archive DB (24.6 GiB against 37.0 GiB); running each binary on the other build's database shows that the lore table's layout drives this, not the vectors code.

Kernel (v7.3 tree, 64,869 files). Median wall seconds of 3 runs, with the range.

Workload main wall #112 + #113 wall main sys CPU s #112 + #113 sys CPU s
Cold -s, cold page cache 221.4 (220.7–225.8) 204.3 (191.1–207.4) 15,796 3,077
Cold -s, hot page cache 223.0 (218.1–225.2) 209.6 (205.1–211.8) 16,158 3,177
Re-run -s, nothing new 2.2 2.2 1 1
--git v7.3..v7.4-rc1 (19,780 commits) 677.1 (670.7–694.1) 664.3 (642.0–677.8) 40,622 5,100
--git v7.4-rc1..v7.4-rc2 (990 commits) 73.6 (72.8–75.6) 74.5 (72.7–77.5) 5,047 733

Max thread count drops from 961 to 677 on cold runs.

Correctness. All 270 measured runs exited 0, and no PR-build run logged an error. Lore: after the cold and no-op runs (3 reps each) both builds hold the same row counts and the same number of distinct message_ids; after the day-sized run (3 reps) and at the end of the 10-archive series they also have the same order-independent digest over every non-vector column of lore, lore_indexed_commits and lore_vectors. Vector values were not compared. Kernel: 27 of the 28 non-empty data tables have identical row counts and digests in all 12 cold runs; the 28th, symbol_filename, varies by up to 118 of 1.21 M rows between runs of the same build, and the two builds' ranges overlap. After the --git ranges call_edges varies by up to 6% between runs of the same binary. Its PR median is 2% below main's after v7.3..v7.4-rc1 (3.72 M against 3.80 M) and 0.3% above after v7.4-rc1..v7.4-rc2 (3.612 M against 3.600 M); three runs each cannot separate that from the spread. If you know a reason the tokio worker cap could change call_edges, that is where I would look. functions, types and git_commits match in row count after the ranges, but their digests differ between runs of either build. processed_files matches, except in two main runs of v7.3..v7.4-rc1 that came out 512 rows short after a "Too many concurrent writers" insert error and still reported success, a main bug that none of the three PR runs hit.

Which PR does what. Each PR speeds up the cold lore build on its own, #112 cuts kernel system CPU, and #113 keeps hourly refreshes flat. Each branch ran alone on the two-archive DB (cold, no-op, 6 hourly runs, a day-sized run) plus one cold kernel run:

What was run. Both PRs at their current heads (be2d75f and 6b14580), merged locally, against main at f6ba491; main has since gained only #114, which touches scripts/ and nothing the indexer runs. The host is a 316-CPU, ~1 TB RAM Linux machine. Main and PR runs were interleaved on fixed local inputs and measured with /usr/bin/time -v. --vectors ran as --vectors -j 32 after each --lore run, the pair I run hourly from a systemd timer. Lore times leave out the network fetch, the manifest check and repack: archives came from local mirrors, the manifest fetch failed at once, and both builds ran with --no-lore-repack. Nine main runs, and no PR run, overlapped my hourly lore refresh on the host for 30–36 s each, among them main's run 24, both 10-archive cold runs and one 10-archive hourly run; that slows those runs by at most about 36 s, which changes none of the comparisons above.

insert_lore_indexed_commits() recorded each batch's SHAs with a
merge_insert keyed on git_commit_sha.  lore_indexed_commits has no
index, so every merge_insert hash-joined the batch against the whole
table.  The join grew with every batch, and on a 316-CPU host most of
the indexer's busy threads sat in that join while the email parsers
waited on the inserters.

The merge buys nothing here.  The pipeline only hands it SHAs that
were filtered against get_indexed_lore_commits() before parsing, and
the readers treat the column as a set, so a rare duplicate row is
harmless.  Append with table.add() instead.
insert_lore_emails() filtered every batch through a
`message_id IN (...)` query with one literal per email.  lance plans
that predicate once per fragment, and the BTree index on message_id
only covers fragments that existed when it was last built, so each
batch also scanned every fragment appended during the run.  The cost
of a batch grew with fragment count times batch size, and the
indexer spent its time building and freeing DataFusion literal
expressions.

The check is still needed: a crossposted email has the same message_id
in every list's archive, and a partially failed run can leave stored
rows whose commit SHA was never recorded.  So load the table's
message_ids into a map with one unfiltered column scan
(get_lore_message_ids()) when the first archive of a run with new mail
needs them, and share the map between the inserters of every archive for
the rest of the run.  Once a pipeline's inserters finish, the map holds
exactly the ids stored so far, this run's included, so a crosspost of an
email stored from an earlier archive of the run is still caught.  A run
in which no archive has new commits never scans the column.
LoreIdState, the map's value type, is exported next to DatabaseManager.

Within an archive, a message_id can appear in more than one commit, and
those copies can land in batches that different inserters store at the
same time.  Each batch claims its new ids as pending under the map's
lock before appending, marks them stored afterwards, and releases the
ids of emails that failed so another copy can still be stored.  A copy
whose id another batch has pending is deferred rather than skipped: it
is reported with the failed emails, so its commit SHA is not recorded
and the next run checks it against the table.  Skipping it would record
the SHA even if the other batch's insert then failed.

The map costs one string per stored email.  On a 316-CPU host, with
the other two changes in this series applied, this took lore inserts
from about 85 to 400-660 batches a minute.
#[tokio::main] starts one worker per CPU, and the lore pipeline started
one email parser thread per CPU.  On a 316-CPU host that is over 600
threads for a job limited by two database inserters: the parsers sat
blocked on the result channel, and the process spent most of its CPU
time in the kernel, in futex and spinlock contention from idle tokio
workers parking and waking.

Build the runtime by hand with min(num_cpus, 32) workers, still letting
TOKIO_WORKER_THREADS override it as it does for the default runtime,
and cap the lore parser threads the same way.  The rayon pool is left
alone; -j already sizes it.  Machines with 32 CPUs or fewer see no
change.

The other modes do their CPU work on rayon, std threads or
spawn_blocking, so the tokio cap does not touch them.  The exception
is lance's FTS index build, which tokenizes on tokio workers: a full
lore FTS (re)build on a large host is now limited to 32 workers, and
TOKIO_WORKER_THREADS lifts that.

The database inserters are another matter.  The inserter tasks of the
--git, tree, --commits and lore pipelines are tokio tasks that block
their worker in a std channel recv() while they wait for a batch, so
with --db-threads at or above the worker count they can occupy every
worker and starve the async work the inserters wait on.  That was
already reachable at one worker per CPU on small hosts; with the cap it
is reachable at 32 inserters on any host.  The defect predates this
change, and facebookexperimental#119 moves those receives off the workers.
@vladwing

Copy link
Copy Markdown
Contributor Author

I pushed an update with two review fixes and one change to landing order: this PR should now land with or after #119.

The reason is now in the tokio-cap commit's message. The database inserters of the --git, tree, --commits and lore pipelines block a tokio worker while they wait for a batch, so with workers capped at 32, a host with more than 32 CPUs can starve the runtime at --db-threads 32 or more (the default is 2). The defect predates the cap, since small hosts could already reach it, and #119 moves those receives off the workers.

Both review fixes are folded into "lore: check for duplicate message_ids in memory":

  • The message_id map is now loaded once per run, when the first archive with new mail needs it, and shared by every archive in the run. Before, each archive with new mail scanned the whole message_id column again. A run with no new mail never loads it, and the map is dropped before the lore tables are compacted.
  • LoreIdState is now exported, since insert_lore_emails() is public and takes it.

Four new tests cover the map:

  • A second load returns the same map without scanning the table again.
  • An id that another batch has claimed is deferred.
  • A failed first load is retried.
  • An email crossposted to two archives in one run is stored once, and both commits are recorded as indexed.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant