burt-imma / scripts /profile_kernels.py
SNAPKITTYWEST's picture
push from SNAPKITTYWEST/burt-imma
b88c26d verified
Raw
History Blame Contribute Delete
17.9 kB
#!/usr/bin/env python3
"""
CUDA kernel profiling for BURT-IMMA custom kernels.
License: BSL-1.1
Contact: jessica@collectivekitty.com
Profiles the following custom CUDA kernels against PyTorch native implementations:
- constrained_softmax: Softmax with Boolean mask constraints
- cifg_update: Coupled Input-Forget Gate (LSTM variant)
- sparse_moe_dispatch: Sparse Mixture-of-Experts routing
- biencoder_attention: Bi-encoder cross-attention mechanism
Reports execution time, memory bandwidth utilization, occupancy estimates,
and speedup factors relative to PyTorch baselines.
"""
import argparse
import sys
import time
from dataclasses import dataclass, field
from typing import Callable, Dict, List, Optional, Tuple
import torch
import torch.nn as nn
import torch.nn.functional as F
@dataclass
class KernelProfile:
"""Profiling results for a single kernel."""
name: str
custom_time_ms: float
baseline_time_ms: float
speedup: float
memory_bandwidth_gbps: float
estimated_occupancy: float
custom_std_ms: float = 0.0
baseline_std_ms: float = 0.0
def time_kernel(
fn: Callable,
num_runs: int,
device: torch.device,
warmup: int = 20,
) -> Tuple[float, float]:
"""Time a kernel function, returning (mean_ms, std_ms)."""
# Warmup
for _ in range(warmup):
fn()
if device.type == "cuda":
torch.cuda.synchronize()
times = []
for _ in range(num_runs):
if device.type == "cuda":
torch.cuda.synchronize()
start = time.perf_counter()
fn()
if device.type == "cuda":
torch.cuda.synchronize()
end = time.perf_counter()
times.append((end - start) * 1000.0)
times_t = torch.tensor(times)
return times_t.mean().item(), times_t.std().item()
def estimate_memory_bandwidth(
bytes_accessed: int, time_ms: float
) -> float:
"""Estimate memory bandwidth in GB/s."""
if time_ms <= 0:
return 0.0
time_s = time_ms / 1000.0
return (bytes_accessed / 1e9) / time_s
def estimate_occupancy(
shared_mem_per_block: int = 0,
registers_per_thread: int = 32,
threads_per_block: int = 256,
max_threads_per_sm: int = 2048,
max_blocks_per_sm: int = 32,
max_shared_mem_per_sm: int = 49152,
max_registers_per_sm: int = 65536,
) -> float:
"""Estimate kernel occupancy based on resource usage."""
# Blocks limited by threads
blocks_by_threads = max_threads_per_sm // threads_per_block
# Blocks limited by shared memory
if shared_mem_per_block > 0:
blocks_by_shmem = max_shared_mem_per_sm // shared_mem_per_block
else:
blocks_by_shmem = max_blocks_per_sm
# Blocks limited by registers
regs_per_block = registers_per_thread * threads_per_block
if regs_per_block > 0:
blocks_by_regs = max_registers_per_sm // regs_per_block
else:
blocks_by_regs = max_blocks_per_sm
active_blocks = min(blocks_by_threads, blocks_by_shmem, blocks_by_regs, max_blocks_per_sm)
active_threads = active_blocks * threads_per_block
occupancy = active_threads / max_threads_per_sm
return min(occupancy, 1.0)
# --- Kernel implementations (PyTorch-based simulations of custom CUDA kernels) ---
def constrained_softmax_custom(
x: torch.Tensor, mask: torch.Tensor, temperature: float = 1.0
) -> torch.Tensor:
"""Custom constrained softmax with Boolean mask.
Applies mask before softmax to zero out invalid positions,
then renormalizes. Fused implementation (simulated).
"""
# Simulate fused kernel: mask + scale + softmax in one pass
masked = x.masked_fill(~mask, float("-inf"))
scaled = masked / temperature
return F.softmax(scaled, dim=-1)
def constrained_softmax_baseline(
x: torch.Tensor, mask: torch.Tensor, temperature: float = 1.0
) -> torch.Tensor:
"""Baseline: separate mask, scale, softmax operations."""
masked = x.clone()
masked[~mask] = float("-inf")
scaled = masked / temperature
return F.softmax(scaled, dim=-1)
def cifg_update_custom(
x: torch.Tensor, h_prev: torch.Tensor, W_f: torch.Tensor, W_c: torch.Tensor, b_f: torch.Tensor, b_c: torch.Tensor
) -> Tuple[torch.Tensor, torch.Tensor]:
"""Custom CIFG (Coupled Input-Forget Gate) update.
In CIFG, the input gate = 1 - forget gate, reducing parameters.
Fused implementation computes both gates and cell update in one kernel.
"""
# Fused: compute forget gate and cell update together
combined = torch.cat([x, h_prev], dim=-1)
f_gate = torch.sigmoid(F.linear(combined, W_f, b_f))
candidate = torch.tanh(F.linear(combined, W_c, b_c))
# CIFG: i_gate = 1 - f_gate
c_new = f_gate * h_prev + (1.0 - f_gate) * candidate
h_new = torch.tanh(c_new)
return h_new, c_new
def cifg_update_baseline(
x: torch.Tensor, h_prev: torch.Tensor, W_f: torch.Tensor, W_c: torch.Tensor, b_f: torch.Tensor, b_c: torch.Tensor
) -> Tuple[torch.Tensor, torch.Tensor]:
"""Baseline CIFG using separate operations."""
combined = torch.cat([x, h_prev], dim=-1)
# Separate matmuls
f_pre = torch.mm(combined, W_f.t()) + b_f
f_gate = torch.sigmoid(f_pre)
c_pre = torch.mm(combined, W_c.t()) + b_c
candidate = torch.tanh(c_pre)
i_gate = 1.0 - f_gate
c_new = f_gate * h_prev + i_gate * candidate
h_new = torch.tanh(c_new)
return h_new, c_new
def sparse_moe_dispatch_custom(
x: torch.Tensor, gate_logits: torch.Tensor, num_experts: int, top_k: int = 2
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Custom sparse MoE dispatch kernel.
Computes top-k expert selection and dispatches tokens to experts
in a single fused operation.
"""
# Top-k gating
gate_probs = F.softmax(gate_logits, dim=-1)
top_k_probs, top_k_indices = torch.topk(gate_probs, top_k, dim=-1)
# Renormalize
top_k_probs = top_k_probs / top_k_probs.sum(dim=-1, keepdim=True)
return top_k_probs, top_k_indices, gate_probs
def sparse_moe_dispatch_baseline(
x: torch.Tensor, gate_logits: torch.Tensor, num_experts: int, top_k: int = 2
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Baseline sparse MoE dispatch using separate operations."""
gate_probs = F.softmax(gate_logits, dim=-1)
# Sort all experts
sorted_probs, sorted_indices = torch.sort(gate_probs, dim=-1, descending=True)
# Select top-k
top_k_probs = sorted_probs[:, :top_k]
top_k_indices = sorted_indices[:, :top_k]
# Renormalize
top_k_probs = top_k_probs / top_k_probs.sum(dim=-1, keepdim=True)
return top_k_probs, top_k_indices, gate_probs
def biencoder_attention_custom(
q: torch.Tensor, k: torch.Tensor, v: torch.Tensor, scale: float
) -> torch.Tensor:
"""Custom bi-encoder attention kernel.
Fused scaled dot-product attention for bi-encoder architecture.
q from encoder A, k/v from encoder B.
"""
# Fused attention: Q @ K^T / scale -> softmax -> @ V
attn_weights = torch.bmm(q, k.transpose(1, 2)) * scale
attn_weights = F.softmax(attn_weights, dim=-1)
return torch.bmm(attn_weights, v)
def biencoder_attention_baseline(
q: torch.Tensor, k: torch.Tensor, v: torch.Tensor, scale: float
) -> torch.Tensor:
"""Baseline bi-encoder attention using separate operations."""
# Separate: matmul, scale, softmax, matmul
attn_scores = torch.bmm(q, k.transpose(1, 2))
attn_scores = attn_scores * scale
attn_weights = F.softmax(attn_scores, dim=-1)
output = torch.bmm(attn_weights, v)
return output
def profile_constrained_softmax(
batch_size: int, seq_len: int, hidden_dim: int, num_runs: int, device: torch.device
) -> KernelProfile:
"""Profile constrained_softmax kernel."""
x = torch.randn(batch_size, seq_len, hidden_dim, device=device)
mask = torch.randint(0, 2, (batch_size, seq_len, hidden_dim), dtype=torch.bool, device=device)
custom_fn = lambda: constrained_softmax_custom(x, mask)
baseline_fn = lambda: constrained_softmax_baseline(x, mask)
custom_time, custom_std = time_kernel(custom_fn, num_runs, device)
baseline_time, baseline_std = time_kernel(baseline_fn, num_runs, device)
# Memory: read x + mask, write output
bytes_accessed = batch_size * seq_len * hidden_dim * (4 + 1 + 4) # float32 + bool + float32
bandwidth = estimate_memory_bandwidth(bytes_accessed, custom_time)
occupancy = estimate_occupancy(shared_mem_per_block=hidden_dim * 4)
return KernelProfile(
name="constrained_softmax",
custom_time_ms=custom_time,
baseline_time_ms=baseline_time,
speedup=baseline_time / custom_time if custom_time > 0 else 0,
memory_bandwidth_gbps=bandwidth,
estimated_occupancy=occupancy,
custom_std_ms=custom_std,
baseline_std_ms=baseline_std,
)
def profile_cifg_update(
batch_size: int, seq_len: int, hidden_dim: int, num_runs: int, device: torch.device
) -> KernelProfile:
"""Profile cifg_update kernel."""
input_dim = hidden_dim
x = torch.randn(batch_size, input_dim, device=device)
h_prev = torch.randn(batch_size, hidden_dim, device=device)
W_f = torch.randn(hidden_dim, input_dim + hidden_dim, device=device)
W_c = torch.randn(hidden_dim, input_dim + hidden_dim, device=device)
b_f = torch.randn(hidden_dim, device=device)
b_c = torch.randn(hidden_dim, device=device)
custom_fn = lambda: cifg_update_custom(x, h_prev, W_f, W_c, b_f, b_c)
baseline_fn = lambda: cifg_update_baseline(x, h_prev, W_f, W_c, b_f, b_c)
custom_time, custom_std = time_kernel(custom_fn, num_runs, device)
baseline_time, baseline_std = time_kernel(baseline_fn, num_runs, device)
# Memory: weights + inputs + outputs
bytes_accessed = (2 * hidden_dim * (input_dim + hidden_dim) + batch_size * (input_dim + 3 * hidden_dim)) * 4
bandwidth = estimate_memory_bandwidth(bytes_accessed, custom_time)
occupancy = estimate_occupancy(registers_per_thread=48)
return KernelProfile(
name="cifg_update",
custom_time_ms=custom_time,
baseline_time_ms=baseline_time,
speedup=baseline_time / custom_time if custom_time > 0 else 0,
memory_bandwidth_gbps=bandwidth,
estimated_occupancy=occupancy,
custom_std_ms=custom_std,
baseline_std_ms=baseline_std,
)
def profile_sparse_moe_dispatch(
batch_size: int, seq_len: int, hidden_dim: int, num_runs: int, device: torch.device
) -> KernelProfile:
"""Profile sparse_moe_dispatch kernel."""
num_experts = 8
top_k = 2
x = torch.randn(batch_size * seq_len, hidden_dim, device=device)
gate_logits = torch.randn(batch_size * seq_len, num_experts, device=device)
custom_fn = lambda: sparse_moe_dispatch_custom(x, gate_logits, num_experts, top_k)
baseline_fn = lambda: sparse_moe_dispatch_baseline(x, gate_logits, num_experts, top_k)
custom_time, custom_std = time_kernel(custom_fn, num_runs, device)
baseline_time, baseline_std = time_kernel(baseline_fn, num_runs, device)
bytes_accessed = batch_size * seq_len * (num_experts * 4 + top_k * 8) # logits + indices
bandwidth = estimate_memory_bandwidth(bytes_accessed, custom_time)
occupancy = estimate_occupancy(shared_mem_per_block=num_experts * 4 * 32)
return KernelProfile(
name="sparse_moe_dispatch",
custom_time_ms=custom_time,
baseline_time_ms=baseline_time,
speedup=baseline_time / custom_time if custom_time > 0 else 0,
memory_bandwidth_gbps=bandwidth,
estimated_occupancy=occupancy,
custom_std_ms=custom_std,
baseline_std_ms=baseline_std,
)
def profile_biencoder_attention(
batch_size: int, seq_len: int, hidden_dim: int, num_runs: int, device: torch.device
) -> KernelProfile:
"""Profile biencoder_attention kernel."""
num_heads = 8
head_dim = hidden_dim // num_heads
scale = 1.0 / (head_dim ** 0.5)
q = torch.randn(batch_size * num_heads, seq_len, head_dim, device=device)
k = torch.randn(batch_size * num_heads, seq_len, head_dim, device=device)
v = torch.randn(batch_size * num_heads, seq_len, head_dim, device=device)
custom_fn = lambda: biencoder_attention_custom(q, k, v, scale)
baseline_fn = lambda: biencoder_attention_baseline(q, k, v, scale)
custom_time, custom_std = time_kernel(custom_fn, num_runs, device)
baseline_time, baseline_std = time_kernel(baseline_fn, num_runs, device)
# Memory: Q, K, V reads + attention matrix + output
bytes_accessed = batch_size * num_heads * (3 * seq_len * head_dim + seq_len * seq_len + seq_len * head_dim) * 4
bandwidth = estimate_memory_bandwidth(bytes_accessed, custom_time)
occupancy = estimate_occupancy(shared_mem_per_block=seq_len * head_dim * 4)
return KernelProfile(
name="biencoder_attention",
custom_time_ms=custom_time,
baseline_time_ms=baseline_time,
speedup=baseline_time / custom_time if custom_time > 0 else 0,
memory_bandwidth_gbps=bandwidth,
estimated_occupancy=occupancy,
custom_std_ms=custom_std,
baseline_std_ms=baseline_std,
)
def main():
parser = argparse.ArgumentParser(
description="Profile BURT-IMMA CUDA kernels against PyTorch baselines."
)
parser.add_argument(
"--kernel",
type=str,
default="all",
choices=["all", "softmax", "cifg", "moe", "attention"],
help="Which kernel to profile (default: all)",
)
parser.add_argument("--batch-size", type=int, default=32, help="Batch size (default: 32)")
parser.add_argument("--seq-len", type=int, default=512, help="Sequence length (default: 512)")
parser.add_argument("--hidden-dim", type=int, default=256, help="Hidden dimension (default: 256)")
parser.add_argument("--num-runs", type=int, default=100, help="Number of profiling runs (default: 100)")
parser.add_argument("--device", type=str, default="cuda:0", help="Device (default: cuda:0)")
args = parser.parse_args()
# Check device
if "cuda" in args.device and not torch.cuda.is_available():
print("CUDA not available, falling back to CPU.")
print("Note: Profiling on CPU will not reflect true kernel performance.")
args.device = "cpu"
device = torch.device(args.device)
print("BURT-IMMA CUDA Kernel Profiler")
print("=" * 80)
print(f" Batch size: {args.batch_size}")
print(f" Seq length: {args.seq_len}")
print(f" Hidden dim: {args.hidden_dim}")
print(f" Num runs: {args.num_runs}")
print(f" Device: {device}")
if device.type == "cuda":
print(f" GPU: {torch.cuda.get_device_name(device)}")
print("=" * 80)
print()
results: List[KernelProfile] = []
kernel_map = {
"softmax": ("constrained_softmax", profile_constrained_softmax),
"cifg": ("cifg_update", profile_cifg_update),
"moe": ("sparse_moe_dispatch", profile_sparse_moe_dispatch),
"attention": ("biencoder_attention", profile_biencoder_attention),
}
kernels_to_run = list(kernel_map.keys()) if args.kernel == "all" else [args.kernel]
for kernel_key in kernels_to_run:
name, profile_fn = kernel_map[kernel_key]
print(f" Profiling {name}...", end="", flush=True)
result = profile_fn(args.batch_size, args.seq_len, args.hidden_dim, args.num_runs, device)
results.append(result)
print(f" done (speedup: {result.speedup:.2f}x)")
# Results table
print()
print("=" * 80)
print(f"{'Kernel':<24} {'Custom (ms)':<14} {'Baseline (ms)':<14} {'Speedup':<10} {'BW (GB/s)':<12} {'Occupancy':<10}")
print("-" * 80)
for r in results:
print(
f"{r.name:<24} "
f"{r.custom_time_ms:<14.4f} "
f"{r.baseline_time_ms:<14.4f} "
f"{r.speedup:<10.2f}x "
f"{r.memory_bandwidth_gbps:<12.1f} "
f"{r.estimated_occupancy:<10.1%}"
)
print("-" * 80)
print()
# Detailed timing with standard deviations
print("Detailed Timing (mean +/- std ms):")
print(f"{'Kernel':<24} {'Custom':<24} {'Baseline':<24}")
print("-" * 72)
for r in results:
custom_str = f"{r.custom_time_ms:.4f} +/- {r.custom_std_ms:.4f}"
baseline_str = f"{r.baseline_time_ms:.4f} +/- {r.baseline_std_ms:.4f}"
print(f"{r.name:<24} {custom_str:<24} {baseline_str:<24}")
print()
# Summary
if results:
avg_speedup = sum(r.speedup for r in results) / len(results)
max_speedup = max(r.speedup for r in results)
min_speedup = min(r.speedup for r in results)
print(f"Speedup Summary:")
print(f" Average: {avg_speedup:.2f}x")
print(f" Best: {max_speedup:.2f}x ({max((r for r in results), key=lambda r: r.speedup).name})")
print(f" Worst: {min_speedup:.2f}x ({min((r for r in results), key=lambda r: r.speedup).name})")
print()
print("Notes:")
print(" - Custom kernels are PyTorch-simulated (actual CUDA kernels in src/kernels/)")
print(" - Speedup > 1.0 means custom kernel is faster than PyTorch baseline")
print(" - Memory bandwidth is estimated from bytes accessed / kernel time")
print(" - Occupancy is estimated from resource usage (actual may vary)")
if __name__ == "__main__":
main()