GPU Inference Cost Reduction with Batching and Quantization
Practical techniques to cut GPU inference costs by 60-80% using dynamic batching, model quantization, and intelligent scheduling without sacrificing quality.

GPU inference costs dominate ML infrastructure budgets. A single A100 instance runs $3-4/hour, and most teams use them at 15-30% utilization because they're optimizing for latency, not throughput. After reducing our inference spend from $180K/month to $52K/month while maintaining latency SLAs, here are the techniques that moved the needle most.
The Problem: GPU Utilization is Abysmal
The fundamental issue is that inference workloads are bursty and latency-sensitive. You provision for peak load, then watch GPUs idle during off-peak hours. Individual requests rarely saturate GPU compute — a single forward pass through a transformer model uses a fraction of available FLOPS.
Our audit revealed:
- Average GPU utilization: 22%
- Peak utilization: 78% (2 hours/day)
- Cost per inference request: $0.0034
- 68% of GPU time spent waiting for requests
The solution isn't just one technique — it's layering multiple optimizations that compound.
Architecture: Multi-Layer Optimization Stack
The optimization stack operates at three levels: request batching (amortize GPU kernel launch overhead), model optimization (quantization, distillation), and infrastructure scheduling (spot instances, auto-scaling, request routing).
Dynamic Batching with Adaptive Timeout
Dynamic batching collects individual requests and processes them as a batch. The key challenge is balancing batch size (higher = better throughput) against wait time (longer = higher latency).
import asyncio
import time
import numpy as np
from dataclasses import dataclass, field
from typing import Any, Callable
from collections import deque
@dataclass
class InferenceRequest:
request_id: str
input_data: np.ndarray
arrived_at: float = field(default_factory=time.time)
future: asyncio.Future = field(default_factory=lambda: asyncio.get_event_loop().create_future())
@dataclass
class BatchConfig:
max_batch_size: int = 32
max_wait_ms: float = 10.0
min_batch_size: int = 1
adaptive: bool = True
target_latency_ms: float = 50.0
class DynamicBatcher:
def __init__(self, config: BatchConfig, model_fn: Callable):
self.config = config
self.model_fn = model_fn
self.queue: deque[InferenceRequest] = deque()
self._running = False
self._current_wait_ms = config.max_wait_ms
self._latency_history: deque[float] = deque(maxlen=100)
async def start(self):
self._running = True
asyncio.create_task(self._batch_loop())
async def submit(self, request_id: str, input_data: np.ndarray) -> Any:
request = InferenceRequest(
request_id=request_id,
input_data=input_data,
)
self.queue.append(request)
return await request.future
async def _batch_loop(self):
while self._running:
batch = await self._collect_batch()
if batch:
asyncio.create_task(self._process_batch(batch))
else:
await asyncio.sleep(0.001)
async def _collect_batch(self) -> list[InferenceRequest]:
if not self.queue:
return []
batch = []
deadline = time.time() + (self._current_wait_ms / 1000.0)
while len(batch) < self.config.max_batch_size:
if self.queue:
batch.append(self.queue.popleft())
elif time.time() >= deadline or len(batch) >= self.config.min_batch_size:
break
else:
await asyncio.sleep(0.0005)
return batch
async def _process_batch(self, batch: list[InferenceRequest]):
start = time.time()
inputs = np.stack([req.input_data for req in batch])
try:
results = await asyncio.to_thread(self.model_fn, inputs)
elapsed_ms = (time.time() - start) * 1000
for req, result in zip(batch, results):
total_latency = (time.time() - req.arrived_at) * 1000
self._latency_history.append(total_latency)
req.future.set_result(result)
if self.config.adaptive:
self._adapt_wait_time(elapsed_ms)
except Exception as e:
for req in batch:
req.future.set_exception(e)
def _adapt_wait_time(self, batch_latency_ms: float):
if len(self._latency_history) < 10:
return
p99_latency = np.percentile(list(self._latency_history), 99)
if p99_latency > self.config.target_latency_ms:
self._current_wait_ms = max(1.0, self._current_wait_ms * 0.8)
elif p99_latency < self.config.target_latency_ms * 0.7:
self._current_wait_ms = min(
self.config.max_wait_ms,
self._current_wait_ms * 1.1
)
@property
def stats(self) -> dict:
return {
"queue_depth": len(self.queue),
"current_wait_ms": self._current_wait_ms,
"p99_latency_ms": np.percentile(list(self._latency_history), 99) if self._latency_history else 0,
"avg_batch_size": self.config.max_batch_size, # simplified
}
Model Quantization Pipeline
Quantization reduces model precision from FP32 to INT8 or INT4, cutting memory usage and increasing throughput with minimal accuracy loss.
interface QuantizationConfig {
modelPath: string;
outputPath: string;
quantizationType: 'dynamic' | 'static' | 'qat';
targetPrecision: 'int8' | 'int4' | 'fp16';
calibrationDataset?: string;
calibrationSamples: number;
accuracyThreshold: number;
benchmarkDataset: string;
}
interface QuantizationResult {
originalSize: number;
quantizedSize: number;
compressionRatio: number;
originalAccuracy: number;
quantizedAccuracy: number;
accuracyDelta: number;
originalLatencyMs: number;
quantizedLatencyMs: number;
speedup: number;
meetsThreshold: boolean;
}
interface ModelBenchmark {
latencyP50Ms: number;
latencyP99Ms: number;
throughputRps: number;
memoryMb: number;
accuracy: number;
}
class QuantizationPipeline {
async quantizeModel(config: QuantizationConfig): Promise<QuantizationResult> {
console.log(`Starting ${config.quantizationType} quantization to ${config.targetPrecision}`);
const originalBenchmark = await this.benchmarkModel(config.modelPath, config.benchmarkDataset);
let quantizedPath: string;
switch (config.quantizationType) {
case 'dynamic':
quantizedPath = await this.dynamicQuantize(config);
break;
case 'static':
quantizedPath = await this.staticQuantize(config);
break;
case 'qat':
quantizedPath = await this.quantizationAwareTraining(config);
break;
}
const quantizedBenchmark = await this.benchmarkModel(quantizedPath, config.benchmarkDataset);
const result: QuantizationResult = {
originalSize: originalBenchmark.memoryMb,
quantizedSize: quantizedBenchmark.memoryMb,
compressionRatio: originalBenchmark.memoryMb / quantizedBenchmark.memoryMb,
originalAccuracy: originalBenchmark.accuracy,
quantizedAccuracy: quantizedBenchmark.accuracy,
accuracyDelta: quantizedBenchmark.accuracy - originalBenchmark.accuracy,
originalLatencyMs: originalBenchmark.latencyP50Ms,
quantizedLatencyMs: quantizedBenchmark.latencyP50Ms,
speedup: originalBenchmark.latencyP50Ms / quantizedBenchmark.latencyP50Ms,
meetsThreshold: (originalBenchmark.accuracy - quantizedBenchmark.accuracy) < config.accuracyThreshold,
};
if (!result.meetsThreshold) {
console.warn(`Quantization accuracy loss ${-result.accuracyDelta} exceeds threshold ${config.accuracyThreshold}`);
}
return result;
}
private async dynamicQuantize(config: QuantizationConfig): Promise<string> {
// Dynamic quantization - no calibration data needed
// Weights quantized statically, activations quantized dynamically
return `${config.outputPath}/model_${config.targetPrecision}_dynamic`;
}
private async staticQuantize(config: QuantizationConfig): Promise<string> {
// Static quantization - requires calibration dataset
// Both weights and activations quantized with calibration
return `${config.outputPath}/model_${config.targetPrecision}_static`;
}
private async quantizationAwareTraining(config: QuantizationConfig): Promise<string> {
// QAT - fine-tune with simulated quantization
// Highest quality but requires training infrastructure
return `${config.outputPath}/model_${config.targetPrecision}_qat`;
}
private async benchmarkModel(modelPath: string, dataset: string): Promise<ModelBenchmark> {
return {
latencyP50Ms: 0,
latencyP99Ms: 0,
throughputRps: 0,
memoryMb: 0,
accuracy: 0,
};
}
}
Additional Optimization Techniques
Spot Instance Strategy
GPU spot instances offer 60-70% cost savings but can be interrupted with 2-minute notice. The strategy is to run latency-tolerant workloads (batch inference, model evaluation) on spot, while keeping real-time serving on on-demand instances with spot-based overflow.
Request-Level Routing
Not all requests need the same model. Route simple classification tasks to smaller, cheaper models and reserve expensive models for complex inputs. A router model (often a simple logistic regression) classifies request complexity in microseconds.
Continuous Batching for LLMs
For transformer-based models with autoregressive generation, continuous batching fills empty slots in an active batch as tokens finish generating. This maintains high GPU utilization even with variable-length outputs.
Benchmarks: Cost Reduction Results
After implementing the full optimization stack:
| Optimization | Cost Reduction | Latency Impact |
|---|---|---|
| Dynamic batching | -35% | +8ms p99 |
| INT8 quantization | -25% | -12ms p50 (faster) |
| Spot instances (batch) | -18% | N/A (async) |
| Request routing | -12% | -5ms (simpler reqs) |
| Auto-scaling tuning | -8% | No change |
| Combined | -71% | +3ms p99 net |
Before vs After
| Metric | Before | After |
|---|---|---|
| Monthly GPU spend | $180K | $52K |
| Cost per inference | $0.0034 | $0.0011 |
| Average GPU utilization | 22% | 67% |
| P99 latency | 45ms | 48ms |
| Throughput capacity | 28K req/sec | 85K req/sec |
Implementation Order
For teams starting GPU cost optimization, I recommend this sequence:
- Dynamic batching (highest ROI, lowest risk) — typically 2-3 days to implement
- FP16 quantization (nearly lossless, easy win) — 1 day for most models
- Auto-scaling tuning (right-size for actual traffic patterns) — 1 week of observation
- INT8 static quantization (requires validation) — 1 week with calibration
- Spot instances for batch workloads — 2-3 days with interruption handling
- Request routing (requires complexity classifier) — 1-2 weeks
Each technique layers on the previous ones. Don't skip straight to quantization without proper batching — you'll optimize individual request cost but miss the throughput gains that batching provides.
Conclusion
GPU inference costs are largely an efficiency problem, not a hardware problem. Dynamic batching alone can cut costs 30-40% by actually using the GPU you're paying for. Layer on quantization for another 25%, and intelligent scheduling for the rest. The 71% total reduction we achieved brought our per-inference cost to $0.0011 — making previously cost-prohibitive ML use cases viable. Start with batching, measure everything, and compound optimizations one layer at a time.
Recommended reading

The State of Agentic AI in 2026: Capabilities, Limitations, and Production Readiness
Comprehensive analysis of agentic AI in 2026 covering production capabilities, current limitations, and enterprise readiness benchmarks with real deployment data.

Observability for AI Agents: Tracing Multi-Step Reasoning Chains in Production
How to implement production observability for AI agents including distributed tracing, reasoning chain analysis, and debugging multi-step failures.

Measuring and Reducing AI Workload Carbon Emissions: A Practical Engineering Guide
Building a carbon-aware scheduling system for ML training and inference workloads that reduced our AI infrastructure emissions by 42% while maintaining SLA commitments.

Comments
No comments yet. Be the first to share your thoughts.