Skip to content

[perf][tinker] Add a covering index for the engine's pending scan - #1977

Draft
avigyabb wants to merge 2 commits into
NovaSky-AI:mainfrom
avigyabb:tinker-pending-scan-index
Draft

[perf][tinker] Add a covering index for the engine's pending scan#1977
avigyabb wants to merge 2 commits into
NovaSky-AI:mainfrom
avigyabb:tinker-pending-scan-index

Conversation

@avigyabb

@avigyabb avigyabb commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #1976 — read only the second commit. That PR makes the engine's scheduling scan read only
(request_id, model_id, request_type) instead of whole rows; this one adds an index covering exactly those columns.

GitHub shows the cumulative diff (both commits) because a cross-repo PR cannot use a fork branch as its base.
The change under review here is eac1387 only — 79 lines
across 3 files
. The rest belongs to #1976. Once that merges I will rebase and this will show the 79 lines alone.

What

Adds ix_futures_pending_scan on (status, request_id, request_type, model_id), so the engine's per-poll scan is answered from the index without visiting the table rows.

With only the plain status index, SQLite locates the pending rows through the index but must then visit each one to read model_id and request_type. Those rows carry request_data payloads of ~76 KiB, so they are spread far apart on disk and a few hundred row visits means a few hundred scattered page reads. An index holding all four columns the scan reads answers it outright:

EXPLAIN QUERY PLAN
  SELECT request_id, model_id, request_type FROM futures WHERE status='pending' ORDER BY request_id
→ SEARCH futures USING COVERING INDEX ix_futures_pending_scan

status is the leading column, so it supersedes the plain status index — that one is dropped rather than maintained alongside.

create_missing_indexes is here because SQLModel.metadata.create_all skips tables it already finds, so an index declared later never gets built on an existing database. Without it this change would help fresh databases and silently do nothing for every existing deployment, which is the worst kind of no-op — the code works and just keeps the slow plan. It is idempotent (checkfirst=True) and runs on every startup.

What it is actually worth

Being upfront, because it is much less than #1976 and it is not free.

Measured, isolated scan of a 512-row pending backlog with realistic payloads:

metadata scan (warm)
plain status index 1.9 ms
covering index 0.9 ms

End-to-end, it is below noise. The parked-backlog scan in bench_tinker_db.py reads 1.2 ms with or without this index, because #1976 already removed the expensive part. The split across the two PRs is very lopsided:

So: accept this as a scan refinement and a hedge against deeper queues, not as the fix for anything.

The scaling argument, and why I am not quoting a number for it. The covering index should pull further ahead as the backlog deepens, since the plain index's cost comes from row visits that get more scattered as payloads grow. Measuring that at 2000 and 6000 pending rows gave between 1.5x and 6.5x, but the results were non-monotonic and in one case reported the warm run slower than the cold one — at hundreds of MB of payload the measurement is dominated by OS page-cache behaviour rather than by SQLite. Directionally the covering index was faster in every configuration I tried; I cannot honestly put a figure on how much.

What it costs

  • Index storage: 18.1 → 51.5 bytes/row (~2.8x). Measured with dbstat over 200k rows — 10.3 MB against 3.6 MB for the status index it replaces, so roughly +33 MB per million requests. Modest next to the payloads, and next to the fact that completed futures are never pruned at all.
  • Writes: unaffected. Inserts measure 59.1 → 59.4 ms for 512 rows in one transaction, and 91.2 → 89.6 ms for 128 rows in a transaction each. Status write-back measures ~105 ms either way, across both index shapes and both light and heavy scan history. I initially assumed the wider index would cost something on every status update and only found otherwise by measuring it.

Testing

uv run --isolated --extra dev --extra jax --extra tinker pytest tests/tinker/ --ignore=tests/tinker/skyrl_train

Two new tests in tests/tinker/test_db.py:

  • test_create_missing_indexes_backfills_existing_database — builds a schema without the index (as a pre-existing deployment would have), asserts the index is absent, runs the backfill, asserts it appears, and runs it again to confirm it is safe on every startup.
  • test_pending_scan_uses_covering_index — asserts the query plan actually says COVERING INDEX. Without this, a later column addition to the scan could quietly turn it back into row visits with no test failing.

Unrelated flake to be aware of. Running the suite twice in a row fails test_api.py::test_training_workflow and ::test_delete_checkpoint with 404s. That is because the default database_url is a persistent file in the source tree (skyrl/tinker/tinker.db), so consecutive runs contaminate each other; deleting it first makes them pass. Pre-existing on main and unrelated to this change, but it cost me a confused half hour, so: rm -f skyrl/tinker/tinker.db* before a rerun. A separate PR in this series moves that default to node-local /tmp, though per-run isolation would be the real fix.

🤖 Generated with Claude Code

avigyabb and others added 2 commits August 3, 2026 23:10
The engine's scheduling loop read every pending request's full payload on every
poll and then discarded the ones it could not run, so the scheduler's cost scaled
with queue depth rather than with the work it dispatched. That is the multi-LoRA
steady state: some adapters stepping while others have passes stacked behind a
barrier.

`find_batchable_model_passes`, `find_batchable_sample`, and `find_single_requests`
each ran `select(FutureDB)` -- the whole entity -- over all pending rows and
filtered in Python. A forward_backward with 4x512 tokens is ~76 KiB of JSON, so a
few hundred parked requests meant tens of MB of wasted decode per 100ms tick,
enough to keep the engine CPU-bound and slow to dispatch. Between them the three
finders issued eight queries and recomputed the same barrier set three times.

- `scan_pending_requests` selects only (request_id, model_id, request_type).
  Barrier detection and all three finders now work from that metadata in plain
  Python, and request_data is fetched only for requests actually dispatched. The
  main loop scans once and shares the result, so a poll with nothing runnable
  costs one query.
- Sample batch compatibility reads checkpoint_id via a JSON path extraction
  instead of loading whole prompts to reach one field.

Scheduling semantics are unchanged: barriers stay per-model and keyed on
request_id order, as the existing regression tests pin.

Measured with the new skyrl/benchmarks/bench_tinker_db.py (no GPU) against an
otherwise identical main worktree, at 8 adapters / 64 concurrent submitters / 256
dispatchable plus 256 parked requests:

  scan_parked (re-run every poll)   247ms -> 1.2ms
  scan_ready  (decodes the batch)   928ms -> 648ms
  SQL statements per scan               8 -> 2-3

A covering index for this scan is deliberately left out and will follow
separately: measured on its own it is worth roughly 2x on the metadata scan but
is below noise here, so it does not need to be reviewed alongside this.

The benchmark's `complete` phase reads ~1.7x slower here, which is a harness
artifact rather than a real regression -- an isolated harness measuring only the
status update reports ~105ms regardless of index shape or scan history, and
--scan-iterations 1 gives identical numbers on both sides. Its docstring says so,
since the raw number misleads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
Follow-up to the scheduling-scan change, kept separate because its measured
value is much smaller and it carries a storage cost, so it should be accepted or
declined on its own.

With only the plain `status` index, SQLite locates pending rows through the index
but must then visit each row to read `model_id` and `request_type`. Those rows
carry request_data payloads of ~76 KiB, so they are spread far apart and a few
hundred row visits is a few hundred scattered page reads. An index holding all
four columns the scan reads answers it outright:

  EXPLAIN QUERY PLAN ... -> SEARCH futures USING COVERING INDEX ix_futures_pending_scan

`status` is its leading column, so it supersedes the plain status index, which is
dropped rather than maintained alongside.

`create_missing_indexes` exists because `SQLModel.metadata.create_all` skips
tables it already finds, so an index declared later is never built on an existing
database -- the code would work and silently keep the slow plan. It is idempotent
(`checkfirst=True`) and runs on every startup.

What it is worth, measured on a 512-row pending backlog with realistic payloads:

  metadata scan, status-only index   1.9ms warm
  metadata scan, covering index      0.9ms warm

In the end-to-end benchmark it is below noise: the parked-backlog scan reads
1.2ms with or without it, because the scheduling change already removed the
expensive part. Deeper backlogs favour it more (measured between 1.5x and 6.5x
at 2000-6000 rows), but those numbers are dominated by OS page-cache effects at
hundreds of MB of payload and are not reliable enough to quote as a figure.

Cost: the wider index is 51.5 bytes/row against 18.1 for the status index it
replaces (measured via dbstat over 200k rows), so roughly +33 MB per million
requests. Writes are unaffected -- inserts and status updates measure identical
either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant