[WIP][perf][tinker] Cut database work off the multi-LoRA critical path - #1972
Closed
avigyabb wants to merge 3 commits into
Closed
[WIP][perf][tinker] Cut database work off the multi-LoRA critical path#1972avigyabb wants to merge 3 commits into
avigyabb wants to merge 3 commits into
Conversation
- Move the default sqlite DB off the package checkout (often NFS on clusters, where sqlite locking/WAL are unreliable) to node-local /tmp/skyrl_tinker/tinker.db - Add prepare_sqlite_path(): creates missing parent directories and warns when the DB file lives on a network/FUSE filesystem - Pair WAL with PRAGMA synchronous=NORMAL to drop the per-commit fsync writer bottleneck Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Measured with the new `skyrl/benchmarks/bench_tinker_db.py` (no GPU; drives the real API and engine functions and reports SQL statement/commit counts). At 16 adapters / 128 concurrent submitters / 512 requests with 512 more parked behind barriers, three costs dominated: - The engine's scheduling scan SELECTed full `FutureDB` rows for every pending request, then discarded those parked behind a barrier -- 534ms of wasted deserialization per 100ms poll tick, leaving the engine CPU-saturated and slow to dispatch. - `retrieve_future` polled per caller: 1038 SQL statements/sec of pure polling, plus up to 1s of added latency from the backoff. - `json.dumps` on payload columns, not the commits, dominated the write path. Changes: - Scheduling reads metadata only. `scan_pending_requests` fetches `(request_id, model_id, request_type)` through a new `ix_futures_pending_scan` covering index; the finders decide what runs from that and fetch `request_data` for dispatched requests only. One scan now feeds all four finders instead of issuing eight queries. - `FutureWaiter` (new `skyrl/tinker/futures.py`) backs every `retrieve_future` with one shared batched query per tick. Results written in this process (forwarded samples) resolve via `notify()` and skip the round trip. - `json_engine_kwargs()` installs orjson for JSON columns when available, with a stdlib fallback; added to the `tinker` extra so the fast path is not accidental. - Completion writes update by primary key instead of loading the whole row (payload included) through the ORM. - `create_missing_indexes()` backfills indexes on databases created before they were declared, since `create_all` skips existing tables. Results (same shape): parked-backlog scan 534ms -> 1.9ms, dispatchable-batch scan 1906ms -> 965ms, submit 257 -> 492 op/s with p99 1020ms -> 390ms, `retrieve_future` load 1038 -> 33 stmt/s. Group-committing submissions was built and measured but not kept: only 1.18x throughput once orjson removed the JSON cost, and batching the hot endpoints while `save_weights`-style endpoints commit directly would reorder `request_id` allocation, which barrier scheduling depends on. Reasoning is recorded in `.claude/docs/tinker.md`. Not addressed: completed futures are never pruned and each retains its full payload, so long runs grow the SQLite file without bound. Pruning needs a policy decision on how long results stay fetchable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
…ever When sampling is forwarded (non-colocated backend or an external inference URL), `asample` writes an EXTERNAL future and hands off to a fire-and-forget task. That task's result write is the only thing that can ever complete the request: the engine excludes EXTERNAL from scheduling and nothing reaps pending rows. The write also sat outside the surrounding try/except, so a single failure did not fail one request -- it left the row pending forever while the client polled until its own deadline, surfacing as a hang rather than an error. That failure is reachable under load. The async engine takes SQLAlchemy's defaults, so it allows 15 connections (5 + 10 overflow) with a 30s checkout timeout. Before the shared poller, every in-flight request long-polled `retrieve_future` with its own session, so checkouts scaled with concurrency. In a 2048-concurrent-sample configuration a DB-layer reproduction demanded ~2048 polls/sec against those 15 connections, achieved 1185/sec, and pushed a `session_heartbeat`-shaped write from 2ms to p50 1084ms / max 1682ms. Under that much contention a completion write can exceed the 30s checkout timeout, and one `SATimeoutError` was enough to orphan the sample. - `complete_future` now retries `SATimeoutError` and `OperationalError` with exponential backoff (6 attempts, ~1 min total). Pool-checkout timeouts and `database is locked` are precisely what a burst of simultaneous completions produces, so they must not be terminal. Other exceptions still propagate immediately. - Both forwarding clients catch and log anything that still escapes, so an orphaned request is diagnosable in server.log instead of vanishing with the task. Verified against a genuinely saturated pool (pool_size=1, no overflow): the write retries twice with logged warnings and then succeeds, where previously the first timeout escaped and orphaned the row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Reduces the database work on the Tinker server's hot paths, which showed up as poor performance under multi-LoRA training. Three commits:
/tmp/skyrl_tinker/tinker.db, create missing parent dirs, warn when the SQLite file sits on a network/FUSE filesystem (where locking and WAL are unreliable and producedatabase is locked), and pair WAL withsynchronous=NORMAL.How this was measured
New
skyrl/benchmarks/bench_tinker_db.py: no GPU, no HTTP server, no inference engine. It drives the same functions the API server and engine use, and reports SQL statement and commit counts alongside timings, so the numbers move when those code paths change. It also tolerates an engine withoutscan_pending_requests, so it can be run against an older checkout for a direct before/after.uv run --isolated --extra dev --extra tinker python skyrl/benchmarks/bench_tinker_db.py \ --num-models 16 --concurrency 128 --num-requests 512 --blocked-requests 512 \ --await-waiters 512 --poller sharedWhat was actually slow
The reported symptom (serialized writes) was real, but the dominant costs were elsewhere:
find_batchable_*/find_single_requestseachSELECTed fullFutureDBrows for all pending requests, then discarded the ones parked behind a barrier — which is exactly the multi-LoRA steady state (some adapters stepping while others queue). With a 512-request parked backlog that was 534 ms of wasted deserialization per 100 ms poll tick, so the engine sat CPU-saturated and was slow to dispatch.retrieve_futurepolled per caller. Each in-flight future opened its own session and ran its ownSELECTon a 100 ms → 1 s backoff. At 512 concurrent rollouts that is 1038 SQL statements/sec of pure polling, plus up to a second of added latency.json.dumpsdominated the write path, not the commits — 0.40 s of a 0.77 s / 256-request submit.Changes
TinkerEngine.scan_pending_requestsreads only(request_id, model_id, request_type)through a newix_futures_pending_scancovering index (verified:SEARCH futures USING COVERING INDEX, no table access). Barrier logic is now a pure function of that metadata, andrequest_datais fetched only for the requests actually being dispatched. One scan feeds all four finders instead of issuing eight queries. Sample batch compatibility readscheckpoint_idvia a JSON path extraction rather than loading whole prompts.skyrl/tinker/futures.pyaddsFutureWaiter: callers await an asyncio future and a single background task resolves all of them with oneWHERE request_id IN (...)query per 50 ms tick. Results written by the API process itself (forwarded samples) callnotify()and skip the round trip entirely. The tighter interval also lowers latency relative to the old backoff.json_engine_kwargs()installs orjson when available (~7× faster encode, ~3× faster decode on these numeric arrays; byte-compatible round-trip) with a stdlib fallback. Added to thetinkerextra so the fast path isn't accidental.session.get(FutureDB, request_id)— loading the row's full request payload — just to write a result. Now a targetedUPDATE.create_missing_indexes()backfills declared indexes on databases created before they existed, sincecreate_allskips tables it already finds. Idempotent, safe on every startup.Results
16 adapters, 128 concurrent submitters, 512 requests plus 512 parked behind barriers, ~76 KiB payload per request:
retrieve_futureSQL loadThe hang, not just the slowness
The Tinker Fully Async E2E CI job was wedging during sample and dying on the SDK's
No progress made in 7200sguard. Investigating it turned up a latent bug in the same area, reachable by exactly the contention above.When sampling is forwarded (non-colocated backend, as that job uses),
asamplewrites anEXTERNALfuture and hands off to a fire-and-forgetasyncio.create_task. That task's result write is the only thing that can ever complete the request — the engine excludesEXTERNALfrom scheduling and nothing reaps pending rows — and the write sat outside the surroundingtry/except. So a single failure there did not fail one request; it left the row pending forever while the client polled until its own deadline, surfacing as a hang rather than an error.That failure is reachable: the async engine takes SQLAlchemy's defaults, so 15 connections (5 + 10 overflow) with a 30 s checkout timeout. With per-caller
retrieve_futurepolling, checkouts scaled with in-flight requests. Reproduced at the DB layer for that job's 2048-concurrent-sample shape:session_heartbeat-shaped writeThat heartbeat starvation matches the CI log, where
session_heartbeatwas failing for 120–220 s from the first sampling wave onward. Under that much contention a completion write can exceed the 30 s checkout timeout, and oneSATimeoutErrororphaned the sample.Fixes:
complete_futureretriesSATimeoutErrorandOperationalErrorwith exponential backoff (6 attempts, ~1 min) — pool-checkout timeouts anddatabase is lockedare precisely what a burst of simultaneous completions produces, so they must not be terminal; other exceptions still propagate immediately. Both forwarding clients now catch and log anything that still escapes, so an orphaned request is diagnosable inserver.loginstead of vanishing with the task. Verified against a genuinely saturated pool (pool_size=1, no overflow): two logged retries, then success, where previously the first timeout orphaned the row.Caveat on causation: this reproduces the mechanism and the symptom, and the shared poller removes the contention that triggers it — but on an uncontended box I reached ~1.1 s heartbeat latency, not the 120 s+ in the CI log, and could not make the completion write fail outright. Getting there needs the 4×L4 box's contention (FSDP trainer + 2 vLLM engines + router + engine subprocess competing with the API event loop). Sampling was also already very slow before anything wedged (
sampling_time_max600 s at step 0, 759 s at step 1), consistent with the vLLM prefix-cache reset being a separate contributor. I'd treat this as removing a necessary condition, not as a proven single root cause.Deliberately not done
save_weights-style endpoints commit directly would systematically reorderrequest_idallocation — which the engine's barrier scheduling depends on. Not worth that hazard; the reasoning is recorded in.claude/docs/tinker.mdso it isn't re-litigated.retrieve_future, so it is out of scope here.Testing
76 passed, including the
test_api.pyintegration tests that launch a real server subprocess and exerciseretrieve_futureand the engine loop end to end.New coverage:
tests/tinker/test_futures.py— waiter resolution, failed status, 404 for unknown ids, timeout, multiple waiters per request,notify()without a running poller,complete_futurewrite/missing-row/payload-preservation, retry-then-succeed, raise-after-exhausting-retries, no-retry-on-programming-errors, and an assertion that query count does not scale with waiter count.tests/tinker/test_engine.py— metadata scan ordering, passes held back at a barrier, one-checkpoint-per-model sample batching through the JSON extraction, and a backlog larger than the bound-parameter chunk size.tests/tinker/test_db.py— index backfill on a pre-existing database, and a query-plan assertion that the scan stays index-only.🤖 Generated with Claude Code