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#cost-optimization#inference#machine-learning
Cover image for the article: GPU Inference Cost Reduction with Batching and Quantization

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).

GPU Cost Optimization Architecture

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:

OptimizationCost ReductionLatency 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

MetricBeforeAfter
Monthly GPU spend$180K$52K
Cost per inference$0.0034$0.0011
Average GPU utilization22%67%
P99 latency45ms48ms
Throughput capacity28K req/sec85K req/sec

Implementation Order

For teams starting GPU cost optimization, I recommend this sequence:

  1. Dynamic batching (highest ROI, lowest risk) — typically 2-3 days to implement
  2. FP16 quantization (nearly lossless, easy win) — 1 day for most models
  3. Auto-scaling tuning (right-size for actual traffic patterns) — 1 week of observation
  4. INT8 static quantization (requires validation) — 1 week with calibration
  5. Spot instances for batch workloads — 2-3 days with interruption handling
  6. 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.

Comments

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