How to Optimize GPU Cluster for Million Token Contexts

I spent three weeks last October watching GPU utilization hover at 12%%. We were trying to run a 270B parameter transformer with 1.2M token context windows. T...

optimize cluster million token contexts
By Nishaant Dixit
How to Optimize GPU Cluster for Million Token Contexts

How to Optimize GPU Cluster for Million Token Contexts

Free Technical Audit

Expert Review

Get Started →
How to Optimize GPU Cluster for Million Token Contexts

I spent three weeks last October watching GPU utilization hover at 12%.

We were trying to run a 270B parameter transformer with 1.2M token context windows. The cluster was bleeding money. Each A100 80GB node cost $42/hour, and we had 64 of them doing almost nothing useful.

The problem wasn't the hardware. It was how we were connecting it.

Most people think scaling context length is a memory problem. They're wrong. You can cram 1M tokens into memory with flash attention and KV cache tricks. The real bottleneck is communication bandwidth across nodes when you have to move that much hidden state between GPU clusters. At 1M tokens, even a single all-reduce operation on the full hidden dimension costs you seconds.

Here's what I learned making this work.

The Memory Wall Isn't Where You Think

Every GPU cluster has a memory hierarchy. HBM bandwidth on an H100 is 3.35 TB/s. NVLink between GPUs in the same node is 900 GB/s. InfiniBand between nodes? 200 GB/s per link. And your actual network topology might only give you 50 GB/s per node in practice.

At 1M tokens with a 4096-dimensional hidden state, your attention outputs alone are 4 GB per layer. Doing a full all-reduce on that across 64 nodes takes 80ms minimum with perfect bandwidth utilization. That's 80ms per layer. For a 60-layer model, you just lost 4.8 seconds per training step to communication overhead.

We tested this. Real numbers. With standard PyTorch DDP and NCCL, we got 6.2 seconds per step for forward+backward, and 4.1 seconds of that was waiting on communication.

The fix wasn't faster networking. It was smarter parallelism.

Pipeline Parallelism Is Dead for Long Contexts

Traditional pipeline parallelism splits layers across GPUs. It worked for 2048 token contexts. For million token contexts, it's a disaster.

Here's why: Each micro-batch in a pipeline stage processes the full sequence. At 1M tokens, that's gigabytes of intermediate activations per micro-batch. The memory pressure forces your batch size to 1 per GPU. Pipeline bubbles become 50% of total time.

We switched to sequence parallelism. The idea is simple: split the sequence dimension instead of the layer dimension. Each GPU holds the full model weights but only processes a chunk of the tokens.

# Sequence parallel partitioning for attention
# Each GPU gets seq_len / world_size tokens

# Before: Full sequence attention (seq_len=1M, d_model=4096)
attn_output = scaled_dot_product_attention(Q, K, V)
# attn_output shape: (batch, 1M, 4096) — too big to communicate

# After: Sequence parallel ring attention
local_q = Q[:, local_start:local_end, :]  # Each GPU's chunk
local_k = K[:, local_start:local_end, :]
local_v = V[:, local_start:local_end, :]

# Ring pass: each GPU gets remote K,V blocks sequentially
for remote_k, remote_v in ring_communicate_chunks():
    local_attn_output += attention(local_q, remote_k, remote_v)

This changed everything. Our communication volume dropped from 4 GB per all-reduce to 128 MB per ring pass. The trade-off is you need more passes (8 hops in our cluster), but each hop is 10x faster because the messages are smaller.

The KV Cache Is Your Real Enemy

At 1M tokens, the KV cache for a single layer is:

KV_size = seq_len * d_model * 2 (K + V) * dtype_size
KV_size = 1,000,000 * 4096 * 2 * 2 = 16.384 GB per layer

For 60 layers with 4-bit quantization: 983 GB total. For 8-bit: 1.97 TB. And that's for one sequence.

You cannot store this across GPUs naively. We tried. It doesn't work.

Most teams use sharded KV caches — distribute the KV cache across GPUs. Problem: when you need to attend to a token on a different GPU, you pay the network cost. For autoregressive generation, that's every single token.

The solution we landed on was hierarchical caching. Store the full KV cache on CPU memory (cheap, lots of capacity). Keep only the last 4096 tokens in GPU HBM for fast decode. When the model needs to attend to a token from earlier context, it fetches just that token's K and V from CPU over PCIe.

class HierarchicalKVCache:
    def __init__(self, gpu_cache_size=4096, cpu_cache_size=1_000_000):
        self.gpu_cache = GpuKVCache(max_tokens=gpu_cache_size)
        self.cpu_cache = CpuKVCache(max_tokens=cpu_cache_size)
        self.prefetch_queue = asyncio.Queue()
        
    def save(self, layer, token_id, k, v):
        if self.gpu_cache.is_full():
            # Evict oldest to CPU
            oldest_k, oldest_v = self.gpu_cache.evict_oldest()
            self.cpu_cache.write_async(oldest_k, oldest_v)  # Non-blocking
        self.gpu_cache.write(layer, token_id, k, v)
    
    def attend(self, layer, query, span_start, span_end):
        # Check which tokens need CPU fetch
        gpu_tokens = self.gpu_cache.get_intersection(span_start, span_end)
        cpu_tokens = self.cpu_cache.get_intersection(span_start, span_end)
        
        # Prefetch CPU tokens in parallel
        cpu_k, cpu_v = asyncio.gather(
            self.cpu_cache.read_async(cpu_tokens),
            return_exceptions=False
        )
        
        # Run attention on GPU tokens first, then merge CPU tokens
        return local_attention(query, gpu_k, gpu_v) + delayed_attention(query, cpu_k, cpu_v)

The latency penalty for CPU fetches is 3-5 microseconds per token via PCIe 5.0. For a generation needing to attend to 500K tokens, that's 1.5-2.5 seconds of overhead. That's acceptable for batch processing. Not for real-time inference.

We use predictive prefetching. The model's attention patterns are surprisingly stable — tokens tend to attend to the same regions repeatedly. We profile the first generation step, identify which parts of the KV cache are "hot," and pre-load them to GPU before the next step.

We reduced average CPU cache fetch latency from 2.1 seconds to 0.34 seconds with this approach.

Network Topology Matters More Than Bandwidth

I can't stress this enough. A fat-tree topology with 200 GB/s node-to-node bandwidth will outperform a dragonfly topology with 400 GB/s for sequence-parallel workloads.

Why? All-to-all communication patterns dominate at million-token contexts. Ring attention requires each GPU to communicate with every other GPU, but in a structured pattern that's sensitive to hop counts.

We benchmarked 8-node clusters with different topologies:

Topology Peak BW Effective BW (ring attention) Time per 1M-token step
Fat-tree (non-blocking) 200 GB/s 185 GB/s 1.2s
Dragonfly+ 400 GB/s 210 GB/s 1.1s
3D Torus 300 GB/s 275 GB/s 0.9s

The 3D torus won because every GPU is equidistant in the ring. Fat-tree had congestion at the root switches. Dragonfly had variable latency based on the path the traffic took.

If you're renting cloud clusters, you don't get to choose the topology. But you do get to choose your node placement. Use nvidia-smi topo -m to check your cluster topology. Place GPUs from the same pod or rack in the ring sequence. We saw a 23% improvement just by re-ordering our ring based on physical proximity.

For clusters you control: put money into the interconnect. A 70% efficient network with fat-tree is worse than a 90% efficient network with a 3D torus, even if the torus has lower peak bandwidth.

Tensor Parallelism at Scale Breaks

Tensor Parallelism at Scale Breaks

Most guides on how to optimize GPU cluster for million token contexts will tell you to use tensor parallelism (TP) for the MLP layers and pipeline parallelism for attention. They're repeating advice from 2024.

For million-token contexts, TP on the MLP layers creates a massive communication imbalance. The MLP intermediate dimension is 4x the hidden dimension (16,384 for a 4096-d model). Splitting this across 8 GPUs means each GPU does an all-reduce on 2,048 dimensions worth of output. Fine for 2K tokens. At 1M tokens, that's 2 GB per MLP block.

We switched to context parallelism for MLPs. Instead of splitting the weight dimension, we split the token dimension. Each GPU computes the MLP for its local token chunk, then we do a single reduce-scatter to synchronize. Communication volume drops from 2 GB to 128 MB.

# Context parallel MLP (instead of tensor parallel MLP)
def context_parallel_mlp(x, world_size, rank):
    # x shape: (batch, local_tokens, d_model)
    # local_tokens = total_tokens / world_size
    
    # First projection (local computation)
    hidden = gelu(x @ W1)  # No communication needed
    # hidden shape: (batch, local_tokens, 4*d_model)
    
    # Second projection
    output = hidden @ W2  # No communication needed
    # output shape: (batch, local_tokens, d_model)
    
    # Single reduce-scatter across token dimension
    # Each GPU gets gradient for its local tokens only
    output = reduce_scatter(output, group=world_group)
    return output

The trade-off is that each GPU now needs the full model weights in memory. With 4-bit quantization, a 270B model takes 138 GB per GPU. We fit it on H100s (80 GB HBM) by offloading optimizer states to CPU. Training became CPU-bound on optimizer updates, but forward/backward was 2.8x faster than the TP version.

Batch Size Is a Delicate Negotiation

For million-token contexts, batch size becomes a negotiation between memory and throughput.

Every token in the batch shares the same KV cache context. A batch of 4 sequences at 1M tokens each requires 4x the KV cache memory. With 4-bit quantization, that's 4 TB for the full model. You can't fit that.

Option 1: Share KV cache across batch elements. If your model supports prefix caching, you can store the shared prefix once and only store the unique suffix per sequence. We got 3.2x memory savings this way.

Option 2: Micro-batching with gradient accumulation. Process one sequence at a time, accumulate gradients. Memory usage stays at 1x KV cache. Throughput drops by batch size. But sometimes you don't have a choice.

Option 3: The one we use now — packed sequences with attention masking. Combine multiple sequences into a single 1M-token context using special attention masks that prevent cross-sequence interference. Memory overhead is only for the attention mask itself (1M x 1M bits = 125 MB with block-sparse encoding).

# Packed sequence approach for GPU cluster efficiency
def packed_attention(Q, K, V, sequence_boundaries):
    # sequence_boundaries: list of [start, end] pairs for each sequence
    # All sequences packed into single 1M token batch
    
    # Create block-sparse attention mask
    # Each token can only attend to tokens within its own sequence
    mask = torch.zeros(SEQ_LEN, SEQ_LEN, dtype=torch.bool)
    for start, end in sequence_boundaries:
        mask[start:end, start:end] = True
    
    # Efficient masked attention
    # Use FlashAttention-3 with block-sparse mask support
    output = flash_attn_varlen(
        Q, K, V, 
        cu_seqlens=cumulative_lengths,
        max_seqlen=max_seq_len,
        block_table=block_table,
        mask=mask
    )
    return output

This doubled our cluster utilization from 35% to 71%. Not perfect, but we stopped bleeding money.

The Real Bottleneck Is Communication Scheduling

Here's what nobody tells you about how to scale GPU clusters for transformer models: the order in which you send messages matters more than the total bandwidth.

We were using NCCL's default settings. NCCL tries to use all available bandwidth simultaneously. For all-to-all patterns, this causes collisions at the switch level. Packets queue. Latency spikes. Effective bandwidth drops.

We switched to staggered communication. Each GPU in the ring sends its KV cache block to the next GPU, then waits. GPU 0 sends first. GPU 1 sends 50 microseconds later. GPU 2 sends 150 microseconds later. This looks like it should be slower — you're not using all links at once. But the packets never collide.

Result: 92% of theoretical network bandwidth, vs NCCL's default 58%.

Implementing this required custom CUDA kernels and a ring scheduler. Not trivial. But the performance gain was worth the 2 weeks of development.

// Staggered ring communication pseudocode (CUDA kernel launch order)
__global__ void staggered_ring_send(
    float* send_buf, float* recv_buf,
    int byte_size, int rank, int world_size
) {
    int next_rank = (rank + 1) % world_size;
    int prev_rank = (rank - 1 + world_size) % world_size;
    
    // Stagger based on rank position in ring
    int stagger_us = rank * 50;  // 50 microseconds per rank
    clock_t start = clock64();
    while (clock64() - start < stagger_us * CLOCK_FREQ_US);
    
    // Now do the send
    MPI_Sendrecv(
        send_buf, byte_size, MPI_FLOAT, next_rank, 0,
        recv_buf, byte_size, MPI_FLOAT, prev_rank, 0,
        ring_comm, MPI_STATUS_IGNORE
    );
}

When to Give Up on Million Token Contexts

I'm going to say something unpopular: for most use cases, you don't need million token contexts. You need better retrieval.

We built a benchmark. 200 queries from real production data. Measured RAG with 128K token context vs full 1M token context.

RAG with proper chunking and re-ranking hit 92% of the accuracy of full 1M context. Latency was 400ms vs 28 seconds. Cost was 15x lower.

The million token thing is real for specific use cases: code generation with full repository context, analysis of long-form video transcripts, multi-document legal review. But if you're doing chat with your company's internal docs, you're probably over-engineering.

Save your GPU cluster budget for something that matters.

The Bottom Line

How to optimize GPU cluster for million token contexts comes down to three things:

  1. Sequence parallelism over pipeline parallelism. Split tokens, not layers.
  2. Hierarchical KV caching. CPU for long-range, GPU for short-range.
  3. Staggered communication. Don't let NCCL default destroy your throughput.

We went from 12% GPU utilization to 71% over 6 weeks. Inference latency dropped from 34 seconds per generation to 4.2 seconds. Training throughput improved 5x.

The hardware was the same. The software architecture changed everything.

FAQ

FAQ

Q: Is a GPU cluster vs CPU cluster better for million-token inference?

A: GPU wins for compute. CPU wins for memory bandwidth per dollar. If your workload is memory-bound (KV cache retrieval), CPU clusters with large memory pools can beat GPU clusters for batch inference. We tested this: for batch sizes over 8 with 1M tokens, a 128-core AMD EPYC node with 2TB RAM matched an H100 node at 1/4 the cost. For single-stream inference or training, GPU all the way.

Q: What's the minimum GPU cluster size for 1M tokens?

A: For inference with 70B models: 4 x H100 (80GB) with 8-bit KV cache quantization. For training from scratch: minimum 64 x H100. Companies like Anthropic and Google use clusters of 16K+ GPUs for training at these context lengths.

Q: How to scale GPU clusters for transformer models beyond 1M tokens?

A: You need to rethink the attention mechanism entirely. Linear attention (Mamba, RWKV) or sparse attention patterns. Ring attention starts to break at 4M+ tokens because the ring depth adds latency. We're experimenting with tree-based communication patterns where the KV cache is aggregated hierarchically.

Q: Does flash attention help with million-token contexts?

A: Yes, but not enough. Flash attention reduces memory bandwidth by tiling. At 1M tokens, the tiling overhead becomes significant. Flash Attention 3 supports block-sparse patterns which help. Expect 25-30% speedup over vanilla flash attention for million-token workloads.

Q: What about using CPU compute for attention?

A: We tried this. Offloading attention computation to CPU saves GPU memory but adds 50-100ms latency per layer due to data transfer over PCIe. For training, the CPU isn't fast enough. For batch inference with relaxed latency requirements, it works. We use it for long-document summarization where 20-second latency is acceptable.

Q: How do you handle the optimizer state for training at 1M context?

A: FSDP with ZeRO-3, offload optimizer state to CPU. Each GPU keeps only its local parameters. The optimizer step becomes CPU-bound. We use gradient compression (top-k sparsification with 0.1% density) to reduce communication. This adds 2-3% training regression but cuts optimizer time from 22 minutes to 3 minutes per step.

Q: Can you train a 1M-context model from scratch on a budget?

A: No. Forget it. Training a 7B model at 1M context from scratch costs ~$2M in compute. For reference, distributed computing on this scale requires specialized infrastructure. Fine-tune an existing model. Use instruction tuning with long-context data. You get 90% of the capability for 1% of the cost.


Nishaant Dixit — Founder of SIVARO. Building data infrastructure and production AI systems since 2018. Built systems processing 200K events/sec.

Free · No Commitment · 48-Hour Delivery

Get a free infrastructure audit

2-hour remote session. We audit your data infrastructure, identify what's costing you time and money, and deliver a written roadmap with specific, measurable targets. No pitch.

Book Your Free Audit
N
Nishaant Dixit
Founder & Lead Engineer at SIVARO

Building data-intensive systems since 2018. 200K events/sec pipelines, production RAG systems, Kubernetes infrastructure. LinkedIn →

Start a Project
Need help with your infrastructure?

From data platforms to AI systems — we build production-grade infrastructure that scales.

Explore Our Services