This post walks through a sample architecture for running 100M+ LLM inference calls with near-maximal GPU utilization on Union using Flyte’s orchestration primitives.
GPUs are brutally expensive to idle. And yet, that’s what most batch inference pipelines do: load a model, process some data, hit a straggler, wait, eventually finish. If you’re running evals at scale, generating synthetic datasets, scoring large corpora or doing multi-round LLM judging, you’ve probably already felt the cost of this the hard way.
The core problem isn’t that inference is slow. It’s that the orchestration around inference is naive. Data gets materialized into memory all at once. Workers finish at different rates and sit idle waiting for each other. Stragglers block entire rounds. Models load and unload repeatedly on every task invocation. All of this is infrastructure overhead eating into what should be GPU compute time.
The architecture in this post solves each of these problems directly. It’s built on Flyte, runs on Union, and gives you two concrete dispatch strategies depending on the shape of your workload. By the end you’ll understand not just how it works, but why the design choices were made, and how to tune it for your own pipeline.
Why naive batch inference falls apart
Before we get into the architecture, let’s be precise about what goes wrong with the obvious approach. The obvious approach looks like this: take your dataset, split it into chunks, spin up workers, run inference, collect results. Four things break when you take this to scale.
Memory pressure from materializing the dataset: If you load 100M samples upfront, you’re allocating gigabytes of data into memory before a single inference call has happened. Even with distributed storage, the overhead is real and entirely avoidable.
GPU waste from uneven workloads: LLM inference isn’t uniform as some prompts are longer and some outputs trigger more decoding steps. When one worker is stuck on a difficult batch, every other worker either waits or drains its work queue and idles.
The straggler problem: In any multi-round judging or approval pipeline, some samples get rejected and need another pass. If your architecture doesn’t handle stragglers gracefully, a handful of slow samples can hold the entire pipeline hostage, blocking the round from closing while GPUs sit mostly unused.
Cold start overhead: Loading a large model onto a GPU takes time. A 70B model at fp16 is roughly 140 GB, so it does not fit on a single 80 GB card; loading it across two A100-80GBs with tensor parallelism takes minutes. Even a 13B model that fits on one card costs 30–60 seconds on fast NVMe. In a naive architecture, every new pod pays this cost upfront. If you’re spinning up hundreds of pods, that’s a lot of model loading that never touches a single real sample.
The architecture we’re looking at fixes all four. Let’s go through the primitives it’s built on, then the design itself.
The platform: Flyte on Union
Flyte and Union together give you four primitives that do most of the heavy lifting in this architecture. Worth understanding each one clearly, because they solve different problems at different layers.
`JsonlDir` as a streaming primitive: The `JsonlDir` type ships in a separate plugin (`pip install flyteplugins-jsonl`, then from `flyteplugins.jsonl import JsonlDir, JsonlFile`). It lets you work with JSONL data stored in object storage (S3, GCS) without loading it all into memory. The dataset is split into shards, each shard is streamed record by record, and the memory footprint stays constant whether you are processing 1M samples or 100M. More on how this works under the hood in the next section.
Reusable containers: By default, every task execution in Flyte spins up a fresh container and discards it when done. For batch inference that’s a problem. Reusable containers fix this by maintaining a persistent pool via `ReusePolicy`. The model loads once and stays hot. Two timeouts matter. `idle_ttl` (default 30 s) shuts down the whole environment once it has been idle that long, so it needs to exceed the gap between rounds: set it too short and you’re cold-starting containers mid-pipeline, too long and you’re paying for idle GPU time at the tail end of a run. `scaledown_ttl` (default 30 s) is the minimum time an individual idle replica waits before removal, which damps churn when work arrives in bursts.
Dynamic batchers: This operates inside each container, at inference time, and is a separate concern from container lifecycle entirely. When multiple concurrent tasks are submitting samples asynchronously, the dynamic batcher accumulates those individual submissions and groups them into batched model calls before they hit the GPU. A single forward pass over 256 samples is far cheaper than 256 individual forward passes. The container handles scheduling; the batcher handles GPU utilization within that container.
Replica-level parallelism: Tasks run with multiple replicas, which is effectively a pool of identical GPU workers. Combined with the `concurrency` setting on reusable containers, this is what determines how many shards the system processes in parallel. At most `replicas × concurrency` shards are in flight at once.
JsonlDir and backpressure
`JsonlDir` is worth spending time on because it’s doing more work than it looks like at first glance.
At the surface level, it’s a directory of JSONL shard files stored in object storage: `part-00000.jsonl`, `part-00001.jsonl` and so on. Tasks read from it by iterating shards in sorted order. Two things about it matter for what follows: how reads stay bounded, and how writes rotate.
Reads stream, they never materialize
`iter_records()` is an async generator over `readline()`, with 64 KB chunked decompression for `.jsonl.zst`. A task reading a 20,000-record shard holds one record at a time, not 20,000. Memory is bounded by what you choose to accumulate rather than by shard size, which is why the tasks below batch in 2,000-record chunks and nothing larger ever exists.
Backpressure
Without backpressure, a reader racing ahead of a slow GPU produces memory bloat: you buffer records you cannot process yet. This architecture takes its backpressure from the dispatcher rather than the reader. The driver caps how many shards are in flight, so the loop that dispatches them stalls whenever the GPU pool is saturated. The consumer signals the producer to slow down without any explicit coordination code, and the GPU stays the bottleneck. That is exactly what you want.
You will see this mechanism in `_dispatch_bounded` below.
Automatic shard rotation on writes
`JsonlDir` also handles the write side. When your inference task writes results, `JsonlDir` manages shard rotation automatically, splitting output into new shard files when a shard hits a configurable size limit (default: 256 MB uncompressed). You don’t manage file handles or shard indices. You just write records.
Per-shard zstd compression is supported through the `.jsonl.zst` extension. On a 100M-sample dataset at roughly 1 KB per record, that is the difference between about 100 GB and about 25 GB in storage.
DynamicBatcher and TokenBatcher
The batching layer is where most of the GPU efficiency actually lives.
How DynamicBatcher works
The batcher acts as an accumulator between your async inference coroutines and the model. Coroutines submit individual samples via `await batcher.submit(record)`, which returns a `Future` as soon as the record is queued and suspends only when the submission queue is full, and the batcher groups them into optimally-sized batches before sending to the model.
The batcher accumulates across all concurrent tasks in the container, not just one. With 10-way concurrency, you have 10 tasks all submitting samples simultaneously. The batcher sees submissions from all of them, groups them together, and dispatches in cost-budgeted batches. That’s how you get high GPU utilization even when individual tasks are processing unevenly-sized shards. This works because all `concurrency` tasks in a reusable container share a single event loop. `DynamicBatcher` creates each record’s `Future` on the loop that called `submit()` and resolves it from its own processing loop, so every producer has to live on that same loop.
Three signals control when a batch gets dispatched:
- Cost budget: The aggregator accumulates records until the running cost reaches `target_batch_cost`, then dispatches. The record that crosses the threshold is admitted in full, so a dispatched batch can exceed the budget by up to the cost of one record, and a single record larger than the budget forms an oversized batch on its own. Size your GPU headroom accordingly.
- Hard cap: `max_batch_size` sets an absolute ceiling on records per batch regardless of cost.
- Timeout: `batch_timeout_s` bounds the fill window, measured from the moment the batch’s first record is dequeued. When it expires the batcher dispatches whatever has accumulated, even a partial batch. This prevents stalls when arrival rates aren’t perfectly steady.
Cost estimation
The batcher uses cost estimates to decide how many records to group into each batch. You can provide them in several ways, checked in order:
- Explicit: pass `estimated_cost` to `submit()`
- Estimator function: pass `cost_estimator` to the constructor
- Protocol: implement `estimate_cost()` on your record type
- Default: falls back to `default_cost` (default: `1`)
TokenBatcher for LLM inference
For LLM workloads, `TokenBatcher` is a convenience subclass that uses token-aware parameter names and checks the `TokenEstimator` protocol.
This matters because LLM inference cost scales with the token count of a batch, not its record count. `DynamicBatcher` with a count-based cost caps records per batch, which forces you to size `max_batch_size` for your worst-case prompt: set it to 256 and a burst of 200-token prompts produces a 51,200-token batch that overruns a GPU provisioned for 12,800; set it to 64 to stay safe and a run of 50-token prompts dispatches at 3,200 tokens, a quarter of the budget. `TokenBatcher` inverts the constraint. It fills a token budget and lets the record count float, so 256 prompts at 50 tokens and 64 prompts at 200 tokens both dispatch as a single 12,800-token batch.
Neither batcher groups by sequence length. A long prompt arriving behind short ones lands in their batch, in arrival order. Whether that costs you anything is decided by `inference_fn`: if it builds one padded tensor per batch, the short prompts get padded up to the long one; if your serving stack packs sequences instead, length variation is free. Note that a token budget would not rescue you if it did pad. It bounds the sum of a batch's tokens, while a padded batch costs `n × max_len`.
`estimate_tokens` here counts prompt tokens only. If your budget is meant to bound GPU memory during generation, add the maximum number of tokens you intend to sample. The KV cache grows with every decode step and for long generations, the output dominates the prompt.
Monitoring utilization
The batcher exposes a `stats` property with real-time metrics:
A healthy batcher shows two things at once: `utilization` above 0.9, meaning the processing loop is rarely waiting for work, and `avg_batch_cost` close to `target_batch_tokens`, meaning the batches it dispatches are actually full. Either number alone is misleading.
The levers, in the order worth trying. First, more concurrent producers: raise `concurrency` on the `ReusePolicy`, or submit more samples per task. This is almost always the real fix. Second, `prefetch_batches`, if the processing loop is starving between batches. Third, a longer `batch_timeout_s`, which trades tail latency for fuller batches.
Two things that look like levers and aren’t: `max_queue_size` bounds how far producers may run ahead before `submit()` suspends and nothing else. And a shorter `batch_timeout_s` makes batches smaller, which raises the reported utilization while lowering real throughput.
The data pipeline: streaming at scale
With those primitives in mind, here’s how the data flows through the system.
The example dataset is 100M samples stored as a `JsonlFile` in object storage. The first task converts it into a `JsonlDir`. A `JsonlFile` has no range or seek: `iter_records()` always starts at byte 0 and you cannot locate a JSONL line boundary without reading up to it. So 320 workers cannot each take a different slice of one file. Sharding is what turns an unsplittable object into N independently dispatchable units.
That first pass is serial, but it is a cheap one. It runs once, on a CPU node, before the GPU pool is needed, and every round afterwards reads the `JsonlDir`. Keep it off your GPU environment and never repeat it per round.
Because each shard becomes one task, shard size is a scheduling knob. Too large and each task runs for many minutes, so the final partial wave leaves most of the pool idle. Too small and you pay to list and open hundreds of thousands of objects. At 20,000 records per shard, 100M samples is 5,000 shards, and one task is a few minutes of work.
Flyte’s scheduler dispatches the shards across 32 replicas with 10-way concurrency per replica: 320 concurrent shard processors. At 5,000 shards / 320 concurrent workers, the dataset takes roughly 16 waves of scheduling.
The critical property: task inputs are references, not payloads. A task receives a shard handle and streams the records itself, so at any point in time only the chunk each worker is currently batching sits in memory. Everything else stays in object storage. Memory footprint is bounded by `active_workers × chunk_size`, not by dataset size and not by shard size.

The two dispatch strategies
This is the core of the architecture. The system offers two fundamentally different approaches to dispatching work.
Strategy 1: re-dispatch remaining samples each round
Use this when approval rates are high and most samples complete within 1–3 rounds.

In this strategy, each round dispatches all pending samples together. After the round finishes, the workflow runs the judge, identifies which samples didn’t pass, and re-dispatches the remaining set as input to the next round.
Helpers like `build_infer_prompt`, `save_checkpoint` and `MAX_ROUNDS` are yours to supply; only the orchestration shape matters here. `get_batcher` is the exception, shown in full above, because the single-event-loop constraint it encodes is easy to get wrong.
The re-dispatch loop in the workflow:
The `batch_inference_workflow` driver fans out across 32 replicas with 10-way concurrency, resulting in 320 shards in flight simultaneously. Each task receives a shard reference and reads the records itself; inside `infer_shard`, sample submissions flow into the batcher, which accumulates across all concurrent tasks in the container and dispatches to the model. The `judge_shard` phase works identically: the same fan-out, the same batcher, splitting records into `approved` and `rejected` shards. The rejected set becomes `pending` for the next round.
Why it works: On high-approval workloads, the pending set shrinks fast. Round 1 clears 90% of samples. Round 2 clears 90% of what’s left. By round 3 you’re dealing with a negligible tail. GPU utilization stays high because every round is a full fan-out, and the stragglers never accumulate enough to stall progress.
The tradeoff: If approval rates are low, this strategy amplifies costs. A pipeline with 50% approval per round leaves 50M samples pending after round 1, 25M after round 2. You’re doing significantly more total inference work, and each round carries the full overhead of scheduling, dispatching, writing intermediate results, and repacking the rejected set. That’s when you reach for Strategy 2.
Strategy 2: in-place retry inside the task
Use this when approval rates are low, samples frequently need multiple passes, and you want GPU utilization to stay high regardless of how many samples are churning.

Both strategies use the same reusable container setup with 32 replicas, 10-way concurrency, where the model is loaded once and kept hot. What changes is where retry logic lives. In Strategy 1, a rejected sample exits the current round and gets re-queued into the next. The pod is done with it. In Strategy 2, the pod owns the sample until it’s approved or exhausted. Retry happens inside `process_one`, against the same warm model, without ever touching the scheduler.
The checkpoint pattern is doing more work than it looks like. Each attempt writes its state before moving on. If the container goes down mid-attempt due to spot preemption or OOM, the next container picks up from the last saved state rather than starting over.
Compare that to Strategy 1, where you don’t need explicit checkpointing at all. The round structure gives you fault tolerance for free, but only because Flyte retries the failed task and the retried task rewrites its whole shard. That is the part worth being precise about: the next round’s input is `rejected`, not “everything that wasn’t approved”, so a shard whose records reach neither sink vanishes from the run. Flyte’s `retries=3` covers the transient case and the `failed` quarantine dir covers what survives it. With both in place the `JsonlDir` write pattern is your checkpoint and nothing extra is needed.
The two differ in the cost of recovery. Strategy 1’s recovery unit is the shard: a retried task re-infers all 20,000 records from scratch, because the output shard is its only checkpoint. Strategy 2 has no round boundary to fall back on, so `save_checkpoint` becomes the mechanism that makes in-place retry safe on interruptible infrastructure. Its recovery unit is the sample. An eviction at attempt 4 of 5 picks up at attempt 4, not attempt 0.
Then there’s the straggler behavior. Say 1,999 of 2,000 samples in a chunk approve on the first attempt. The one difficult sample keeps retrying inside `process_one`. The other 9 concurrency slots in the same container are working on entirely different shards, so a single retrying sample costs one slot rather than the whole container, and the GPU keeps receiving full batches from its neighbours. The caveat is the very end of a run: once most shards have drained, the remaining slots can all be occupied by single-sample retry loops, and batch sizes collapse. Strategy 2 makes the tail cheap but it does not make it free.
Why this works for low-approval workloads: In Strategy 1, a 50% approval rate means round 2 is scheduling 50M samples with full fan-out, scheduling overhead and intermediate writes. Strategy 2 handles those retries where they happen, inside the container, against a model that’s already warm. The scheduler never sees them. What you give up is simplicity. The code is more involved, the checkpoint infrastructure needs to exist and you need confidence that your retry loop eventually converges.
One more thing: always put a hard cap on `MAX_ROUNDS`. If you leave retries open-ended and your judge has a flaw, the pipeline can keep running indefinitely without it being immediately obvious. You usually only notice once the costs start adding up. And when the pipeline does hit the retry cap, that should be treated as something worth investigating, not as a normal completion path.
The capacity math stays the same across both strategies:
The numbers are identical across both strategies. What changes is entirely where retry work happens: at the scheduler level or inside the container. That single decision shapes your fault tolerance model, your infrastructure requirements, and how gracefully the pipeline degrades when approval rates are low.
Choosing between the two strategies
The decision comes down to one number: your approval rate per round.
If most samples pass on the first or second attempt, above roughly 70% approval per round, Strategy 1 is the right call. The pending set shrinks fast, the round structure keeps things simple, and you get fault tolerance for free from the `JsonlDir` write pattern. The code is straightforward and the failure modes are easy to reason about.
If approval rates are low, or your judging criteria are strict enough that samples regularly need three, four or five attempts, Strategy 1 starts to break down. Every retry goes back through the scheduler, so the overhead keeps piling up and the tail latency only gets worse. That’s where Strategy 2 starts making a lot more sense. Retries happen inside the container itself against a model that’s already warm, without constantly bouncing back through the scheduler.
That fault tolerance story is also what makes spot instances viable here. Strategy 1 recovers via the round structure; Strategy 2 recovers via checkpointing. Either way, a preempted container picks up where it left off rather than starting over. With `interruptible=True`, Flyte reschedules a preempted task automatically. Note that this system-level rescheduling is a separate tier from the `retries` count on the task, which is what covers application failures like a model OOM; you want both. Spot discounts typically land around 60–70% off on-demand, though the figure varies by region and instance type.
The judge in both strategies doesn’t have to be an LLM either. Union's human-in-the-loop support lets you insert a human review gate at the round boundary. The workflow pauses after each inference phase, surfaces the pending samples for a human reviewer, and resumes once approvals come back. The `JsonlDir` round structure maps cleanly onto this: each round’s `rejected` set is exactly the queue of samples waiting for human judgment. You get the same fan-out and streaming behavior, with a human in the approval loop instead of, or alongside, an automated judge. This is useful when judging criteria are genuinely subjective or when you want human oversight on low-confidence outputs before they get re-queued.
There’s also a hybrid worth considering. Run Strategy 1 for the first round or two to clear the easy approvals cheaply, then switch to Strategy 2 for the stubborn tail. Both strategies read from and write to `JsonlDir`, so the output of one is a valid input to the other:
The crossover point is mostly a judgment call. A reasonable rule of thumb is to switch to Strategy 2 once the pending set stops shrinking meaningfully between rounds. At that point, the remaining samples are unlikely to clear cheaply no matter how many times you keep re-dispatching them.
Closing thoughts
Not every batch inference workload needs this level of complexity. If you’re processing a few million samples with a strong judge and relatively low retry rates, a simple fan-out approach is usually enough.
But once datasets get large, approval rates become unpredictable, and GPU costs start mattering, these decisions begin to compound quickly. Streaming instead of materializing keeps memory usage under control. Backpressure ensures the GPU stays the bottleneck instead of the scheduler. Reusable containers help avoid repeated cold starts. And the two dispatch strategies let you adapt to actual retry behavior instead of forcing one approach onto every workload.
The nice part is that most of these decisions are encapsulated in the primitives themselves. The pipeline code ends up expressing intent rather than infrastructure concerns, which is usually what good orchestration should feel like.




