Production Systems

Priority request queue with batch formation

Not all requests are equal. A paid user's chatbot query should not wait behind a batch processing job. I built a priority queue that also forms efficient batches by grouping requests with similar characteristics.

In production LLM serving, you almost always have multiple tiers of traffic. Interactive chat users need low latency. Background summarization jobs need throughput. Evaluation pipelines need to run but should never block user-facing traffic. A simple FIFO queue treats them all the same, which means a burst of batch processing requests can spike latency for your paying users.

Today I will build a priority request queue that respects these tiers and also forms batches intelligently, grouping requests with similar prompt lengths to reduce padding waste and improve GPU utilization.

The priority queue

The core data structure is a priority queue where each request carries a priority level. Higher-priority requests are dequeued first, regardless of arrival order. Within the same priority level, requests are served in FIFO order.

import heapq
import time
from dataclasses import dataclass, field
from enum import IntEnum

class Priority(IntEnum):
    CRITICAL = 0    # System health checks, canary requests
    HIGH = 1        # Interactive users, paid tier
    NORMAL = 2      # Standard API requests
    LOW = 3         # Batch processing, eval pipelines
    BACKGROUND = 4  # Backfill, non-urgent tasks

@dataclass(order=True)
class PrioritizedRequest:
    priority: int
    arrival_time: float = field(compare=True)
    request_id: str = field(compare=False)
    prompt_len: int = field(compare=False)
    max_tokens: int = field(compare=False)
    payload: dict = field(compare=False, default_factory=dict)

class PriorityQueue:
    def __init__(self, max_queue_size: int = 1000):
        self.heap = []
        self.max_queue_size = max_queue_size
        self.dropped = 0

    def enqueue(self, request: PrioritizedRequest) -> bool:
        if len(self.heap) >= self.max_queue_size:
            # Shed load: drop the lowest-priority request
            if self.heap and self.heap[-1].priority > request.priority:
                # New request is higher priority; drop something lower
                self._drop_lowest()
            else:
                self.dropped += 1
                return False
        heapq.heappush(self.heap, request)
        return True

    def dequeue(self) -> PrioritizedRequest:
        return heapq.heappop(self.heap)

    def _drop_lowest(self):
        """Remove the lowest-priority (highest number) request."""
        # Find and remove the max-priority item
        if not self.heap:
            return
        worst_idx = max(range(len(self.heap)),
                        key=lambda i: (self.heap[i].priority, self.heap[i].arrival_time))
        self.heap[worst_idx] = self.heap[-1]
        self.heap.pop()
        heapq.heapify(self.heap)
        self.dropped += 1

    def __len__(self):
        return len(self.heap)

Why batch formation matters

Continuous batching (as I covered on day 50) lets requests enter and leave the batch at every decode step. But the prefill phase still benefits from grouping. When the scheduler runs prefill for multiple new requests simultaneously, having similar prompt lengths reduces padding waste in the attention computation.

Consider two scenarios for prefilling 4 requests:

In practice, modern systems like vLLM use ragged (unpadded) prefill, so the padding waste is less severe. But grouping still helps because similar-length sequences have more uniform memory requirements, making KV cache block allocation more predictable.

The batch former

class BatchFormer:
    def __init__(self, max_batch_size: int = 8,
                 max_wait_ms: float = 50.0,
                 length_buckets: list[int] = None):
        self.max_batch_size = max_batch_size
        self.max_wait_ms = max_wait_ms
        # Buckets for grouping similar prompt lengths
        self.length_buckets = length_buckets or [256, 512, 1024, 2048, 4096, 8192]

    def _bucket_for_length(self, prompt_len: int) -> int:
        """Find the smallest bucket that fits this prompt length."""
        for bucket in self.length_buckets:
            if prompt_len <= bucket:
                return bucket
        return self.length_buckets[-1]

    def form_batch(self, queue: PriorityQueue,
                   current_batch_size: int = 0) -> list[PrioritizedRequest]:
        """Form a batch from the priority queue.

        Returns a list of requests to prefill together.
        Prioritizes: (1) priority level, (2) length similarity, (3) wait time.
        """
        available_slots = self.max_batch_size - current_batch_size
        if available_slots <= 0 or len(queue) == 0:
            return []

        batch = []
        skipped = []
        target_bucket = None
        now = time.time()

        while len(batch) < available_slots and len(queue) > 0:
            request = queue.dequeue()

            # Always admit high-priority requests regardless of bucketing
            if request.priority <= Priority.HIGH:
                batch.append(request)
                continue

            bucket = self._bucket_for_length(request.prompt_len)

            # First non-critical request sets the target bucket
            if target_bucket is None:
                target_bucket = bucket
                batch.append(request)
                continue

            # Same bucket or waited too long: add to batch
            wait_ms = (now - request.arrival_time) * 1000
            if bucket == target_bucket or wait_ms > self.max_wait_ms:
                batch.append(request)
            else:
                skipped.append(request)

        # Put skipped requests back
        for req in skipped:
            queue.enqueue(req)

        return batch

Putting it together: the request handler

class InferenceRouter:
    def __init__(self):
        self.queue = PriorityQueue(max_queue_size=500)
        self.batch_former = BatchFormer(max_batch_size=16)
        self.active_batch_size = 0

    def handle_request(self, request_id: str, prompt: str,
                       max_tokens: int, priority: str = "normal"):
        """Accept an incoming request and add it to the priority queue."""
        pri = getattr(Priority, priority.upper(), Priority.NORMAL)
        prompt_len = len(prompt.split())  # approximate token count

        req = PrioritizedRequest(
            priority=pri,
            arrival_time=time.time(),
            request_id=request_id,
            prompt_len=prompt_len,
            max_tokens=max_tokens,
            payload={"prompt": prompt},
        )

        accepted = self.queue.enqueue(req)
        if not accepted:
            return {"error": "Queue full", "status": 429}

        return {"status": "queued", "position": len(self.queue)}

    def next_batch(self) -> list[PrioritizedRequest]:
        """Called by the inference engine when it has capacity."""
        batch = self.batch_former.form_batch(
            self.queue,
            current_batch_size=self.active_batch_size,
        )
        self.active_batch_size += len(batch)
        return batch

    def on_request_complete(self, request_id: str):
        """Called when a request finishes generation."""
        self.active_batch_size -= 1

Load shedding and fairness

The priority queue naturally handles load shedding: when the queue is full, the lowest-priority requests are dropped first. But we also need fairness guarantees to prevent starvation. A constant stream of HIGH priority requests should not indefinitely block NORMAL requests.

The max_wait_ms parameter in the batch former provides soft fairness: if a normal-priority request has been waiting more than 50 milliseconds, it gets admitted to the next batch regardless of bucketing. For hard fairness, you can implement priority aging:

def age_priorities(self, aging_rate_ms: float = 100.0):
    """Promote requests that have waited too long.

    Every aging_rate_ms of wait time, promote by one priority level.
    This prevents starvation of low-priority requests.
    """
    now = time.time()
    for req in self.queue.heap:
        wait_ms = (now - req.arrival_time) * 1000
        promotions = int(wait_ms / aging_rate_ms)
        if promotions > 0:
            req.priority = max(0, req.priority - promotions)
    heapq.heapify(self.queue.heap)
Why this matters in production

Without priority queuing, a burst of batch evaluation traffic (say, 500 requests from a nightly eval pipeline) will spike latency for interactive users by minutes. With priorities, those eval requests queue behind user traffic and process during quiet periods. The eval pipeline runs a bit slower, but no user is impacted.

Integration with vLLM and SGLang

Both vLLM and SGLang have their own internal schedulers, so this priority queue sits in front of the engine, not inside it. The integration point is an API gateway (like FastAPI) that accepts requests, enqueues them, and forwards them to the engine when capacity is available.

vLLM's AsyncLLMEngine exposes an add_request method that accepts individual requests. Your gateway calls next_batch() on the priority queue and feeds each request to the engine via add_request. The engine's internal continuous batching scheduler handles the rest.

# Simplified integration with vLLM's AsyncLLMEngine
from vllm.engine.async_llm_engine import AsyncLLMEngine

async def scheduler_loop(router: InferenceRouter, engine: AsyncLLMEngine):
    while True:
        batch = router.next_batch()
        for req in batch:
            await engine.add_request(
                request_id=req.request_id,
                prompt=req.payload["prompt"],
                sampling_params=SamplingParams(max_tokens=req.max_tokens),
            )
        await asyncio.sleep(0.01)  # 10ms scheduling interval

A priority queue is the simplest form of QoS (quality of service) for LLM serving. It does not require changes to the inference engine, just a thin layer between the load balancer and the engine that decides which requests go first.

This wraps up the initial production systems block. We have gone from building a container to balancing load to managing request priorities. Next, I will look at multi-GPU tensor parallel benchmarks to understand how scaling across GPUs affects the throughput and latency characteristics we have been measuring.