[perf][tinker] Scan pending requests without loading their payloads - #1976
[perf][tinker] Scan pending requests without loading their payloads#1976avigyabb wants to merge 1 commit into
Conversation
| } | ||
|
|
||
| def find_single_requests(self, session: Session) -> dict[str, tuple[str, types.RequestType, dict]]: | ||
| def find_single_requests( |
There was a problem hiding this comment.
could potentially combine these functions to reduce code
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>
312944c to
046c087
Compare
There was a problem hiding this comment.
Code Review
This pull request introduces a database benchmark script for the Tinker server and optimizes the engine's scheduling queries by separating the initial lightweight metadata scan from the loading of large request payloads. This prevents deserializing the entire backlog on every poll. The feedback focuses on enhancing robustness and atomicity by filtering for pending status when loading payloads and checkpoint IDs, as well as safely handling potential null values when retrieving checkpoint IDs.
| rows = session.exec( | ||
| select(FutureDB.request_id, FutureDB.request_data).where(FutureDB.request_id.in_(chunk)) | ||
| ).all() |
There was a problem hiding this comment.
To address the atomicity concern mentioned in the PR description, we can filter by FutureDB.status == RequestStatus.PENDING when loading payloads. This ensures that if a request's status is updated, completed, or cancelled by another process/thread between the initial metadata scan and this payload load, we won't load or dispatch it.
| rows = session.exec( | |
| select(FutureDB.request_id, FutureDB.request_data).where(FutureDB.request_id.in_(chunk)) | |
| ).all() | |
| rows = session.exec( | |
| select(FutureDB.request_id, FutureDB.request_data) | |
| .where(FutureDB.request_id.in_(chunk)) | |
| .where(FutureDB.status == RequestStatus.PENDING) | |
| ).all() |
| rows = session.exec( | ||
| select(FutureDB.request_id, FutureDB.request_data["checkpoint_id"].as_string()).where( | ||
| FutureDB.request_id.in_(chunk) | ||
| ) | ||
| ).all() |
There was a problem hiding this comment.
For consistency and robustness, filter by FutureDB.status == RequestStatus.PENDING when loading sample checkpoint IDs to avoid loading metadata for requests that are no longer pending.
| rows = session.exec( | |
| select(FutureDB.request_id, FutureDB.request_data["checkpoint_id"].as_string()).where( | |
| FutureDB.request_id.in_(chunk) | |
| ) | |
| ).all() | |
| rows = session.exec( | |
| select(FutureDB.request_id, FutureDB.request_data["checkpoint_id"].as_string()) | |
| .where(FutureDB.request_id.in_(chunk)) | |
| .where(FutureDB.status == RequestStatus.PENDING) | |
| ).all() |
| model_checkpoints = {} # Map from model_id to checkpoint_id of first request to that model | ||
| for op in sample_ops: | ||
| checkpoint_id = op.request_data["checkpoint_id"] | ||
| checkpoint_id = checkpoint_ids.get(op.request_id, "") |
There was a problem hiding this comment.
Using checkpoint_ids.get(op.request_id, "") can return None if the key exists in the dictionary but its value is None (which happens if the checkpoint_id field is missing or null in the JSON payload extracted by the database). Using checkpoint_ids.get(op.request_id) or "" safely coerces any None values to "".
| checkpoint_id = checkpoint_ids.get(op.request_id, "") | |
| checkpoint_id = checkpoint_ids.get(op.request_id) or "" |
What
The Tinker engine's scheduling loop read every pending request's full payload on every poll, then threw away the ones it could not run. This makes the scheduler cost scale with the depth of the queue rather than with the work it dispatches — which is the multi-LoRA steady state, where some adapters are stepping while others have passes stacked behind a barrier.
This is the first of several PRs splitting up #1972. It contains only the scheduling-scan change — the covering index that originally rode along has moved to its own PR, since it turned out to be worth far less than this part (details under What this leaves on the table).
The problem
find_batchable_model_passes,find_batchable_sample, andfind_single_requestseach ran their ownSELECT FutureDB ...over all pending rows and then filtered in Python. Three consequences:optim_step/load_weightsbarrier had their payloads deserialized on every poll and immediately discarded. Aforward_backwardwith 4×512 tokens is ~76 KiB of JSON, so a few hundred parked requests is tens of MB of wasted decode per 100 ms tick — enough to keep the engine CPU-bound and slow to dispatch anything.The change
scan_pending_requestsreads only(request_id, model_id, request_type)for pending rows. Barrier detection and all three finders now work from that metadata as plain Python, andrequest_datais fetched only for the requests actually being dispatched. The main loop scans once and passes the result to all four finders, so a poll with nothing runnable is a single indexed query.checkpoint_idthrough a JSON path extraction (json_extract) rather than loading whole prompts to reach one field. This is what makes skipping possible for samples at all: the scheduler needs that one field to group them, so without extracting it in the database you would have to pull every sample's payload into Python and there would be nothing left to skip.Scheduling semantics are unchanged: barriers are still per-model, still keyed on
request_idorder, and the existing regression tests for that pin the behaviour.Results
skyrl/benchmarks/bench_tinker_db.py(new; no GPU, drives the real engine functions, reports SQL statement and commit counts). 8 adapters, 64 concurrent submitters, 256 dispatchable requests plus 256 parked behind barriers, ~76 KiB payloads. Measured on this branch vs. an otherwise identicalmainworktree:scan_parked— backlog behind barriers, re-run every pollscan_ready— scan + decode a dispatchable batchfind_batchable_sample, 640 samples, half held back by checkpointscan_parkedis the one that matters most: it is the cost the engine pays on every poll while it waits for a barrier to clear, and it previously grew with the queue. It is now flat.scan_readystill decodes the batch it is about to dispatch, which is real work; the remaining win there comes from not also decoding the parked rows.Where this is not a win
This is not strictly better, so here is everything I could find that it costs. Each row below is measured, not reasoned about — I was wrong twice while checking, so nothing here is an estimate unless it says so.
Neutral, verified rather than assumed:
find_batchable_sample, 640 samples, all dispatchableThe no-parked case is the one I expected to lose: it now pays for a metadata scan plus a primary-key payload fetch, where before a single query got rows and payloads together. It comes out ~5% ahead anyway, because the old path constructed full ORM
FutureDBobjects for every pending row and the new one returns lightweight tuples.Actually worse:
if request_id in payloads, and safe today only because the single-threaded engine is the sole completer of these request types. That is a new invariant a future change could break, and it is the part of this PR I would most like a second opinion on.One number in the benchmark output is misleading: its
completephase reads ~1.7x slower here, and nothing in this PR touches the write path. It is a harness artifact — all phases share one database in a fixed order, and this write picks up process and I/O state from the scan phases that precede it. An isolated harness measuring only the status update reports ~105 ms either way, and--scan-iterations 1gives identical numbers on both sides (~0.122 s). The phase docstring now says so. I took it for a real regression at first and only found otherwise by measuring, so it is called out here rather than left for a reviewer to trip over.What this leaves on the table
A covering index on
(status, request_id, request_type, model_id)would let the scan run without visiting the table rows at all. I had it in here originally and split it out, because measuring the two halves separately showed the split is very lopsided:statusindex (this PR) → 1.2 msNot asking for the payload is the whole win. The index is a refinement worth roughly 2x on a microbenchmark, and it costs ~2.8x index storage, so it deserves its own accept-or-decline decision rather than being smuggled in here. It follows as a separate PR.
Not in this PR
Split out of #1972 and coming separately, in rough priority order:
EXTERNALrequests are completed by exactly one fire-and-forget task; any failure there orphans them permanently). Relevant to the stuck Tinker Fully Async E2E job.retrieve_future, replacing per-caller polling that scales connection-pool checkouts with in-flight requests.Also unaddressed anywhere: completed futures are never pruned and each retains its full payload, so a long run grows the SQLite file without bound. That needs a policy decision on how long results stay fetchable.
Testing
55 passed. New coverage in
tests/tinker/test_engine.py:_load_request_payloadsThe pre-existing barrier regression tests pass untouched, which is the main evidence that scheduling semantics did not move.
🤖 Generated with Claude Code