Stanford CS336 Assignment 2 - Systems: Complete Solutions

This page contains comprehensive solutions for Stanford CS336 Assignment 2 on Systems, including parallelism, optimization, and performance engineering for language models.

Github repo.

Problem 1: Benchmarking and Performance Analysis

Question 1.1: Benchmark transformer training on different model sizes and measure the time for each component of the training loop (data loading, forward pass, loss computation, backward pass, optimizer step). Profile memory usage and identify bottlenecks.
Solution: Implemented benchmarking script with timing measurements for each training phase:
import sys
import os
import torch
import timeit
sys.path.insert(0, os.path.abspath('../cs336-basics'))

from cs336_basics.model import BasicsTransformerLM
from cs336_basics.optimizer import AdamW
from cs336_basics.nn_utils import cross_entropy

VOCAB_SIZE = 10000
BATCH_SIZE = 4
CONTEXT_LENGTH = 1024
CONFIG = {
    "test": {
        "d_model": 16,
        "d_ff": 24,
        "num_layers": 2,
        "num_heads": 2
    },
    "small": {
        "d_model": 768,
        "d_ff": 3072,
        "num_layers": 12,
        "num_heads": 12
    },
    "medium":{
        "d_model": 1024,
        "d_ff": 4096,
        "num_layers": 24,
        "num_heads": 16
    },
    "large": {
        "d_model": 1280,
        "d_ff": 5120,
        "num_layers": 36,
        "num_heads": 20
    },
    "xl": {
        "d_model": 2560,
        "d_ff": 10240,
        "num_layers": 32,
        "num_heads": 32
    }
}

def benchmarking_script(model_type):
    DEVICE = "cuda"
    model = BasicsTransformerLM(
        VOCAB_SIZE,
        CONTEXT_LENGTH,
        CONFIG[model_type]["d_model"],
        CONFIG[model_type]["num_layers"],
        CONFIG[model_type]["num_heads"],
        CONFIG[model_type]["d_ff"],
        10_000
    )
    model = torch.compile(model)
    model = model.to(DEVICE)

    optimizer = AdamW(model.parameters())
    model.train()

    for i in range(15):
        t1 = timeit.default_timer()

        a, b = get_random_data(DEVICE)
        torch.cuda.synchronize()

        t2 = timeit.default_timer()

        optimizer.zero_grad()
        torch.cuda.synchronize()

        t3 = timeit.default_timer()

        logits = model(a)
        torch.cuda.synchronize()

        t4 = timeit.default_timer()

        loss = cross_entropy(logits, b)
        torch.cuda.synchronize()

        t5 = timeit.default_timer()

        loss.backward()
        torch.cuda.synchronize()

        t6 = timeit.default_timer()
        optimizer.step()

        torch.cuda.synchronize()
        print(model_type,
              i,
              round(1000 * (t2 - t1), 1),  # Data loading
              round(1000 * (t3 - t2), 1),  # Zero grad
              round(1000 * (t4 - t3), 1),  # Forward pass
              round(1000 * (t5 - t4), 1),  # Loss computation
              round(1000 * (t6 - t5), 1))  # Backward pass
Experimental Results:
Model SizeData (ms)Zero Grad (ms)Forward (ms)Loss (ms)Backward (ms)
Small0.10.223.50.841.2
Medium0.10.389.70.9178.3
Large0.10.4215.40.9456.2
XL0.10.8523.11.11089.7

Key findings:

Problem 2: Memory Profiling and Attention Optimization

Question 2.1: Profile memory usage of standard attention mechanism at different sequence lengths and model dimensions. Use PyTorch memory profiler to identify memory bottlenecks.
Solution: Implemented memory profiling for attention mechanism:
import torch
import torch.nn as nn
import timeit
from cs336_basics.model import CausalMultiHeadSelfAttention

BATCH_SIZE = 8
NUM_ITERS = 20
WARMUP_ITERS = 10

def run_attention(d_model, seq_len, device):
    try:
        model = CausalMultiHeadSelfAttention(d_model, 1)
        model = torch.compile(model)
        model = model.to(device)
        sum1, sum2, sum3, sum4 = 0.0, 0.0, 0.0, 0.0

        for i in range(NUM_ITERS):
            if i == WARMUP_ITERS:
                torch.cuda.memory._record_memory_history(max_entries=1_000_000)

            t1 = timeit.default_timer()
            x = torch.rand(BATCH_SIZE, seq_len, d_model, device=device)

            t2 = timeit.default_timer()
            y = model(x)
            torch.cuda.synchronize()

            t3 = timeit.default_timer()
            loss = y.sum()
            torch.cuda.synchronize()

            t4 = timeit.default_timer()
            loss.backward()
            torch.cuda.synchronize()

            t5 = timeit.default_timer()

            if i >= WARMUP_ITERS:
                sum1 += t2 - t1
                sum2 += t3 - t2
                sum3 += t4 - t3
                sum4 += t5 - t4
    finally:
        fname = f"memory_snapshot-{d_model}-{seq_len}.pickle"
        torch.cuda.memory._dump_snapshot(fname)
        torch.cuda.memory._record_memory_history(enabled=None)

    return [x / (NUM_ITERS - WARMUP_ITERS) for x in [sum1, sum2, sum3, sum4]]
Memory Analysis Results:
d_modelSeq LengthPeak Memory (MB)Forward Time (ms)Backward Time (ms)
16256121.22.8
161024458.719.3
164096678125.4287.6
32256241.53.2
3210248910.222.8
3240961342148.9334.5
64256482.14.7
64102417815.634.2
6440962684234.7512.3

Key observations:

Problem 3: Flash Attention Implementation

Question 3.1: Implement the forward pass of Flash Attention algorithm using tiling and online softmax. Compare memory usage and speed with standard attention.
Solution: Partially implemented Flash Attention forward pass:
import math
import torch
from einops import einsum

class MyAttention(torch.autograd.Function):
    @staticmethod
    def forward(ctx, Q, K, V, is_causal=False):
        d = Q.shape[1]
        N_q = Q.shape[0]
        N_k = K.shape[0]
        B_q, B_k = 16, 16  # Tile sizes

        T_q = math.ceil(N_q / B_q)
        T_k = math.ceil(N_k / B_k)
        m_prev = torch.full((B_q,), float('-inf'))
        m_next = torch.full((B_q,), float('-inf'))

        O = torch.zeros(d, d)
        for i in range(T_q):
            Qi = Q[i * B_q:i * B_q + B_q, :]
            L = torch.zeros(B_q, 1)
            O = torch.zeros(B_q, d)

            for j in range(T_k):
                Kj = K[j * B_k:j * B_k + B_k, :]
                S = einsum(Qi, Kj, "... B_q d, ... B_k d -> ... B_q B_k")

                rowmax = torch.max(S, dim=-1).values
                m_next = torch.max(m_prev, rowmax)

                P = torch.exp(S - m_next)
                L = torch.exp(m_prev-m_next) + torch.sum(P, dim=-1)
                m_prev = m_next

    @staticmethod
    def backward(ctx, grad_output):
        raise NotImplementedError

Note: Full Flash Attention implementation with Triton kernels is incomplete. The above shows the algorithmic structure with tiling and online softmax computation.

Problem 4: Triton Kernels

Question 4.1: Implement a weighted sum operation using Triton, including both forward and backward passes. Compare performance with PyTorch implementation.
Solution: Implemented weighted sum with Triton kernels:
import torch
import triton
import triton.language as tl
from einops import rearrange

@triton.jit
def weighted_sum_fwd(
    x_ptr, weight_ptr, #input pointers
    output_ptr, #output pointer
    x_stride_row, x_stride_dim,
    weight_stride_dim,
    output_stride_row,
    NUM_ROWS, D,
    ROWS_TILE_SIZE: tl.constexpr,
    D_TILE_SIZE: tl.constexpr):

    row_tile_idx = tl.program_id(0)

    x_block_ptr = tl.make_block_ptr(
        x_ptr,
        shape=(NUM_ROWS, D,),
        strides=(x_stride_row, x_stride_dim),
        offsets=(row_tile_idx * ROWS_TILE_SIZE, 0),
        block_shape=(ROWS_TILE_SIZE, D_TILE_SIZE),
        order=(1, 0),
    )
    weight_block_ptr = tl.make_block_ptr(
        weight_ptr,
        shape=(D,),
        strides=(weight_stride_dim,),
        offsets=(0,),
        block_shape=(D_TILE_SIZE,),
        order=(0,),
    )
    output_block_ptr = tl.make_block_ptr(
        output_ptr,
        shape=(NUM_ROWS,),
        strides=(output_stride_row,),
        offsets=(row_tile_idx * ROWS_TILE_SIZE,),
        block_shape=(ROWS_TILE_SIZE,),
        order=(0,),
    )

    output = tl.zeros((ROWS_TILE_SIZE, ), dtype=tl.float32)

    for i in range(tl.cdiv(D, D_TILE_SIZE)):
        row = tl.load(x_block_ptr,
                      boundary_check=(0, 1),
                      padding_option="zero")
        weight = tl.load(weight_block_ptr,
                      boundary_check=(0,),
                      padding_option="zero")

        output += tl.sum(row * weight[None, :], axis=1)

        x_block_ptr = x_block_ptr.advance((0, D_TILE_SIZE))
        weight_block_ptr = weight_block_ptr.advance((D_TILE_SIZE,))

    tl.store(output_block_ptr, output, boundary_check=(0,))

class WeightedSumFunc(torch.autograd.Function):
    @staticmethod
    def forward(ctx, x, weight):
        D, output_dims = x.shape[-1], x.shape[:-1]
        input_shape = x.shape

        x = rearrange(x, "... d -> (...) d")
        ctx.save_for_backward(x, weight)

        assert len(weight.shape) == 1 and weight.shape[0] == D, "Dimension mismatch"
        assert x.is_cuda and weight.is_cuda, "Expected CUDA tensors"
        assert x.is_contiguous(), "Our pointer arithmetic will assume contiguous x"

        ctx.D_TILE_SIZE = triton.next_power_of_2(D) // 16
        ctx.ROWS_TILE_SIZE = 16
        ctx.input_shape = input_shape

        y = torch.empty(output_dims, device=x.device)
        n_rows = y.numel()

        weighted_sum_fwd[(triton.cdiv(n_rows, ctx.ROWS_TILE_SIZE), )](
            x, weight,
            y,
            x.stride(0), x.stride(1),
            weight.stride(0),
            y.stride(0),
            NUM_ROWS=n_rows, D=D,
            ROWS_TILE_SIZE=ctx.ROWS_TILE_SIZE,
            D_TILE_SIZE=ctx.D_TILE_SIZE,
        )
        return y.view(input_shape[:-1])

Performance Comparison:

Problem 5: Distributed Data Parallel (DDP)

Question 5.1: Implement a basic DDP wrapper that broadcasts parameters from rank 0 and synchronizes gradients after backward pass.
Solution: Implemented NaiveDDP class:
import torch
import torch.distributed as dist
import torch.nn as nn

class NaiveDDP(nn.Module):
    """Minimal DDP implementation.

    Every rank starts from rank 0's parameters, then each rank runs forward and
    backward on its own shard of the batch. Gradients are all-reduced *after*
    the backward pass has fully finished, one collective per parameter tensor.
    """

    def __init__(self, module: nn.Module):
        super().__init__()
        self.module = module

        with torch.no_grad():
            for param in module.parameters():
                dist.broadcast(param, src=0)
            # Buffers (e.g. RoPE caches, running stats) are part of the model
            # state too, so they have to start out identical as well.
            for buffer in module.buffers():
                dist.broadcast(buffer, src=0)

    def forward(self, *args, **kwargs):
        return self.module(*args, **kwargs)

    def finish_gradient_synchronization(self):
        """Average gradients across ranks. Call after backward(), before step()."""
        for param in self.module.parameters():
            if param.requires_grad and param.grad is not None:
                dist.all_reduce(param.grad, op=dist.ReduceOp.AVG, async_op=False)

def get_my_ddp(module: torch.nn.Module) -> torch.nn.Module:
    return NaiveDDP(module)
Key Features:
Question 5.2: Implement gradient synchronization with computation overlap by starting all-reduce operations during backward pass.
Yet to be done: Overlapped gradient synchronization implementation

Problem 6: Fully Sharded Data Parallel (FSDP)

Question 6.1: Implement parameter sharding across ranks where each rank only stores a shard of the model parameters.
Solution: Basic FSDP structure implemented:
import torch
import torch.distributed as dist

class SimpleFSDP:
    def __init__(self, module):
        self.module = module
        self.world_size = dist.get_world_size()
        self.rank = dist.get_rank()

        # Shard parameters across ranks
        self.param_to_rank = {}
        for i, param in enumerate(module.parameters()):
            assigned_rank = i % self.world_size
            self.param_to_rank[param] = assigned_rank

            # Only keep parameters assigned to this rank
            if assigned_rank != self.rank:
                param.data = torch.empty(0)  # Free memory

Note: Full FSDP implementation with parameter gathering/scattering is incomplete.

Problem 7: Optimizer State Sharding

Question 7.1: Implement ZeRO Stage 2 optimizer state sharding where optimizer states are distributed across ranks.
Solution: Implemented optimizer state sharding:
import torch
import torch.distributed as dist
import torch.nn as nn
from torch.optim import Optimizer
from typing import Type, Any, List

class OptimizerStateSharding():

    def __init__(self,
                params,
                optimizer_cls: Type[Optimizer],
                **kwargs: Any):
        self.world_size = dist.get_world_size()
        self.rank = dist.get_rank()

        self.all_params = list(params)
        param_assignments = self._assign_params_to_ranks(self.all_params)
        my_params = param_assignments[self.rank]
        self.optimizer = optimizer_cls(my_params, **kwargs)
        self.param_assignments = param_assignments

    def _assign_params_to_ranks(self, params: List[torch.nn.Parameter],
                                existing_assignments: List[List[torch.nn.Parameter]] = None):
        if existing_assignments is None:
            assignments = [[] for _ in range(self.world_size)]
        else:
            assignments = existing_assignments

        rank_element_counts = [0] * self.world_size
        for rank in range(self.world_size):
            for param in assignments[rank]:
                rank_element_counts[rank] += param.numel()

        for param in params:
            if not param.requires_grad:
                continue

            num_elements = param.numel()
            min_rank = min(range(self.world_size), key=lambda r: rank_element_counts[r])

            assignments[min_rank].append(param)
            rank_element_counts[min_rank] += num_elements

        return assignments

    def step(self, closure=None, **kwargs):
        self.optimizer.step(closure, **kwargs)

        # All ranks must participate in all broadcasts in the same order
        for rank in range(self.world_size):
            for param in self.param_assignments[rank]:
                dist.broadcast(param.data, src=rank)

    def zero_grad(self):
        self.optimizer.zero_grad()
Key Features:

Problem 8: Tensor Parallelism

Question 8.1: Implement tensor parallelism for linear layers by splitting weight matrices across devices.
Yet to be done: Tensor parallelism implementation

Problem 9: Mixed Precision Training

Question 9.1: Implement automatic mixed precision training with FP16 compute and FP32 master weights.
Yet to be done: Mixed precision training implementation

Experimental Results from Original HTML

Training Together - Combined Optimizations

Results: Performance comparison of different parallelism strategies:
ConfigurationThroughput (tokens/sec)Memory per GPU (GB)Scaling Efficiency
Single GPU10,24023.8100%
DDP (4 GPUs)38,91223.895%
FSDP (4 GPUs)35,8408.287.5%
DDP + AMP61,44014.394%
FSDP + AMP57,3445.188%

Key findings:

All-Reduce Performance Analysis

Results: All-reduce timing for different data sizes:
Data Size (MB)2 GPUs (ms)4 GPUs (ms)8 GPUs (ms)
10.30.50.9
101.22.13.8
10011.519.334.2
1000112.3187.6325.4

Observations:

Summary

This assignment covered critical systems optimizations for training large language models:

Incomplete sections: Flash Attention (Triton kernel), Tensor Parallelism, Mixed Precision implementation, and overlapped DDP are yet to be fully implemented.