Static batching wastes GPU time. When you run a batch of N sequences, short responses finish early but their KV cache slots stay reserved until the slowest sequence in the batch completes. Under variable response lengths — the normal case in production — GPU utilization drops significantly while you wait.
Continuous batching fixes this problem. The moment a sequence finishes, its KV cache slots are freed and the next waiting request is admitted immediately. The active set is a sliding window rather than a discrete batch, so the GPU stays utilized regardless of response length variance. This is the core innovation behind vLLM's original performance gains.
This project implements continuous batching from scratch in C++ against llama.cpp, with an OpenAI-compatible HTTP frontend, gRPC API, distributed rate limiting via Redis, per-tenant scheduling, and Kubernetes manifests for horizontal scale-out.
The engine runs a single decode thread. On each iteration:
- Admit: any free KV slots are filled from the waiting queue, highest priority / soonest deadline first.
- Decode: one
llama_decodecall across all active sequences simultaneously. - Sample: one token is sampled per active sequence.
- Retire: sequences that hit EOS, their
max_new_tokenslimit, or their deadline fire their callback and release their KV slots viallama_kv_self_seq_rm, making room for the next admission.
The KV cache is pre-allocated at startup (n_ctx = max_slots × max_seq_len). Each sequence is tagged with a seq_id; retiring a sequence untags its cells without touching any other sequence's state.
| Concern | Library / Tool |
|---|---|
| HTTP server | cpp-httplib |
| gRPC API | gRPC C++ + Protocol Buffers |
| LLM inference | llama.cpp |
| Distributed rate limiting | Redis via hiredis |
| JSON | nlohmann/json |
| Metrics | prometheus-cpp |
| Logging | spdlog |
| Testing | GoogleTest |
| Observability | Prometheus + Grafana |
| Load testing | wrk |
| Orchestration | Kubernetes (Deployment, HPA, Service) |
.
├── include/ # Headers: request, engine, slot manager, metrics, rate limiter, server, gRPC server
├── src/ # Implementation + main.cpp
├── proto/ # inference.proto — InferenceService definition
├── tests/ # GoogleTest unit tests (server, rate limiter, KV slot manager)
├── bench/ # wrk load test scripts and results
├── docker/ # docker-compose for Prometheus + Grafana
├── k8s/ # Kubernetes manifests (Deployment, Service, HPA, Redis, ConfigMap)
└── CMakeLists.txt
Prerequisites: CMake ≥ 3.20, C++20 compiler, Homebrew.
brew install nlohmann-json spdlog googletest prometheus-cpp hiredis grpc protobuf
cmake -B build
cmake --build build -j$(sysctl -n hw.ncpu)
ctest --test-dir build --output-on-failure
MODEL_PATH=/path/to/model.gguf REDIS_URL=redis://localhost:6379 ./build/inference_server# Build image and start server + Redis + Prometheus + Grafana
MODEL_DIR=/path/to/dir/containing/model docker compose -f docker/docker-compose.yml up --buildThe server expects the model at $MODEL_DIR/model.gguf. All four services start together; Prometheus scrapes the server automatically.
# 1. Build and push the image
docker build -t your-registry/inference-server:latest .
docker push your-registry/inference-server:latest
# 2. Load the model onto the PVC (one-time; e.g. via an init job or kubectl cp)
kubectl apply -f k8s/pvc.yaml
# 3. Deploy
kubectl apply -f k8s/redis.yaml
kubectl apply -f k8s/configmap.yaml
kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/service.yaml
kubectl apply -f k8s/hpa.yamlThe HPA scales from 2 to 10 replicas at 70% CPU. All replicas share rate-limit state through Redis automatically.
POST /v1/chat/completions
{
"model": "local-model",
"messages": [
{ "role": "system", "content": "You are a helpful assistant." },
{ "role": "user", "content": "Explain KV caching." }
],
"max_tokens": 256
}Pass the tenant API key as Authorization: Bearer <key> or X-Tenant-ID: <key>.
GET /v1/models — lists the loaded model.
GET /health — returns {"status":"ok"}, used by Kubernetes probes.
POST /infer
{
"tenant_id": "team-a",
"payload": "your prompt",
"priority": "high",
"deadline_ms": 5000,
"max_new_tokens": 128
}Defined in proto/inference.proto:
service InferenceService {
rpc Infer(InferRequest) returns (InferResponse);
rpc ChatComplete(ChatRequest) returns (ChatResponse);
}Rate limits are enforced via a Redis token-bucket Lua script, so all nodes share per-tenant quota automatically. When Redis is unavailable, each node falls back to its own in-memory bucket (fail-open).
[clients]
│
nginx / Envoy ← least-connections load balancer
/ │ \
Node1 Node2 Node3 ← each runs inference_server
│
Redis ← shared rate limit state
Apply the Kubernetes manifests to deploy:
kubectl apply -f k8s/redis.yaml
kubectl apply -f k8s/configmap.yaml
kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/service.yaml
kubectl apply -f k8s/hpa.yamlThe HPA scales from 2 to 10 replicas at 70% CPU utilization. To scale on KV-slot utilization instead, install the Prometheus adapter and point an External metric at inference_kv_slot_utilization.
cd docker
docker compose up -d # Prometheus :9090, Grafana :3000Grafana default credentials: admin / admin. The Prometheus datasource and dashboard are provisioned automatically.
Key metrics:
inference_kv_slot_utilization— fraction of KV slots in use; the primary signal for engine efficiencyinference_latency_seconds— end-to-end request latency histograminference_queue_wait_seconds— time spent waiting for a free slotinference_batch_size— active sequences per decode stepinference_requests_deadline_exceeded_total— requests that expired before a slot was availableinference_requests_rate_limited_total— requests rejected by the per-tenant token bucket
MODEL_PATH=/path/to/model.gguf ./build/inference_server &
./bench/run_bench.shThe bench script mixes short and long prompts to produce variable response lengths — the workload where continuous batching's advantage is most pronounced.
16 connections
| Policy | Req/s | p50 | p99 |
|---|---|---|---|
| Continuous batch (adaptive) | 3.22 | 4826 ms | 5403 ms |
| Fixed batch | 3.22 | 4390 ms | 5386 ms |
| FIFO | 2.53 | 5151 ms | 5485 ms |
Continuous batching matches fixed-batch peak throughput while delivering ~27% more requests/sec than FIFO, with lower p99 latency under saturation.