Embedding 685 million texts in 32 minutes
Architecting High-Throughput Batch Embedding Pipelines: From Serving Paradigms to File-to-File Engines
Current Situation Analysis
The modern machine learning pipeline has a hidden tax: corpus-scale embedding. Whether you are building behavioral intelligence models, training retrieval-augmented generation systems, or constructing dense vector indexes for semantic search, the prerequisite is identical. You must convert hundreds of millions of raw text records into fixed-dimensional numerical representations. The industry treats this as a solved problem, but the reality is that most embedding infrastructure is fundamentally misaligned with batch production workloads.
The core pain point is iteration velocity. Embedding is rarely a one-pass operation. Data scientists filter noise, adjust preprocessing rules, swap model architectures, and validate output distributions. A single pipeline version typically requires three to five full execution cycles before the output meets quality thresholds. If your baseline wall-clock time is three hours, you are burning fifteen hours of compute and engineering time just to validate one architectural decision. This creates a severe feedback loop bottleneck. Teams either accept suboptimal embeddings to save time, or they pay a premium for extended cloud instances while waiting for Python-based inference loops to finish.
The misunderstanding stems from how embedding tooling is marketed and designed. Nearly every popular framework assumes a request-response paradigm. You spin up a server, expose an HTTP or gRPC endpoint, and let clients push batches. This architecture optimizes for latency, connection pooling, and request routing. It introduces serialization boundaries, context switches, and network stack overhead that are negligible for serving but catastrophic for throughput. When you are processing 685 million records, the overhead between pipeline stages compounds exponentially. The hardware spends more time waiting for work than executing compute.
Empirical evidence from production runs confirms this structural mismatch. A heavily optimized Python pipeline using PyTorch, BF16 precision, and manual multi-GPU dispatch typically caps at 35,000 to 45,000 messages per second across eight NVIDIA A100 GPUs. Pushing past this ceiling requires fighting the Global Interpreter Lock (GIL), managing library version coupling, and wrestling with framework abstractions that hide latency. The CPU becomes the limiting factor long before the GPUs reach saturation. For a 33-million parameter model, CPU-side tokenization and tensor preparation consume 25-30% of the total cycle time. No amount of hyperparameter tuning or framework switching resolves a structural bottleneck. The solution requires abandoning the serving model entirely and rebuilding the pipeline as a single-process, file-to-file batch engine.
WOW Moment: Key Findings
The breakthrough occurs when you stop optimizing inference and start optimizing data flow. By eliminating inter-process communication, adopting lock-free concurrency, and binding directly to the GPU execution layer, throughput scales non-linearly. The following comparison demonstrates the performance delta between traditional serving stacks, optimized Python inference, and a purpose-built batch engine.
| Approach | 1-GPU Throughput | 8-GPU Throughput | Scaling Efficiency | Hardware Cost (Full Run) |
|---|---|---|---|---|
| Python/PyTorch (Optimized) | ~2,500 msg/s | ~45,000 msg/s | 18.0x | ~$38.50 (3+ hours) |
| HuggingFace TEI (Serving) | ~16,648 msg/s | ~96,492 msg/s | 5.8x | ~$17.80 (1.5 hours) |
| Batch Engine (Rust + TensorRT) | ~50,127 msg/s | ~253,578 msg/s | 5.1x | ~$6.75 (32 minutes) |
The data reveals a counterintuitive truth: scaling efficiency drops as throughput increases, but absolute throughput skyrockets. The batch engine achieves 5.1x scaling on eight GPUs for smaller models like e5-small-v2, compared to 7.4x scaling for larger models like e5-large-v2. This inverse relationship exists because smaller models complete GPU inference faster than the CPU can tokenize the next batch. The GPUs idle waiting for CPU work. Larger models naturally balance the pipeline because inference latency gives the CPU time to prepare subsequent batches.
This finding matters because it shifts the optimization target. You are no longer chasing GPU utilization percentages. You are engineering a balanced producer-consumer system where CPU tokenization, memory bandwidth, and GPU compute operate in lockstep. At 357,893 messages per second sustained, embedding transforms from a scheduling constraint into a deterministic build step. The cost per million embeddings drops from $1.36 (API) or $0.05 (TEI) to $0.01. More importantly, the feedback loop shrinks from days to minutes, enabling rapid experimentation with filtering logic, model variants, and preprocessing strategies.
Core Solution
Building a high-throughput batch embedding engine requires abandoning framework abstractions and designing a pipeline that owns every stage of execution. The architecture must eliminate serialization, enforce lock-free data movement, and maintain strict CPU/GPU balance.
Step 1: Pipeline Topology
The pipeline operates as a linear sequence of concurrent stages, each running on dedicated thread pools or NUMA nodes:
Reader β Tokenizer β Batcher β GPU Dispatcher β Writer
Each stage communicates via lock-free channels. While the GPU dispatcher executes batch N, the tokenizer prepares batch N+1, and the writer flushes batch N-1 to disk. This overlapping execution ensures zero idle cycles across hardware components.
Step 2: Lock-Free Concurrency Implementation
Traditional thread pools introduce mutex contention and context switching overhead. The solution uses single-producer, single-consumer (SPSC) channels for adjacent stages, and multi-producer, single-consumer (MPSC) channels where fan-out occurs. Memory is pre-allocated in ring buffers to avoid runtime allocation during the hot path.
use std::sync::Arc;
use crossbeam::channel::{bounded, Receiver, Sender};
use std::thread;
pub struct VectorPipeline {
gpu_count: usize,
batch_capacity: usize,
}
impl VectorPipeline {
pub fn new(gpus: usize, max_batch: usize) -> Self {
Self { gpu_count: gpus, batch_capacity: max_batch }
}
pub fn execute(&self, input_path: &str, output_path: &str) -> Result<(), PipelineError> {
let (chunk_tx, chunk_rx) = bounded::<TextChunk>(64);
let (token_tx, token_rx) = bounded::<TokenizedBatch>(32);
let (embed_tx, embed_rx) = bounded::<EmbeddingMatrix>(16);
// Stage 1: File Reader
let reader_handle = thread::spawn(move || {
Self::stream_chunks(input_path, chunk_tx);
});
// Stage 2: Parallel Tokenization
let tokenizer_handles: Vec<_> = (0..8).map(|_| {
let rx = chunk_rx.clone();
let tx = token_tx.clone();
thread::spawn(move || Self::tokenize_loop(rx, tx))
}).collect();
// Stage 3: GPU Dispatch & TensorRT Execution
let gpu_handle = thread::spawn(move || {
Self::dispatch_to_gpus(token_rx, embed_tx, self.gpu_count);
});
// Stage 4: Async Writer
let writer_handle = thread::spawn(move || {
Self::flush_embeddings(embed_rx, output_path);
});
// Join and propagate errors
reader_handle.join().unwrap()?;
for h in tokenizer_handles { h.join().unwrap()?; }
gpu_handle.join().unwrap()?;
writer_handle.join().unwrap()?;
Ok(())
}
}
Step 3: Direct TensorRT Integration
ONNX Runtime introduces abstraction layers that mask execution provider failures and add CPU overhead. Binding directly to TensorRT via FFI eliminates this gap. The engine compiles once per GPU architecture, caches the serialized plan, and executes with minimal host-device synchronization.
pub struct TrtInferenceContext {
engine_handle: *mut tensorrt_sys::ICudaEngine,
execution_context: *mut tensorrt_sys::IExecutionContext,
input_buffer: Vec<f32>,
output_buffer: Vec<f32>,
}
impl TrtInferenceContext {
pub fn load_from_cache(model_hash: &str, gpu_id: i32) -> Result<Self, TrtError> {
let cache_path = format!("/cache/engine_{}_gpu{}.plan", model_hash, gpu_id);
let serialized_engine = std::fs::read(cache_path)?;
let runtime = tensorrt_sys::create_infer_runtime();
let engine = unsafe {
runtime.deserialize_cuda_engine(serialized_engine.as_ptr(), serialized_engine.len())
};
let ctx = unsafe { engine.create_execution_context() };
Ok(Self { engine_handle: engine, execution_context: ctx, input_buffer: vec![], output_buffer: vec![] })
}
pub fn run_batch(&mut self, input_ids: &[u32], attention_mask: &[u32]) -> &[f32] {
unsafe {
// Bind input buffers
self.execution_context.set_tensor_address("input_ids", input_ids.as_ptr() as *const _);
self.execution_context.set_tensor_address("attention_mask", attention_mask.as_ptr() as *const _);
// Execute synchronously for batch throughput
self.execution_context.enqueue_v3(std::ptr::null_mut());
// Return output reference
let output_ptr = self.execution_context.get_tensor_address("embeddings");
std::slice::from_raw_parts(output_ptr as *const f32, self.output_buffer.len())
}
}
}
Step 4: CPU/GPU Balance & Dynamic Batching
The pipeline must adapt to variable-length texts. Static batch sizes waste compute on padding tokens. The batcher enforces a maximum token budget per batch (e.g., 4096 tokens) rather than a fixed record count. This ensures GPU tensor cores operate at consistent utilization regardless of input distribution. CPU tokenization threads are pinned to specific NUMA nodes to minimize cross-socket memory latency. The GPU dispatcher monitors queue depth and dynamically adjusts batch sizes to prevent CPU starvation or GPU idle time.
Pitfall Guide
1. GIL Serialization in Python Frameworks
Explanation: PyTorch and HuggingFace tokenizers hold the Global Interpreter Lock during tensor creation, device transfers, and tokenizer wrapper calls. Even with multi-threading, only one CPU core can prepare data at a time. GPUs spend 60-70% of their cycle waiting for the next batch. Fix: Migrate to a language without a GIL. Use Rust or C++ for the orchestration layer. If Python is mandatory, use multiprocessing with shared memory arrays, but accept that IPC overhead will cap throughput at ~50K msg/s.
2. torch.compile Recompilation Storms
Explanation: CUDA graphs require static tensor shapes. When processing variable-length social media text, padding lengths change constantly. torch.compile triggers a multi-second recompilation for each new shape. On eight GPUs, simultaneous recompilations can hang a 96-vCPU machine for over an hour.
Fix: Disable torch.compile for dynamic batch workloads. Use explicit padding to power-of-two boundaries or rely on TensorRT's dynamic shape optimization, which handles shape variance at the execution layer without host-side recompilation.
3. Silent ONNX Runtime Fallback
Explanation: ONNX Runtime searches for CUDA libraries in standard system paths. PyTorch installs CUDA under non-standard pip directories. When the execution provider fails to initialize, ONNX silently falls back to the CPU executor. Throughput drops by 100x, and the failure is indistinguishable from a hang.
Fix: Validate the execution provider immediately after session creation. Throw a hard error if the provider is not TensorrtExecutionProvider. Alternatively, bypass ONNX entirely and use direct TensorRT FFI to eliminate the abstraction layer.
4. Ignoring the CPU Tokenization Ceiling
Explanation: Small models like e5-small-v2 complete GPU inference in microseconds. The CPU cannot tokenize and pad texts fast enough to keep eight GPUs saturated. Scaling efficiency drops to 5.1x instead of the theoretical 8x.
Fix: Over-provision CPU cores relative to GPUs. Use lock-free queues to minimize synchronization latency. Accept that small models will be CPU-bound, or switch to larger models where inference latency naturally balances the pipeline. Monitor CPU tokenization throughput vs GPU inference throughput to identify the bottleneck.
5. Over-Engineering Inter-Process Communication
Explanation: HTTP servers, gRPC endpoints, and container-per-GPU deployments introduce serialization, network stack overhead, and context switches. Each boundary adds 1-5ms of latency. Across 685 million records, this compounds to hours of wasted time. Fix: Use a single-process architecture. Keep all stages in the same address space. Communicate via lock-free channels or memory-mapped files. Eliminate network protocols entirely for batch workloads.
6. Unmanaged TensorRT Engine Compilation
Explanation: TensorRT compiles optimized kernels for the specific GPU architecture on first use. This takes approximately five minutes per GPU. Without caching, every pipeline restart incurs this penalty.
Fix: Implement deterministic engine caching. Hash the model weights, tokenizer configuration, and GPU compute capability. Store the serialized .plan files in a shared cache directory. Validate cache integrity on startup and skip compilation if a valid engine exists.
7. Static Batch Sizing for Variable-Length Texts
Explanation: Fixed record counts per batch (e.g., 256 texts) cause severe compute waste when texts vary from 10 to 2000 tokens. Padding tokens consume memory bandwidth and tensor core cycles without contributing to the embedding. Fix: Implement token-budget batching. Accumulate texts until the total token count approaches the model's maximum sequence length (e.g., 4096). This maintains consistent GPU utilization and reduces padding overhead by 40-60%.
Production Bundle
Action Checklist
- Pin CPU tokenization threads to specific NUMA nodes to minimize cross-socket memory latency
- Validate TensorRT execution provider initialization and fail fast on library path mismatches
- Implement deterministic engine caching using model hash + GPU architecture fingerprint
- Monitor CPU tokenization throughput vs GPU inference throughput to identify the limiting stage
- Use lock-free SPSC/MPSC channels between pipeline stages to eliminate mutex contention
- Compress input/output I/O using
zstdorlz4to saturate NVMe bandwidth without CPU bottlenecks - Benchmark with production-representative data, not synthetic uniform datasets
- Set dynamic batch limits based on token budget, not record count, to minimize padding waste
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Low-latency API serving (<100ms) | HuggingFace TEI or Triton | Optimized for request routing, connection pooling, and concurrent client handling | Higher per-request cost, but necessary for interactive apps |
| Batch corpus embedding (>100M records) | Single-process Rust + TensorRT | Eliminates IPC/HTTP overhead, lock-free concurrency, direct GPU binding | ~$6.75 for 685M records on spot A100s |
| Multi-architecture deployment (AMD/Apple) | ONNX Runtime + CPU/GPU fallback | TensorRT is NVIDIA-exclusive; ONNX provides cross-vendor compatibility | 2-3x slower than native TRT, but hardware-agnostic |
| Rapid prototyping / small datasets (<1M records) | Python + sentence-transformers |
Low setup overhead, familiar ecosystem, sufficient for iteration | Acceptable for dev, prohibitive for production scale |
Configuration Template
pipeline:
concurrency:
tokenizer_threads: 16
gpu_workers: 8
writer_threads: 4
batching:
max_tokens_per_batch: 4096
padding_strategy: "dynamic"
overflow_policy: "split"
hardware:
numa_binding: true
cpu_pin_mask: "0-15"
gpu_ids: [0, 1, 2, 3, 4, 5, 6, 7]
inference:
provider: "tensorrt"
precision: "fp16"
cache_dir: "/var/cache/vector-engines"
engine_timeout_sec: 300
io:
input_format: "jsonl"
compression: "zstd"
output_format: "npy"
chunk_size_mb: 512
Quick Start Guide
- Prepare the environment: Install NVIDIA drivers, CUDA toolkit, and TensorRT libraries. Ensure
LD_LIBRARY_PATHincludes TensorRT binaries. Verify GPU visibility withnvidia-smi. - Cache the engine: Run the pipeline with a small 1000-record sample. The engine will compile and serialize to the configured cache directory. Subsequent runs will load the cached plan instantly.
- Configure the pipeline: Adjust
tokenizer_threadsto match available CPU cores. Setmax_tokens_per_batchbased on your model's sequence limit. Enablenuma_bindingif running on multi-socket servers. - Execute the batch: Point the CLI to your compressed JSONL corpus and specify the output path. Monitor GPU utilization with
nvtopand CPU tokenization throughput withhtop. Expect sustained throughput within 5 minutes of startup. - Validate output: Check the
.npyfile shape matches(total_records, embedding_dimension). Run a cosine similarity check on a subset to verify embedding quality matches the reference model.
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
