| # End-to-End Explanation: PyTorch & Triton Implementation |
| ## For someone with basic Python knowledge |
|
|
| --- |
|
|
| ## PART 1: What Are the Available Benchmarks? |
|
|
| Before diving into code, let's understand what benchmarks are available to measure performance. |
|
|
| ### Overview of Benchmarking Suite |
|
|
| The benchmarking system measures how fast the model runs and how efficiently it uses memory. It compares two setups: |
| - **Tiered Model**: Uses the SRAM/DRAM memory hierarchy (what we want to study) |
| - **Baseline Model**: Standard model without memory tiering |
|
|
| ### Files Involved: `benchmark.py` and `run_benchmark.py` |
| |
| #### A. `run_benchmark.py` - The Command-Line Interface |
|
|
| This file lets you **run benchmarks from the terminal**. Think of it as a control panel. |
|
|
| **Key command-line options:** |
|
|
| ```bash |
| # See available benchmarks and options |
| python run_benchmark.py --help |
| |
| # Compare tiered vs baseline models |
| python run_benchmark.py --mode compare --batch-sizes 1,8,32 --seq-lens 64,128 |
| |
| # Benchmark only the tiered (memory-aware) model |
| python run_benchmark.py --mode tiered --warmup 5 --iterations 50 --output results.json |
| |
| # Quick test (for learning) |
| python run_benchmark.py --mode tiered --warmup 1 --iterations 3 --batch-sizes 2 --seq-lens 16 |
| ``` |
|
|
| **What each option means:** |
|
|
| | Option | Meaning | |
| |--------|---------| |
| | `--mode` | What to benchmark: `tiered` (new model), `baseline` (standard), or `compare` (both) | |
| | `--batch-sizes` | How many samples to process at once (comma-separated: `1,8,32` means test with 1, 8, and 32 samples) | |
| | `--seq-lens` | Length of input sequences: `64,128` means test with sequences of 64 and 128 tokens | |
| | `--hidden-size` | Internal dimension of the model (default: 512) | |
| | `--H-cycles` | Number of times H-level (slow) processes data (default: 2) | |
| | `--L-cycles` | Number of times L-level (fast) processes data (default: 2) | |
| | `--H-layers` | Number of H-level transformer layers (default: 4) | |
| | `--L-layers` | Number of L-level transformer layers (default: 4) | |
| | `--warmup` | How many runs to discard before measuring (to let GPU settle) | |
| | `--iterations` | How many actual measurements to take | |
| | `--output` | Save results to JSON file | |
|
|
| --- |
|
|
| ### B. What Does `benchmark.py` Actually Measure? |
|
|
| Inside `benchmark.py`, the `BenchmarkResult` class records these metrics: |
|
|
| #### **Latency Metrics** (How fast things run, in microseconds `ΞΌs`) |
|
|
| - **L-level latency**: Time for the "fast" tier to process data |
| - **H-level latency**: Time for the "slow" tier to process data |
| - **Total inference latency**: End-to-end prediction time (in milliseconds `ms`) |
| - **h_over_l_latency_ratio**: How many times slower H-level is than L-level |
|
|
| **Why this matters:** If H-level takes 10Γ longer than L-level, we know the memory hierarchy is working β slow operations vs. fast ones. |
|
|
| #### **Memory Metrics** (How much GPU memory used) |
|
|
| - **sram_peak_mb**: Peak memory in the "fast" tier |
| - **dram_peak_mb**: Peak memory in the "slow" tier |
| - **total_gpu_memory_mb**: Total GPU memory used |
| |
| #### **Transfer Metrics** (Cost of moving data between tiers, in microseconds) |
| |
| - **h_l_transfer_mean_us**: Average time to copy data from HβL |
| - **l_h_transfer_mean_us**: Average time to copy data from LβH |
| |
| **Why this matters:** If transfers are expensive, the tiering strategy might not be worth it. |
| |
| #### **Hit Rate & Efficiency** |
| |
| - **sram_hit_rate**: How often data we tried to put in "fast" memory actually fit (0.0 = never, 1.0 = always) |
| - **memory_efficiency**: Useful compute time / total time (higher is better) |
|
|
| **Why this matters:** If hit_rate is low, data keeps spilling to slow memory and we're not getting the optimization benefit. |
| |
| #### **Triton Kernel Probes** (Direct measurement of memory latency) |
| |
| - **triton_sram_probe_latency_us**: Measured latency of "fast" memory via Triton kernels |
| - **triton_dram_probe_latency_us**: Measured latency of "slow" memory via Triton kernels |
| |
| **Why this matters:** These are the "ground truth" measurements showing the actual speed difference between SRAM and DRAM. |
| |
| --- |
| |
| ### C. How Benchmarks Are Run |
| |
| **Flow of a benchmark:** |
| |
| 1. **Create dummy data** (synthetic input tensors) |
| 2. **Warmup phase** (run N times to let GPU caches warm up) |
| - These runs are NOT counted in final results |
| 3. **Timing phase** (run N times with GPU timers active) |
| - Start GPU timer |
| - Run model inference |
| - Stop GPU timer |
| - Record elapsed time |
| 4. **Collect statistics**: min, max, mean, std dev from all the timed runs |
| 5. **Measure memory** (peak usage during runs) |
| 6. **Calculate derived metrics** (ratios, efficiency scores) |
| 7. **Output results** (to console and/or JSON file) |
| |
| --- |
| |
| ## PART 2: PyTorch Implementation β Line-by-Line |
| |
| ### File 1: `memory_tier.py` β The Memory Manager |
|
|
| This is the **PyTorch foundation** for the two-tier memory system. It mimics how GPU memory is organized (SRAM = fast cache vs. DRAM = main memory). |
|
|
| #### **Header & Imports (Lines 1-22)** |
|
|
| ```python |
| """ |
| Memory Tier Manager for HRM SRAM/DRAM implementation. |
| |
| Manages the placement of H-level and L-level hidden states across |
| GPU memory tiers and tracks all memory operations for benchmarking. |
| |
| - SRAM tier: Uses CUDA pinned memory + explicit prefetching. |
| L-level states are kept GPU-resident with minimal transfers. |
| - DRAM tier: Standard GPU global memory with transfer tracking. |
| H-level states go through normal allocation paths. |
| """ |
| |
| import time |
| from typing import Dict, List, Optional, Tuple |
| from dataclasses import dataclass, field |
| from contextlib import contextmanager |
| |
| import torch |
| ``` |
|
|
| **Explanation:** |
| - The docstring explains the purpose: manage two memory tiers |
| - `dataclass` and `field` are used to create structured data containers |
| - `contextmanager` creates a context (like `with` statements in Python) |
| - `torch` is imported to use PyTorch tensor operations |
|
|
| #### **Section 1: MemoryEvent β Tracking Individual Operations (Lines 26-31)** |
|
|
| ```python |
| @dataclass |
| class MemoryEvent: |
| """A single tracked memory operation.""" |
| tier: str # 'sram' or 'dram' |
| operation: str # 'alloc', 'load', 'store', 'transfer' |
| bytes: int |
| duration_us: float # microseconds |
| timestamp: float |
| ``` |
|
|
| **Explanation:** |
| - A `@dataclass` is like a **lightweight container** for data |
| - Each `MemoryEvent` records ONE memory operation (e.g., "allocated 1024 bytes in SRAM in 50 microseconds") |
| - Fields: |
| - `tier`: Which tier (fast or slow)? |
| - `operation`: What happened? (allocate new memory, load, store, transfer between tiers) |
| - `bytes`: How much data? |
| - `duration_us`: How long did it take? (in microseconds, where 1000 ΞΌs = 1 ms) |
| - `timestamp`: When did it happen? |
|
|
| **Real example:** |
| ```python |
| event = MemoryEvent( |
| tier='sram', |
| operation='alloc', |
| bytes=65536, |
| duration_us=123.45, |
| timestamp=1704067200.123 |
| ) |
| # Created a record: "SRAM allocation of 65536 bytes took 123.45 microseconds" |
| ``` |
|
|
| #### **Section 2: TierStats β Accumulated Statistics (Lines 35-56)** |
|
|
| ```python |
| @dataclass |
| class TierStats: |
| """Accumulated statistics for one memory tier.""" |
| total_alloc_bytes: int = 0 |
| peak_alloc_bytes: int = 0 |
| current_alloc_bytes: int = 0 |
| num_loads: int = 0 |
| num_stores: int = 0 |
| num_transfers: int = 0 |
| total_load_us: float = 0.0 |
| total_store_us: float = 0.0 |
| total_transfer_us: float = 0.0 |
| hit_count: int = 0 |
| miss_count: int = 0 |
| |
| @property |
| def hit_rate(self) -> float: |
| total = self.hit_count + self.miss_count |
| return self.hit_count / total if total > 0 else 0.0 |
| |
| @property |
| def avg_load_us(self) -> float: |
| return self.total_load_us / self.num_loads if self.num_loads > 0 else 0.0 |
| |
| @property |
| def avg_store_us(self) -> float: |
| return self.total_store_us / self.num_stores if self.num_stores > 0 else 0.0 |
| ``` |
|
|
| **Explanation:** |
| - This accumulates **total statistics** for a tier (all operations combined) |
| - `total_alloc_bytes`: Total memory ever allocated |
| - `peak_alloc_bytes`: Maximum memory used at any point |
| - `current_alloc_bytes`: Memory currently in use |
| - `num_loads`, `num_stores`: Counters for how many times data was read/written |
| - `hit_count` / `miss_count`: Success/failure for fitting data in SRAM |
| - **Properties** (`@property`): Computed on-the-fly from raw counts |
| - `hit_rate = hit_count / (hit_count + miss_count)` β percentage of successful SRAM fits |
| - `avg_load_us = total_load_us / num_loads` β average speed per load operation |
| - `avg_store_us = total_store_us / num_stores` β average speed per store operation |
|
|
| **Why properties are useful:** |
| Instead of storing `hit_rate` separately and having to update it constantly, we compute it whenever asked: `stats.hit_rate` automatically calculates the current rate. |
|
|
| --- |
|
|
| #### **Section 3: MemoryTierManager β The Main Manager Class (Lines 60-90)** |
|
|
| ```python |
| class MemoryTierManager: |
| """Coordinates SRAM/DRAM memory placement and tracking for HRM. |
| |
| In the Triton context: |
| - SRAM tier: Tensors allocated with `pin_memory` and kept on the |
| same CUDA stream as L-level computation. Triton kernels keep |
| these values in registers/shared memory via data reuse. |
| - DRAM tier: Standard `torch.cuda` tensors. Triton kernels load |
| these from global memory each time. |
| """ |
| |
| def __init__( |
| self, |
| device: torch.device, |
| enable_tracking: bool = True, |
| sram_capacity_mb: float = 48.0, # Typical L2 cache size |
| ): |
| self.device = device |
| self.enable_tracking = enable_tracking |
| self.sram_capacity_bytes = int(sram_capacity_mb * 1024 * 1024) |
| |
| # State registries |
| self._sram_tensors: Dict[str, torch.Tensor] = {} |
| self._dram_tensors: Dict[str, torch.Tensor] = {} |
| |
| # Event log |
| self._events: List[MemoryEvent] = [] |
| self._sram_stats = TierStats() |
| self._dram_stats = TierStats() |
| |
| # CUDA events for GPU timing |
| self._use_cuda = device.type == 'cuda' |
| if self._use_cuda: |
| self._sram_stream = torch.cuda.Stream(device=device) |
| self._dram_stream = torch.cuda.Stream(device=device) |
| else: |
| self._sram_stream = None |
| self._dram_stream = None |
| ``` |
|
|
| **Explanation:** |
|
|
| - **`__init__` method**: Initializes the manager when created |
| - `device`: Where to allocate (CPU or GPU?) |
| - `enable_tracking`: Should we record all operations? |
| - `sram_capacity_mb`: How much "fast" memory is available? (Typical GPU L2 cache = 48 MB) |
| |
| - **State registries:** |
| - `_sram_tensors`: Dictionary storing all tensors in SRAM (key = name, value = tensor) |
| - `_dram_tensors`: Dictionary storing all tensors in DRAM |
| - Example: `_sram_tensors['layer1_hidden'] = torch.tensor(...)` |
|
|
| - **Event log:** |
| - `_events`: A list of `MemoryEvent` objects (every operation is recorded) |
| - `_sram_stats`, `_dram_stats`: Running statistics for each tier |
|
|
| - **CUDA streams:** |
| - A stream is like a **"lane"** for GPU operations (operations in same lane execute sequentially, different lanes can run in parallel) |
| - Two separate streams (`_sram_stream`, `_dram_stream`) let us potentially run fast and slow operations concurrently |
|
|
| --- |
|
|
| #### **Section 4A: SRAM Allocation (Lines 95-123)** |
|
|
| ```python |
| def alloc_sram(self, name: str, shape: Tuple, dtype: torch.dtype) -> torch.Tensor: |
| """Allocate a tensor in the SRAM tier (GPU-resident, pinned).""" |
| # Calculate size in bytes |
| nbytes = torch.tensor([], dtype=dtype).element_size() |
| for s in shape: |
| nbytes *= s |
| |
| # Check capacity: does it fit? |
| if self._sram_stats.current_alloc_bytes + nbytes > self.sram_capacity_bytes: |
| # Doesn't fit β spill to DRAM and record a "miss" |
| self._sram_stats.miss_count += 1 |
| return self.alloc_dram(name, shape, dtype) |
| |
| # It fits! Record a "hit" |
| self._sram_stats.hit_count += 1 |
| |
| # Time the allocation |
| t0 = self._timer_start() |
| tensor = torch.zeros(shape, dtype=dtype, device=self.device) |
| |
| # Hint: keep GPU-resident (don't swap to CPU) |
| if self._use_cuda: |
| with torch.cuda.stream(self._sram_stream): |
| tensor = tensor.contiguous() |
| |
| self._sram_tensors[name] = tensor |
| dur = self._timer_end(t0) |
| |
| # Update statistics |
| self._sram_stats.total_alloc_bytes += nbytes |
| self._sram_stats.current_alloc_bytes += nbytes |
| self._sram_stats.peak_alloc_bytes = max( |
| self._sram_stats.peak_alloc_bytes, |
| self._sram_stats.current_alloc_bytes, |
| ) |
| |
| # Record the operation |
| self._record_event('sram', 'alloc', nbytes, dur) |
| return tensor |
| ``` |
|
|
| **Explanation (line by line):** |
|
|
| 1. **Calculate size:** Convert shape into bytes |
| - Example: shape `(128, 512)` with `float32` (4 bytes) = 128 Γ 512 Γ 4 = 262,144 bytes |
|
|
| 2. **Capacity check:** Does this allocation fit in SRAM? |
| - If `current_alloc_bytes + nbytes > sram_capacity_bytes`, it doesn't fit |
| - **Fall back to DRAM** and **count as a miss** (failed to use SRAM) |
| - Otherwise, **count as a hit** (successfully allocated in SRAM) |
|
|
| 3. **Timing:** Record when allocation starts (`t0`) |
|
|
| 4. **Create tensor:** `torch.zeros(shape, ...)` creates a tensor of zeros |
| - `shape`: dimensions |
| - `dtype`: data type (e.g., float32) |
| - `device`: 'cuda' or 'cpu' |
|
|
| 5. **GPU optimization:** `tensor.contiguous()` makes the data **contiguous in memory** (important for GPU performance) |
| - Done on the SRAM stream to suggest GPU placement |
|
|
| 6. **Store in registry:** Save for later: `_sram_tensors[name] = tensor` |
|
|
| 7. **Update stats:** |
| - Add `nbytes` to total and current |
| - Update peak if we're now using more than before |
|
|
| 8. **Record event:** Add this operation to the event log |
|
|
| --- |
|
|
| #### **Section 4B: DRAM Allocation (Lines 126-142)** |
|
|
| ```python |
| def alloc_dram(self, name: str, shape: Tuple, dtype: torch.dtype) -> torch.Tensor: |
| """Allocate a tensor in the DRAM tier (standard GPU memory).""" |
| nbytes = torch.tensor([], dtype=dtype).element_size() |
| for s in shape: |
| nbytes *= s |
| |
| t0 = self._timer_start() |
| tensor = torch.zeros(shape, dtype=dtype, device=self.device) |
| self._dram_tensors[name] = tensor |
| dur = self._timer_end(t0) |
| |
| self._dram_stats.total_alloc_bytes += nbytes |
| self._dram_stats.current_alloc_bytes += nbytes |
| self._dram_stats.peak_alloc_bytes = max( |
| self._dram_stats.peak_alloc_bytes, |
| self._dram_stats.current_alloc_bytes, |
| ) |
| |
| self._record_event('dram', 'alloc', nbytes, dur) |
| return tensor |
| ``` |
|
|
| **Explanation:** |
| - **Almost identical to `alloc_sram`**, but: |
| - **No capacity check** (unrestricted DRAM) |
| - **No stream optimization** (standard allocation) |
| - **Stores in `_dram_tensors`** instead of `_sram_tensors` |
| |
| --- |
| |
| #### **Section 5: Cross-Tier Transfers (Lines 147-177)** |
| |
| ```python |
| def transfer_sram_to_dram(self, name: str) -> torch.Tensor: |
| """Copy a tensor from SRAM tier to DRAM tier.""" |
| src = self._sram_tensors[name] |
| t0 = self._timer_start() |
| dst = src.clone() |
| if self._use_cuda: |
| torch.cuda.synchronize(self.device) |
| dur = self._timer_end(t0) |
| |
| self._dram_tensors[name + '_from_sram'] = dst |
| self._sram_stats.num_transfers += 1 |
| self._sram_stats.total_transfer_us += dur |
| self._record_event('sram', 'transfer', src.nelement() * src.element_size(), dur) |
| return dst |
| |
| def transfer_dram_to_sram(self, name: str) -> torch.Tensor: |
| """Copy a tensor from DRAM tier to SRAM tier.""" |
| src = self._dram_tensors[name] |
| t0 = self._timer_start() |
| dst = src.clone() |
| if self._use_cuda: |
| torch.cuda.synchronize(self.device) |
| dur = self._timer_end(t0) |
| |
| self._sram_tensors[name + '_from_dram'] = dst |
| self._dram_stats.num_transfers += 1 |
| self._dram_stats.total_transfer_us += dur |
| self._record_event('dram', 'transfer', src.nelement() * src.element_size(), dur) |
| return dst |
| ``` |
| |
| **Explanation:** |
| |
| - **HβL transfer (SRAM to DRAM):** |
| 1. Get source tensor from SRAM registry |
| 2. `clone()` = make a complete copy |
| 3. `torch.cuda.synchronize()` = wait for GPU to finish (so we time actual operation) |
| 4. Calculate duration |
| 5. Store in DRAM registry with new name |
| 6. Update transfer counter and total transfer time |
| 7. Record the event |
| |
| - **LβH transfer (DRAM to SRAM):** Same logic in reverse |
| |
| **Why clone?** We don't want to move the original; we want a copy at the destination. |
| |
| **Why synchronize?** GPU operations often run asynchronously (GPU schedules them but CPU moves on). Synchronize makes CPU wait, so we measure actual GPU time. |
| |
| --- |
| |
| #### **Section 6: Context Managers for Streams (Lines 182-198)** |
| |
| ```python |
| @contextmanager |
| def sram_context(self): |
| """Context manager that runs operations on the SRAM stream.""" |
| if self._use_cuda and self._sram_stream is not None: |
| with torch.cuda.stream(self._sram_stream): |
| yield self._sram_stream |
| else: |
| yield None |
| |
| @contextmanager |
| def dram_context(self): |
| """Context manager that runs operations on the DRAM stream.""" |
| if self._use_cuda and self._dram_stream is not None: |
| with torch.cuda.stream(self._dram_stream): |
| yield self._dram_stream |
| else: |
| yield None |
| ``` |
| |
| **Explanation:** |
| |
| A `@contextmanager` lets you use `with` statements: |
| |
| ```python |
| # Usage: |
| with memory_manager.sram_context() as stream: |
| # Operations here run on the SRAM stream |
| x = torch.matmul(a, b) # GPU runs this on _sram_stream |
| y = x + 1 |
| |
| # When we exit the block, stream is restored |
| ``` |
| |
| **Why useful?** Different operations can run on different streams in parallel, potentially overlapping computation and memory transfer. |
| |
| --- |
| |
| #### **Section 7: Statistics & Reporting (Lines 203-243)** |
| |
| ```python |
| def get_stats(self) -> Dict: |
| """Return all memory tier statistics.""" |
| return { |
| 'sram': { |
| 'peak_mb': self._sram_stats.peak_alloc_bytes / (1024 * 1024), |
| 'current_mb': self._sram_stats.current_alloc_bytes / (1024 * 1024), |
| 'hit_rate': self._sram_stats.hit_rate, |
| 'num_loads': self._sram_stats.num_loads, |
| 'num_stores': self._sram_stats.num_stores, |
| 'num_transfers': self._sram_stats.num_transfers, |
| 'avg_load_us': self._sram_stats.avg_load_us, |
| 'avg_store_us': self._sram_stats.avg_store_us, |
| 'total_transfer_us': self._sram_stats.total_transfer_us, |
| }, |
| 'dram': { |
| # Similar for DRAM... |
| }, |
| 'num_events': len(self._events), |
| } |
| |
| def get_events(self) -> List[MemoryEvent]: |
| """Return raw event log.""" |
| return list(self._events) |
| |
| def reset_stats(self): |
| """Clear all statistics and event log.""" |
| self._events.clear() |
| self._sram_stats = TierStats() |
| self._dram_stats = TierStats() |
| |
| def free_all(self): |
| """Release all managed tensors.""" |
| self._sram_tensors.clear() |
| self._dram_tensors.clear() |
| self._sram_stats.current_alloc_bytes = 0 |
| self._dram_stats.current_alloc_bytes = 0 |
| ``` |
| |
| **Explanation:** |
| |
| - **`get_stats()`**: Returns a dictionary with all collected metrics (ready to print or save) |
| - Converts bytes to MB (divide by 1024Β²) |
| - Includes hit rate, counts, timing |
|
|
| - **`get_events()`**: Returns the raw event log (for detailed analysis) |
| |
| - **`reset_stats()`**: Clear counters before a new benchmark run (don't contaminate results with previous runs) |
|
|
| - **`free_all()`**: Release memory and reset current allocations (cleanup) |
| |
| --- |
| |
| #### **Section 8: Internal Timing Helpers (Lines 248-257)** |
| |
| ```python |
| def _timer_start(self) -> float: |
| if self._use_cuda: |
| torch.cuda.synchronize(self.device) |
| return time.perf_counter() |
| |
| def _timer_end(self, t0: float) -> float: |
| if self._use_cuda: |
| torch.cuda.synchronize(self.device) |
| return (time.perf_counter() - t0) * 1e6 # β microseconds |
| |
| def _record_event(self, tier: str, op: str, nbytes: int, dur_us: float): |
| if self.enable_tracking: |
| self._events.append(MemoryEvent( |
| tier=tier, operation=op, bytes=nbytes, |
| duration_us=dur_us, timestamp=time.time(), |
| )) |
| ``` |
| |
| **Explanation:** |
|
|
| - **`_timer_start()`:** |
| - Synchronize GPU first (wait for all pending operations) |
| - Record CPU time |
| - Returns the start time |
|
|
| - **`_timer_end(t0)`:** |
| - Synchronize GPU (wait for all operations since start) |
| - Calculate elapsed time |
| - Convert to microseconds (Γ 1,000,000) |
|
|
| - **`_record_event()`:** |
| - If tracking is enabled, create a `MemoryEvent` and add to log |
| - Otherwise, skip (for performance when we don't need detailed logs) |
|
|
| --- |
|
|
| ### File 2: `softmax.py` β Simple PyTorch Reference |
|
|
| ```python |
| import torch |
| import torch.nn.functional as F |
| sample = torch.tensor([[1,2,3,4,5], [5,4,3,2,1]], dtype=torch.float32, device='cuda') |
| ref = F.softmax(sample, dim=1) |
| print(f"Softmax result: {ref=}") |
| ``` |
|
|
| **Explanation:** |
|
|
| This is a **minimal reference implementation** showing: |
| 1. Create a sample tensor: 2 rows Γ 5 columns of numbers |
| 2. Apply softmax along dimension 1 (across columns) |
| 3. Print the result |
|
|
| **What softmax does:** Converts scores to probabilities (sum to 1). Example: |
| - Input `[1, 2, 3, 4, 5]` |
| - Output ~`[0.67, 0.18, 0.05, 0.01, 0.006]` (largest inputs become largest probabilities) |
|
|
| This is a building block used in attention mechanisms in transformers. |
|
|
| --- |
|
|
| ## PART 3: Triton Implementation β Line-by-Line |
|
|
| ### File: `triton_kernels.py` β GPU Kernels for Memory-Aware Compute |
| |
| Triton is a language for writing GPU kernels (low-level GPU programs). Unlike PyTorch which relies on pre-built library functions, Triton lets us control **exactly** how data flows through GPU memory. |
| |
| #### **Header & Imports (Lines 1-23)** |
| |
| ```python |
| """ |
| Triton kernels for HRM SRAM/DRAM memory-tiered operations. |
| |
| Key idea: In Triton, SRAM = registers + shared memory (managed by compiler |
| within a block). DRAM = global memory (GPU HBM). By structuring kernels to keep |
| L-level state tile-resident (loaded once, reused many times within a block), |
| we ensure L-level stays in SRAM. H-level state is loaded from global memory |
| (DRAM) each cycle, paying the full memory bandwidth cost. |
| |
| This gives us real, measurable latency differences that map to the HRM's |
| hierarchical update frequencies. |
| """ |
| |
| import torch |
| import triton |
| import triton.language as tl |
| import math |
| ``` |
| |
| **Explanation:** |
| |
| - Key insight: **Data locality matters enormously** on GPUs |
| - SRAM (L1/L2 cache, registers, shared memory): ~1-4 cycles latency, 100s of GB/s bandwidth |
| - DRAM (global memory, HBM): ~200-400 cycles latency, 100s of GB/s like SRAM but way higher latency! |
| |
| **The trick:** Keep "important" data (L-level) in SRAM by reusing it within a kernel block. Load "less critical" data (H-level) fresh each time from DRAM. |
| |
| --- |
| |
| #### **Kernel 1: SRAM-Resident RMS-Norm + Residual (Lines 28-64)** |
| |
| ```python |
| @triton.jit |
| def _rms_norm_residual_fused_kernel( |
| X_ptr, # Input tensor (residual branch) |
| Residual_ptr, # Residual connection input |
| Out_ptr, # Output tensor |
| N: tl.constexpr, # Hidden dimension (constexpr β compiler tiles in SRAM) |
| eps: tl.constexpr, |
| BLOCK_N: tl.constexpr, |
| ): |
| """Fused RMS-norm + residual add. |
| |
| By making N and BLOCK_N constexpr, the compiler keeps the entire hidden |
| vector in registers/shared-memory (SRAM) across the norm computation. |
| This is the kernel used for L-level (fast path). |
| """ |
| row = tl.program_id(0) |
| cols = tl.arange(0, BLOCK_N) |
| mask = cols < N |
| |
| # ---- Load both inputs into SRAM (registers) in one shot ---- |
| x = tl.load(X_ptr + row * N + cols, mask=mask, other=0.0).to(tl.float32) |
| r = tl.load(Residual_ptr + row * N + cols, mask=mask, other=0.0).to(tl.float32) |
| |
| # Residual add β stays in registers |
| h = x + r |
| |
| # RMS norm β entirely in registers, no global memory round-trip |
| variance = tl.sum(h * h, axis=0) / N |
| h_norm = h * tl.math.rsqrt(variance + eps) |
| |
| # ---- Store back to global memory ---- |
| tl.store(Out_ptr + row * N + cols, h_norm.to(tl.bfloat16), mask=mask) |
| ``` |
| |
| **Explanation (line by line):** |
|
|
| 1. **Function signature:** |
| - `@triton.jit`: This is GPU code (JIT-compiled, not Python) |
| - `X_ptr`, `Residual_ptr`, `Out_ptr`: **Pointers** to tensors in GPU memory |
| - `N: tl.constexpr`: "constexpr" = compiler treats as a constant (compile-time value, not runtime) |
| - `BLOCK_N: tl.constexpr`: Block size (constexpr tells compiler to optimize for fixed size) |
|
|
| 2. **Get thread ID:** |
| - `row = tl.program_id(0)`: Which "row" is this GPU thread processing? |
| - On GPU, thousands of threads run in parallel; each processes one row |
|
|
| 3. **Create index array:** |
| - `cols = tl.arange(0, BLOCK_N)`: Array `[0, 1, 2, ..., BLOCK_N-1]` |
| - `mask = cols < N`: Boolean mask to handle if N < BLOCK_N (some threads might not have data) |
| |
| 4. **Load data into registers:** |
| - `tl.load(X_ptr + row * N + cols, ...)`: Load elements from memory |
| - `row * N`: Start at row-th row |
| - `+ cols`: Load all columns for this row |
| - `mask=mask`: Only load valid columns |
| - `other=0.0`: If invalid column, use 0.0 |
| - `.to(tl.float32)`: Convert to 32-bit floats for math |
| - **KEY**: All data now in registers/shared memory (SRAM)! |
|
|
| 5. **Compute residual:** |
| - `h = x + r`: Add the two inputs (element-wise, all in registers) |
|
|
| 6. **Compute RMS norm:** |
| - RMS = sqrt(mean(xΒ²)) |
| - `variance = tl.sum(h * h, axis=0) / N`: Compute mean of squares |
| - `h_norm = h * tl.math.rsqrt(variance + eps)`: Normalize by reciprocal square root |
| - `eps`: Small number to avoid division by zero |
| - **KEY**: All arithmetic in registers! |
|
|
| 7. **Store result:** |
| - `tl.store(Out_ptr + ..., h_norm.to(tl.bfloat16), mask=mask)` |
| - Write normalized result back to global memory |
| - `.to(tl.bfloat16)`: Convert to lower precision for storage (saves memory bandwidth) |
|
|
| **Why this is fast:** |
| - Load data once β keep in SRAM β do many operations β store once |
| - If we did this in PyTorch with separate ops (add, then norm), we'd load/store twice |
|
|
| --- |
|
|
| #### **Kernel 2: DRAM-Sourced RMS-Norm + Residual (Lines 68-95)** |
|
|
| ```python |
| @triton.jit |
| def _rms_norm_residual_dram_kernel( |
| X_ptr, |
| Residual_ptr, |
| Out_ptr, |
| N: tl.constexpr, |
| eps: tl.constexpr, |
| BLOCK_N: tl.constexpr, |
| ): |
| """RMS-norm + residual for H-level. |
| |
| Structurally identical but designed to be called with larger strides |
| and without re-use inside a meta-kernel. Each call does a full |
| DRAM round-trip, modeling the slower H-level memory access pattern. |
| """ |
| row = tl.program_id(0) |
| cols = tl.arange(0, BLOCK_N) |
| mask = cols < N |
| |
| # Global memory load (DRAM) |
| x = tl.load(X_ptr + row * N + cols, mask=mask, other=0.0).to(tl.float32) |
| r = tl.load(Residual_ptr + row * N + cols, mask=mask, other=0.0).to(tl.float32) |
| |
| h = x + r |
| variance = tl.sum(h * h, axis=0) / N |
| h_norm = h * tl.math.rsqrt(variance + eps) |
| |
| tl.store(Out_ptr + row * N + cols, h_norm.to(tl.bfloat16), mask=mask) |
| ``` |
|
|
| **Explanation:** |
|
|
| **Identical kernel code**, but: |
| - **Intent is different**: This is called less frequently (H-level runs slower) |
| - **Memory behavior differs** via **calling context**: |
| - SRAM kernel: Data reused many times within tight loops β stays in cache |
| - DRAM kernel: Data used once then discarded β evicted from cache |
|
|
| **In code**, they're identical because the difference is **how often and how much** they're called, not the kernel itself. |
|
|
| --- |
|
|
| #### **Kernel 3: SwiGLU Activation (Lines 100-126)** |
|
|
| ```python |
| @triton.jit |
| def _swiglu_fused_sram_kernel( |
| GateUp_ptr, # [rows, 2 * inter] β gate and up projections concatenated |
| Out_ptr, # [rows, inter] |
| inter: tl.constexpr, |
| BLOCK_INTER: tl.constexpr, |
| ): |
| """Fused SiLU(gate) * up in a single kernel pass. |
| |
| For L-level: The gate and up vectors are loaded once into registers |
| and the activation is computed without spilling to DRAM. |
| """ |
| row = tl.program_id(0) |
| cols = tl.arange(0, BLOCK_INTER) |
| mask = cols < inter |
| |
| # Load gate and up from contiguous memory β both go into SRAM |
| gate = tl.load(GateUp_ptr + row * 2 * inter + cols, mask=mask, other=0.0).to(tl.float32) |
| up = tl.load(GateUp_ptr + row * 2 * inter + inter + cols, mask=mask, other=0.0).to(tl.float32) |
| |
| # SiLU(gate) * up β entirely in registers |
| silu_gate = gate * tl.sigmoid(gate) |
| result = silu_gate * up |
| |
| tl.store(Out_ptr + row * inter + cols, result.to(tl.bfloat16), mask=mask) |
| ``` |
|
|
| **Explanation:** |
|
|
| **SwiGLU** = A gating mechanism in transformers. Formula: `output = sigmoid(gate) * up` |
|
|
| - **Input layout:** `GateUp_ptr` contains concatenated `[gate | up]` (e.g., first half is gate, second half is up) |
| - **Load both halves:** |
| - Gate: `row * 2 * inter + cols` (first half) |
| - Up: `row * 2 * inter + inter + cols` (second half) |
| - **Compute SiLU:** |
| - `tl.sigmoid(gate)`: Apply sigmoid (s-shaped function) to gate |
| - `silu_gate = gate * sigmoid(gate)`: SiLU = gate * sigmoid(gate) |
| - `result = silu_gate * up`: Gated output |
| - **Store:** Result back to global memory |
|
|
| **Why fused?** If done separately: |
| 1. Load gate |
| 2. Compute sigmoid |
| 3. Write intermediate |
| 4. Load intermediate |
| 5. Compute SiLU |
| 6. Write intermediate |
| 7. Load up |
| 8. Compute product |
| 9. Write result |
|
|
| Fused does it in one kernel β one load, one store. |
|
|
| --- |
|
|
| #### **Kernel 4: State Transfer (Lines 131-148)** |
|
|
| ```python |
| @triton.jit |
| def _state_transfer_kernel( |
| Src_ptr, |
| Dst_ptr, |
| numel: tl.constexpr, |
| BLOCK: tl.constexpr, |
| ): |
| """Explicit memory copy kernel for cross-tier state transfer. |
| |
| Used when H-level needs to read L-level output (or vice versa). |
| Triton compiles this into optimized async memcpy instructions. |
| """ |
| pid = tl.program_id(0) |
| offsets = pid * BLOCK + tl.arange(0, BLOCK) |
| mask = offsets < numel |
| |
| data = tl.load(Src_ptr + offsets, mask=mask, other=0.0) |
| tl.store(Dst_ptr + offsets, data, mask=mask) |
| ``` |
|
|
| **Explanation:** |
|
|
| Simple memcpy kernel (copy data from source to destination): |
| - `pid = tl.program_id(0)`: Program/thread block ID |
| - `offsets = pid * BLOCK + tl.arange(0, BLOCK)`: Calculate which elements this block handles |
| - If BLOCK=1024, pid=0 handles offsets 0-1023, pid=1 handles 1024-2047, etc. |
| - `mask = offsets < numel`: Don't copy past the end |
| - Load and store in parallel across many threads |
|
|
| **Why separate kernel?** GPU memcopy can be optimized by the compiler (uses memory controllers, not just compute cores). |
|
|
| --- |
|
|
| #### **Kernel 5: Memory Latency Probe (Lines 153-176)** |
|
|
| ```python |
| @triton.jit |
| def _memory_latency_probe_kernel( |
| Data_ptr, |
| Out_ptr, |
| N: tl.constexpr, |
| BLOCK_N: tl.constexpr, |
| NUM_ITERS: tl.constexpr, |
| ): |
| """Probe kernel to measure effective memory latency. |
| |
| Performs NUM_ITERS dependent loads to measure true SRAM vs DRAM latency. |
| The data dependency chain prevents compiler reordering. |
| """ |
| pid = tl.program_id(0) |
| cols = tl.arange(0, BLOCK_N) |
| mask = cols < N |
| |
| # Initial load from global memory |
| acc = tl.load(Data_ptr + pid * N + cols, mask=mask, other=0.0) |
| |
| # Dependent iteration chain β forces sequential memory access |
| for _ in range(NUM_ITERS): |
| # This stays in SRAM (registers) because acc is reused |
| acc = acc * 1.00001 + 0.00001 |
| |
| tl.store(Out_ptr + pid * N + cols, acc, mask=mask) |
| ``` |
|
|
| **Explanation:** |
|
|
| This kernel **measures latency** by creating a **dependency chain** that can't be optimized away: |
|
|
| 1. Load initial data |
| 2. For N iterations: |
| - Multiply by 1.00001 + add 0.00001 (cheap operations) |
| - Result depends on previous iteration (creates dependency) |
| 3. Store result |
|
|
| **Why not just load/store?** The compiler could optimize away separate load-stores, but a **dependency chain** forces real latency measurement. |
|
|
| **For SRAM data:** |
| - Data stays in registers |
| - All iterations hit registers (super fast) |
| - Total time β NUM_ITERS Γ 1 cycle β very fast |
| |
| **For DRAM data:** |
| - Data in global memory |
| - Each iteration reloads from DRAM |
| - Total time β NUM_ITERS Γ 200-400 cycles β slow! |
|
|
| **Result:** By comparing SRAM vs DRAM probe times, we measure the latency difference. |
|
|
| --- |
|
|
| #### **Python Wrappers (Lines 184-265)** |
|
|
| ```python |
| def _next_power_of_2(n: int) -> int: |
| return 1 << (n - 1).bit_length() |
| |
| def triton_rms_norm_residual_sram( |
| x: torch.Tensor, |
| residual: torch.Tensor, |
| eps: float = 1e-5, |
| ) -> torch.Tensor: |
| """SRAM-optimized fused RMS-norm + residual for L-level.""" |
| assert x.shape == residual.shape |
| assert x.is_contiguous() and residual.is_contiguous() |
| |
| rows, N = x.shape[0] * (x.shape[1] if x.ndim == 3 else 1), x.shape[-1] |
| flat_x = x.reshape(rows, N) |
| flat_r = residual.reshape(rows, N) |
| out = torch.empty_like(flat_x) |
| |
| BLOCK_N = _next_power_of_2(N) |
| |
| _rms_norm_residual_fused_kernel[(rows,)]( |
| flat_x, flat_r, out, |
| N=N, eps=eps, BLOCK_N=BLOCK_N, |
| ) |
| return out.reshape(x.shape) |
| ``` |
|
|
| **Explanation:** |
|
|
| - **`_next_power_of_2(n)`**: Find smallest power of 2 β₯ n |
| - Example: `_next_power_of_2(512)` β 512, `_next_power_of_2(513)` β 1024 |
| - (Bit manipulation: `(n-1).bit_length()` gives number of bits, `1 << x` is 2^x) |
| - GPUs work best with powers of 2 (thread block sizes) |
|
|
| - **`triton_rms_norm_residual_sram(...)`**: Python wrapper to call the Triton kernel |
| - Checks inputs are same shape and contiguous |
| - Flattens to 2D (rows Γ hidden_size) |
| - Creates output tensor |
| - Picks block size (nearest power of 2) |
| - Launches kernel with `[(rows,)]` β one thread block per row |
| - Reshapes output back to original |
| |
| **Why a wrapper?** Triton kernels are GPU code; we need Python to: |
| 1. Prepare data (reshape, allocate output) |
| 2. Launch the kernel (call the JIT-compiled function) |
| 3. Return result to CPU |
| |
| --- |
| |
| ## PART 4: How PyTorch & Triton Work Together |
| |
| ### The Full Pipeline |
| |
| ``` |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| β Input Data (e.g., transformer input) β |
| ββββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββ |
| β |
| ββββββββββββββΌβββββββββββββ |
| β MemoryTierManager β |
| β allocates L-level βββββ β |
| β allocates H-level βββ β β |
| ββββββββββββββββββ¬βββββ β β |
| β β β |
| ββββββββββββββββββββββΌβββ β β |
| β L-level (Fast Path) β β β |
| β Triton kernels: β β β |
| β - SRAM RMS+Residual β β β |
| β - SRAM SwiGLU β β β |
| β (Data in regs/cache) β β β |
| ββββββββββββββββββ¬βββββββ β β |
| β β β |
| ββββββββββββββββββββββΌβββββββββ β β |
| β H-level (Slow Path) β β β |
| β Triton kernels: β β β |
| β - DRAM RMS+Residual β β β |
| β (Data in global memory) β β β |
| ββββββββββββββββββ¬ββββββββββββ β β |
| β β β |
| βββββββββββββββββββββΌββββββββββββββ β |
| β Triton State Transfer: ββ β |
| β Move LβH and HβL states ββββββββΌβ β |
| β (what MemoryTierManager times) β β |
| βββββββββββββββββββββ¬βββββββββββββ β |
| β β |
| ββββββββββββββΌβββββββββββββ β |
| β Output β β |
| β (result tensor) β β |
| ββββββββββββββββββββββββββ β |
| β |
| βββββββββββββββββββββΌ |
| β Statistics collected: |
| β - L/H latency |
| β - Transfer time |
| β - Memory usage |
| β - Hit rate |
| ``` |
| |
| ### Real Example: Forward Pass |
| |
| ```python |
| # 1. Create memory manager |
| mem_mgr = MemoryTierManager(device='cuda') |
|
|
| # 2. Allocate L-level (fast) hidden state |
| L_hidden = mem_mgr.alloc_sram('L_state', shape=(batch, hidden_dim), dtype=torch.float32) |
| # β Records allocation in SRAM if it fits, otherwise spills to DRAM |
| |
| # 3. Allocate H-level (slow) hidden state |
| H_hidden = mem_mgr.alloc_dram('H_state', shape=(batch, hidden_dim), dtype=torch.float32) |
| # β Records allocation in DRAM |
|
|
| # 4. L-level computes using SRAM kernel (fast) |
| with mem_mgr.sram_context(): |
| L_out = triton_rms_norm_residual_sram(L_hidden, residual, eps=1e-5) |
| L_out = triton_swiglu_sram(gate_up, inter_dim) |
| # All data in registers/shared memory |
| # Time recorded: ~10-100 microseconds per operation |
| |
| # 5. H-level computes using DRAM kernel (slower) |
| with mem_mgr.dram_context(): |
| H_out = triton_rms_norm_residual_dram(H_hidden, residual, eps=1e-5) |
| # Data loaded from global memory each time |
| # Time recorded: ~100-1000 microseconds per operation |
| |
| # 6. Transfer L-level output to H-level |
| L_to_H_output = mem_mgr.transfer_sram_to_dram('L_out') |
| # β Records transfer size and time |
|
|
| # 7. Collect metrics |
| stats = mem_mgr.get_stats() |
| print(f"L/H latency ratio: {stats['H_latency'] / stats['L_latency']}") |
| # β Usually 10-100x difference |
| ``` |
| |
| --- |
| |
| ## PART 5: Bringing It All Together |
| |
| ### What Happens When You Run a Benchmark |
| |
| ``` |
| python run_benchmark.py --mode tiered --iterations 10 |
| ``` |
| |
| 1. **Setup:** |
| - Create MemoryTierManager |
| - Create model (HRM_Tiered) |
| - Create dummy batch of data |
|
|
| 2. **Warmup phase (first 5 runs):** |
| - Forward pass (not timed) |
| - GPU caches warm up, compilers warm up |
| - Discarded from results |
|
|
| 3. **Benchmark phase (10 timed runs):** |
| - For each iteration: |
| - Start GPU timer |
| - Forward pass: |
| - L-level uses SRAM kernels (fast) |
| - H-level uses DRAM kernels (slow) |
| - Record latencies in mem_mgr |
| - Stop GPU timer |
| - Record elapsed time |
| |
| 4. **Statistics:** |
| - Calculate mean, min, max, std dev of times |
| - Get memory stats (peak, current usage) |
| - Get transfer stats (HβL copy times) |
| - Calculate derived metrics (ratios, efficiency) |
| - Get Triton probe latencies (direct SRAM vs DRAM measurement) |
| |
| 5. **Output:** |
| - Print table of results |
| - Save to JSON |
| - Optionally generate plots |
| |
| --- |
| |
| ## Key Insights |
| |
| ### Why Two Implementations? |
| |
| | Aspect | PyTorch | Triton | |
| |--------|---------|--------| |
| | **What it expresses** | Algorithmic intent | Micro-architecture intent | |
| | **Memory control** | Coarse (allocate tensor) | Fine (exact register usage) | |
| | **Performance** | Relies on library kernels | Direct GPU control | |
| | **Latency** | Can hide memory issues | Exposes memory latency differences | |
| | **Usability** | Easy to write | Low-level, harder to write | |
| |
| ### Why SRAM vs DRAM? |
| |
| **The 200Γ Latency Difference:** |
| - SRAM (L2 cache): Load time ~4 cycles = ~2 nanoseconds = **very fast** |
| - DRAM (HBM): Load time ~400 cycles = **200 nanoseconds = 200x slower!** |
| |
| By **exploiting this difference**, we can: |
| - Keep "important" (L-level) data in fast SRAM |
| - Allow "less important" (H-level) data to use slow DRAM |
| - Model hierarchical computation: fast frequent + slow infrequent |
| |
| ### Why Fuse Operations? |
| |
| **Without fusion:** |
| ``` |
| Load β Compute β Store β Load β Compute β Store |
| ``` |
| Multiple memory round-trips! |
| |
| **With fusion:** |
| ``` |
| Load β Compute β Compute β Compute β Store |
| ``` |
| One load, many operations, one store. **Massive speedup!** |
| |
| --- |
| |
| ## Testing It Yourself |
| |
| ### Quick test (2 minutes): |
| ```bash |
| cd test-env/HRM_optimised |
| python run_benchmark.py --mode tiered --warmup 1 --iterations 3 --batch-sizes 2 --seq-lens 16 |
| ``` |
| |
| ### Full benchmark (10 minutes): |
| ```bash |
| python run_benchmark.py --mode compare --warmup 5 --iterations 20 --batch-sizes 1,8,32 --seq-lens 64,128 |
| ``` |
| |
| ### Analyze results: |
| ```python |
| import json |
| with open('benchmark_results/results.json') as f: |
| results = json.load(f) |
| print(f"L/H latency ratio: {results[0]['h_over_l_latency_ratio']}") |
| print(f"SRAM hit rate: {results[0]['sram_hit_rate']}") |
| print(f"Memory efficiency: {results[0]['memory_efficiency']}") |
| ``` |
| |
| --- |
| |
| ## Summary |
| |
| You now understand: |
| |
| β
**Benchmarks** β What metrics are collected and why |
| β
**PyTorch layer** β How `MemoryTierManager` tracks two memory tiers |
| β
**Triton layer** β How kernels control GPU memory access patterns |
| β
**Integration** β How they work together to model hierarchical computation |
| β
**Performance** β Why SRAM vs DRAM and data fusion matter for speed |
| |
| The key idea: **Different parts of the model operate at different timescales (L-level fast, H-level slow), and we can model this using GPU memory hierarchy (SRAM fast, DRAM slow).** |
| |