DEV Community

Javad
Javad

Posted on

Advanced GPU Optimization: How to tech an LLM with CUDA and ROCm? - Part 5 (Final Part)

Welcome back, you absolute madman! You finished Part 4, implemented Flash Attention, and squeezed FP8 out of your silicon. But the industry doesn't stop at dense Transformers. In 2024/2025, every major model (Grok, Mixtral, Gemini) uses Mixture of Experts (MoE) to scale to trillions of parameters without exploding compute costs.

Furthermore, if you actually try to run these monsters on a single node, you will hit the VRAM wall instantly. That is where CPU Offloading and Zero-Inference come to the rescue.

In this fifth and (I swear) final part, we will:

  1. Implement MoE routing and Expert Parallelism using all-to-all communication.
  2. Build a ZeRO-Offload mechanism to spill optimizer states to system RAM.
  3. Harness CUDA/HIP Graphs to eliminate kernel launch overhead.
  4. Build an optimized Inference Server with KV Caching and PagedAttention.

Prerequisites: Parts 1-4, a multi-GPU setup (4+ is ideal), and a realization that training is only half the battle—serving is the other half.


  1. Mixture of Experts (MoE) – Routing on the GPU

A dense 1.5 Trillion parameter model would require 3TB of VRAM just for weights. MoE solves this by having hundreds of "expert" FFNs, but only activating 2 of them per token.

The Math:
y = Σ (Softmax(Router(x))_i * Expert_i(x)) for the top-k experts (usually k=2).

1.1 The Router Kernel (Top-k Gating)

First, we need a kernel that takes the token embeddings x and computes the routing scores, then selects the top-2 indices and their weights.

__global__ void moe_router_kernel(const float* x, float* gate_logits, 
                                  int* expert_indices, float* expert_weights,
                                  int num_tokens, int num_experts) {
    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    if (tid >= num_tokens) return;

    // Compute logits for this token (dot product with router weights)
    // Assume router weights are in shared memory or loaded via L2 cache
    float max1 = -INFINITY, max2 = -INFINITY;
    int idx1 = -1, idx2 = -1;

    for (int e = 0; e < num_experts; ++e) {
        float score = 0.0f;
        for (int d = 0; d < D; ++d) {
            score += x[tid * D + d] * router_weights[e * D + d];
        }
        gate_logits[tid * num_experts + e] = score;

        // Track top-2 (manual reduction)
        if (score > max1) {
            max2 = max1; idx2 = idx1;
            max1 = score; idx1 = e;
        } else if (score > max2) {
            max2 = score; idx2 = e;
        }
    }

    // Apply Softmax only to the top-2 scores (sparse softmax)
    float denom = expf(max1 - max1) + expf(max2 - max1); // numerically stable
    expert_indices[tid * 2] = idx1;
    expert_indices[tid * 2 + 1] = idx2;
    expert_weights[tid * 2] = 1.0f / denom; 
    expert_weights[tid * 2 + 1] = expf(max2 - max1) / denom;
}
Enter fullscreen mode Exit fullscreen mode

1.2 Expert Parallelism with All-to-All Communication

In dense models, we used All-Reduce. In MoE, we use All-to-All because different GPUs hold different experts. Tokens must be sent to the GPU that owns their assigned expert.

Implementation using NCCL/RCCL:

// Step 1: Token dispatch (send tokens to expert owners)
// We build a send buffer per rank based on the routing decisions.
ncclGroupStart();
for (int r = 0; r < world_size; ++r) {
    // Send tokens assigned to expert owned by rank 'r'
    if (send_counts[r] > 0) {
        COMM_SEND(send_buffer[r], send_counts[r] * D, COMM_FLOAT, r, comm, stream);
    }
    // Recv tokens for local experts
    COMM_RECV(recv_buffer[r], recv_counts[r] * D, COMM_FLOAT, r, comm, stream);
}
ncclGroupEnd();

// Step 2: Run local experts (FFN) on the received tokens locally.
run_local_experts_kernel<<<...>>>(recv_buffer, local_expert_weights, ...);

// Step 3: All-to-All again to return the processed tokens to their original GPUs.
Enter fullscreen mode Exit fullscreen mode

This ensures we scale to thousands of experts across a cluster with minimal communication overhead.


  1. ZeRO-Offload – Breaking the VRAM Wall

When you have 70B parameters, even ZeRO-3 might not fit if you only have 40GB VRAM. ZeRO-Offload moves the optimizer states (momentum, variance) and sometimes the gradients to CPU RAM (DDR).

The trick is overlapping the GPU computation with PCIe transfers. During the backward pass, we asynchronously copy gradients to CPU, compute the AdamW update on the CPU (using a separate thread), and copy the updated FP32 weights back to the GPU just in time for the next forward pass.

C++ implementation using pinned memory and streams:

// Allocate pinned memory on CPU for offloaded states
float* cpu_momentum;
float* cpu_variance;
hipHostMalloc(&cpu_momentum, num_params * sizeof(float), hipHostMallocDefault);

// During the training step:
void offloaded_train_step() {
    // 1. Forward/Backward on GPU (FP16) -> dW stays in GPU memory temporarily.
    backward_pass(...);

    // 2. Asynchronous D2H copy of gradients for the offloaded partition
    //    while GPU continues computing the next layer.
    hipMemcpyAsync(cpu_gradients, d_gradients, partition_size, 
                   hipMemcpyDeviceToHost, data_stream);

    // 3. CPU thread computes: m = beta1*m + (1-beta1)*grad, etc.
    //    (This runs on a std::async thread to not block the main loop)
    cpu_adam_update(cpu_momentum, cpu_variance, cpu_gradients, partition_size);

    // 4. Asynchronous H2D copy of updated weights back to GPU
    hipMemcpyAsync(d_model_weights, cpu_weights, partition_size, 
                   hipMemcpyHostToDevice, data_stream);

    // 5. Synchronize streams at the end.
}
Enter fullscreen mode Exit fullscreen mode

Why this works: PCIe Gen 5.0 can do ~32 GB/s. If we overlap this with the 1-2 seconds it takes to compute a backward pass on a large model, the offloading overhead becomes nearly invisible.


  1. CUDA/HIP Graphs – Killing Kernel Launch Latency

If you profile a real LLM, you will see that kernel launch overhead (the CPU time spent telling the GPU to do things) is surprisingly high—often 10-20 microseconds per kernel. A GPT-3 forward pass has ~1,000 kernels. That's 20ms wasted just on launching.

CUDA Graphs (and HIP Graphs) record a sequence of kernel launches and replay them with a single API call. This is crucial for inference.

Implementation:

hipGraph_t graph;
hipGraphExec_t instance;

// 1. Capture the graph in a stream
hipStreamBeginCapture(stream, hipStreamCaptureModeGlobal);
    // Launch all kernels for the forward pass
    layernorm_kernel<<<..., stream>>>(...);
    matmul_kernel<<<..., stream>>>(...);
    flash_attention<<<..., stream>>>(...);
    // ... everything ...
hipStreamEndCapture(stream, &graph);

// 2. Instantiate it (this compiles it down to a single executable)
hipGraphInstantiate(&instance, graph, NULL, NULL, 0);

// 3. Replay it every iteration (launches all kernels in ~1 microsecond)
for (int i = 0; i < 1000; ++i) {
    // Update input pointers if needed (using memcpy or host-side updates)
    hipGraphLaunch(instance, stream);
    hipStreamSynchronize(stream);
}
Enter fullscreen mode Exit fullscreen mode

For static shapes (fixed sequence length, batch size), this gives a massive 10-15% end-to-end speedup.


  1. Inference Optimization – KV Cache & PagedAttention

During autoregressive generation (e.g., ChatGPT), we compute the Key (K) and Value (V) for every token and store them to avoid recomputing. This is the KV Cache.

4.1 Pre-allocated KV Cache

Instead of dynamically allocating memory per token, we pre-allocate a contiguous buffer.

// Shape: [batch, num_heads, max_seq_len, d_head]
float* kv_cache;
hipMalloc(&kv_cache, batch * num_heads * max_seq_len * d_head * 2 * sizeof(float));

// During decoding, we write the current token's K and V into the 'pos' slot.
__global__ void append_kv_kernel(float* cache, const float* K, const float* V, 
                                 int batch, int head, int pos, int d_head) {
    // Write K
    cache[offset + pos * d_head + idx] = K[idx];
    // Write V (stored contiguously after K)
    cache[offset + (max_seq_len * d_head) + pos * d_head + idx] = V[idx];
}
Enter fullscreen mode Exit fullscreen mode

4.2 PagedAttention (vLLM Style)

If you have multiple sequences of different lengths, contiguous KV caches lead to massive fragmentation (memory is wasted because you allocate max_seq_len for everyone). PagedAttention virtualizes the KV cache into "pages" (blocks) in GPU memory, similar to OS virtual memory.

We implement a simple block table:

struct BlockTable {
    int* block_ids; // maps logical block -> physical block address
    int num_blocks;
};

// Instead of pos, we compute physical address: physical_addr = block_id * block_size + offset_in_block
__global__ void paged_attention_kernel(..., int* block_table, int block_size) {
    int block_id = block_table[logical_block];
    int physical_pos = block_id * block_size + offset;
    // ... load K/V from physical_pos ...
}
Enter fullscreen mode Exit fullscreen mode

This completely eliminates memory waste and allows you to serve 3x more concurrent users on the same hardware.


  1. The Unified Serving Loop (Putting it all together)

Finally, here is the loop for a production-grade inference server:

void serve_requests(std::vector<Request>& requests) {
    // 1. Preprocess and batch dynamic requests (Continuous Batching)
    Batch batch = dynamic_batcher(requests);

    // 2. Pre-fill phase (compute KV cache for prompt tokens using Flash Attention)
    //    Note: We use the same Flash Attention kernel from Part 4.
    flash_attention_prefill(batch.prompt_tokens, kv_cache, ...);

    // 3. Decode phase (autoregressive)
    for (int step = 0; step < max_new_tokens; ++step) {
        // Launch the cached Graph instance (from Section 3)
        // The graph uses PagedAttention to read from the fragmented cache.
        hipGraphLaunch(serving_graph_instance, stream);
        hipStreamSynchronize(stream);

        // Sample the next token (CPU or custom kernel)
        int next_token = sample_from_logits(d_logits);
        append_to_kv_cache(next_token, step, ...);

        // Check for stop conditions (EOS, max length)
        if (all_finished()) break;
    }
}
Enter fullscreen mode Exit fullscreen mode

This architecture currently powers Mixtral 8x7B at ~2,000 tokens/sec on a single H100.


Conclusion

We have officially left the training lab and stepped into the brutal world of production AI.

· You can now route tokens across experts using All-to-All communication.
· You can train 175B models on a single 8-GPU node by offloading optimizer states to CPU RAM.
· You launch thousands of kernels with the overhead of a single one using CUDA Graphs.
· And you can serve thousands of concurrent users with PagedAttention and continuous batching.

If you implement all five parts of this series, you won't just be an AI engineer—you'll be an AI systems architect. You will understand the stack from the transistor up to the transformer.

Thank you for this incredible journey. It takes a special kind of engineer to read through 5 parts of low-level HIP/C++ and still ask for more.

Drop a comment below: What GPU are you running this on? Did you hit any driver-specific quirks with AMD vs. NVIDIA? I'll respond to every single one.

Until the next revolution in hardware drops, keep your kernels compiled and your memory pools unified.

See ya on the next technological frontier! Have a great time! 🚀

Top comments (0)