batch + online co-serving for vLLM
your GPU's decode iterations
use ~1% of the token budget.
tidal sells the other 99%.
An OpenAI-compatible Batch API — /v1/files, /v1/batches,
a 24 hour completion window, half the online price — served from the
same GPU as your live traffic. Batch work fills the slack in every engine
iteration and yields the instant online traffic needs the room.
- 0.5×online price per token
- 24 hSLA, laxity-enforced
- no forkstock vLLM, or one plugin class
- 69.3%of the node’s offline ceiling recovered, measured (GPU)
- online tokens (priority 0)
- batch tokens (priority 100…1)
- unfilled budget
⇐how we
got here
how serving learned to batch
Each generation of batching widened what may share one engine iteration: first requests of different lengths, then requests in different phases — and Tidal widens it to two different products. Continuous batching decides which requests join an iteration; Tidal decides on whose behalf the iteration runs. All four eras animate below on one shared clock, sweeping the same iterations — the paper’s Figure 1, live.
- online tokens
- prefill chunk
- batch-job tokens (Tidal)
- budget wasted / unspent
- arrival queued outside
Agateway
technique
policy outside the engine
A sidecar holds the durable Batch API and decides, once a second, how many batch items are allowed in flight. vLLM stays stock. Online traffic never touches the gateway.
co-serving spec §4, §7 · docs/plans/plan-A-gateway.md
- batch items
- online requests
- metrics scrape
Btidal
scheduler
policy inside the engine
A --scheduler-cls plugin gates around the stock
schedule(). Online tokens fill first; batch fills whatever the
interference model says is still free — every step, at token granularity.
scheduler spec §5.1–§5.3 · docs/plans/plan-B-engine.md
- online tokens, scheduled first
- batch tokens, filling the slack
- cap / KV pressure / starved slack
max_num_batched_tokens — bounds tokens processed; KV memory
bounds how many requests can be resident. Batch fill is capped by a latency model
rather than a fixed quota because interference is not linear in tokens: a 2,048-token
chunk costs far more against a long resident context than a fresh one. Admission
stops at 1−H; above 1−H/2 the scheduler evicts the batch
request with the least unrecoverable work, since preemption is recompute-only and
the prefix cache survives.
§24 h
sla
laxity, not the clock
Escalation is driven by deadline risk, not elapsed time. Urgency is measured against a 6 h escalation horizon — the last stretch of slack — not against the 24 h window, so a batch that is draining fast enough never moves, no matter how much of its window has passed. A batch falling behind climbs on its own, early, without dragging every other batch up with it.
co-serving spec §7 · src/tidal/dispatcher/laxity.py
- on track — flat at 100
- at risk — flat, then climbing inside the horizon
escalation_horizon); scaling by the full 24 h window would
escalate healthy batches on wall-clock alone. Because both time terms cancel for a
batch draining at a steady rate, an on-track batch's laxity is constant — A₁ at
23h 53m and A₂ at 22h 01m, both far outside the horizon, so both sit at exactly
100 and never drift just because the day is passing. Healthy batches therefore
cause zero extra interference, and at-risk batches never escalate as a synchronized
flood. B holds at 100 for 54 minutes, crosses into the horizon, and pins at 1 by
58.5 minutes — 4.1% of its window, early enough that the token-bucket floor
still has 23 hours to recover it. Not shown: once the floor engages, the guaranteed
rate raises laxity back — a closed loop that stabilizes at the floor.
≡a vs b
which one do you want
Both ship in this repo, share the same store, the same wire format and the same SLA machinery, and are evaluated head-to-head. A is the one you can run against a vLLM you did not build.
| dimension | A — gateway | B — TidalScheduler |
|---|---|---|
| where policy runs | sidecar process, once a second | inside the engine, every step (~10–100 ms) |
| vLLM requirement | stock, --scheduling-policy priority |
pinned vLLM main + --scheduler-cls plugin |
| batch admission | AIMD on /metrics watermarks |
per-iteration token-budget packing |
| interference control | coarse — throttles how many items exist | fine — caps tokens via a context-aware latency model |
| calibration | none | self-fitting T̂; no offline profiling sweep |
| KV protection | back off above kv_high; engine preempts |
guardband H, admit stop at 1−H, proactive evict at 1−H/2 |
| preemption victim | engine default — lowest-priority running request | cheapest — argmin(computed − prefix_cached) |
| work conservation | leaves slack on the floor by design | fills every step; unfilled slack is an alarmed metric |
| 24 h SLA | laxity escalation (6 h horizon) at submission time | same, gateway-set; in-engine aging deferred past v1 |
| blast radius | none — the engine is untouched | subclasses a non-public interface; upstream drift risk |
| phase | P1 — usable first | P2 — the throughput ceiling |
Both give you durable OpenAI-wire /v1/files + /v1/batches,
SQLite (WAL) → Postgres by DSN swap, EDF×prefix submission order, and metering
at a 0.5× discount.
$quick
start
two terminals
Alpha, under active development. Everything below runs on a laptop against a CPU build of vLLM with a small model.
install and serve
uv venv && uv pip install -e ".[dev]"
# terminal 1 — the engine, unmodified
vllm serve Qwen/Qwen2.5-0.5B-Instruct --scheduling-policy priority
# terminal 2 — the batch tier
tidal serve
submit a batch with any OpenAI SDK
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8080/v1", api_key="tidal-dev-key")
f = client.files.create(file=open("requests.jsonl", "rb"), purpose="batch")
b = client.batches.create(input_file_id=f.id,
endpoint="/v1/chat/completions",
completion_window="24h")
or move the policy into the engine (technique B)
vllm serve Qwen/Qwen2.5-0.5B-Instruct \
--scheduling-policy priority \
--scheduler-cls tidal.engine.scheduler.TidalScheduler
see what it earned
tidal report # per-batch and per-day cost at the 0.5× batch discount
⇅ladder
the A-ladder: widening the control plane
The gateway generalizes. The same contract — laxity admission, deadline windows, metrics-driven throttling — can place deferrable work over an ever wider scope: one engine, a fleet of replicas, a disaggregated deployment, and finally across time itself. Each rung carries its evidence status honestly: three panels are measured or spike-proven, the dimmed one is design.
Rung status matches the paper exactly: A0 measured on GPU (§5), A-F mechanism-proven on a local rig (appendix C), A-D design plus a Dynamo metric mapping, A-P spike-proven locally. The ladder is the complement of technique B, not a competitor: B is maximum visibility over minimum scope; the ladder trades coarser signals for widening scope, ending in a decision B cannot express — parking work in time.
¶paper
the paper
Tidal: Co-Serving Online and Batch LLM Traffic under Deadline Contracts — the measured version of everything above. On A6000 nodes serving Qwen2.5-7B, the gateway recovers 69.3% of the node’s steady-state offline ceiling at 1.18×/1.23× online p50/p99 under flat load, and 78.1% under a diurnal trace — and the same policy is run inside the engine so both placements are compared head-to-head.
↗more
read the rest
-
rajagurunath/tidal
The repo. Apache-2.0, alpha, design docs in
docs/plans/. source -
rajagurunath/lazycode
The batch client Tidal was built for — event-sourced job orchestration
with an
OpenAIBatchAdapterpointed at the gateway. client - the paper Deadline-guaranteed batch serving under online SLOs: A and B measured head-to-head against an offline ceiling. preprint — v2.4
Every figure on this page is a schematic of the designed mechanism, drawn from the specs and the configuration defaults — not a measurement. The measured results are in the paper.