Concurrent Local LLM Serving: Ollama NUM_PARALLEL vs vLLM Continuous Batching Benchmark
Measured P95 Time-to-First-Token (TTFT), aggregate throughput, and VRAM pre-allocation on an RTX 4090 (24GB) under 1, 4, and 8 parallel workflow workers.
NVIDIA RTX 4090 (24GB VRAM) · 64GB DDR5 · Ubuntu 24.04 · Docker Engine 27.x
Once model weights are cached on local NVMe, both Ollama and vLLM run with `--offline` / zero internet access.
- vLLM pre-allocates `--gpu-memory-utilization` (default 90% of VRAM) on startup and holds it permanently; it does NOT auto-unload after 5 minutes like Ollama.
- Raising `OLLAMA_NUM_PARALLEL` from 1 to 4 multiplies KV cache memory by 4x; if you do not lower per-slot `num_ctx` or enable `q8_0` KV cache, Ollama will spill layers to CPU.
1. Single-User Simplicity (Ollama) vs Multi-Worker Throughput (vLLM)
| Engine & Concurrency | Aggregate Throughput | P95 TTFT (Queue Wait) | Idle VRAM Behavior |
|---|---|---|---|
| Ollama (1 Worker · Q4_K_M) | 61.4 tok/s | 0.18 s | Releases to 0 GB after 5m |
| Ollama (4 Workers · NUM_PARALLEL=4) | 118.2 tok/s | 1.45 s | Releases to 0 GB after 5m |
| Ollama (8 Workers · NUM_PARALLEL=4) | 124.0 tok/s | 6.80 s (4 queued) | Releases to 0 GB after 5m |
| vLLM (8 Workers · AWQ PagedAttention) | 386.5 tok/s (3.1x) | 0.82 s | Pins 20.4 GB (--gpu-memory-utilization 0.85) |
docker run -d --name tinker-vllm --gpus all \ -p 127.0.0.1:8000:8000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ vllm/vllm-openai:v0.6.4 \ --model Qwen/Qwen2.5-14B-Instruct-AWQ \ --quantization awq \ --max-model-len 16384 \ --gpu-memory-utilization 0.85
2. Decision Rule: When to Stay on Ollama vs Switch to vLLM
3. Safe Multi-Slot Tuning in Ollama (`OLLAMA_NUM_PARALLEL` + `q8_0` KV)
Frequently Asked Questions & Edge Cases
Q: Why does vLLM fail on startup with `ValueError: The model’s max seq len (131072) is larger than the maximum number of tokens that can be stored in KV cache`?
Many models declare `131072` (128K) in their `config.json`. vLLM verifies on startup that at least one full-length sequence fits in remaining VRAM. Explicitly pass `--max-model-len 16384` (or `32768`) to match your real workload and boot cleanly on 16GB–24GB cards.
Q: Should I use AWQ, GPTQ, or GGUF weights when running vLLM on an NVIDIA GPU?
Always prefer **AWQ (4-bit)** or **FP8** (on Ada Lovelace RTX 40-series) in vLLM. vLLM’s Marlin/AWQ CUDA kernels deliver significantly higher batch throughput than GGUF, which is primarily optimized for llama.cpp / Ollama.
Multi-Worker Concurrency & P95 TTFT Benchmark Script (Ollama vs vLLM)
Fires N parallel OpenAI-compatible requests and measures individual wall-clock latency, queue stall, and aggregate tokens/sec.
#!/usr/bin/env bash
# Tinker AI — Concurrent Local LLM Throughput & Queue Latency Probe
set -euo pipefail
ENDPOINT="http://127.0.0.1:11434/v1/chat/completions"
MODEL="qwen2.5:7b"
WORKERS="4"
echo "=== Firing $WORKERS Parallel Requests against $MODEL ($ENDPOINT) ==="
START_TS=$(python3 -c 'import time; print(time.time())')
seq 1 "$WORKERS" | xargs -I{} -P "$WORKERS" bash -c '
REQ_START=$(python3 -c "import time; print(time.time())")
RESP=$(curl -sS "'"$ENDPOINT"'" -H "Content-Type: application/json" -d "{
\"model\": \"'"$MODEL"'\",
\"temperature\": 0,
\"max_tokens\": 160,
\"messages\": [{\"role\": \"user\", \"content\": \"Worker #{}: List 3 key differences between continuous batching and static request queues in 80 words.\"}]
}")
REQ_END=$(python3 -c "import time; print(time.time())")
python3 -c "
import json, sys
d = json.loads(sys.argv[1])
toks = d.get("usage", {}).get("completion_tokens", 0)
dur = float(sys.argv[3]) - float(sys.argv[2])
print(f"[Worker #{}] Completed in {dur:.2f}s | {toks} tokens ({toks/max(0.01,dur):.1f} tok/s)")
" "$RESP" "$REQ_START" "$REQ_END"
'
END_TS=$(python3 -c 'import time; print(time.time())')
python3 -c "print(f'=== Total Wall-Clock Duration for $WORKERS parallel requests: {float($END_TS)-float($START_TS):.2f}s ===')"