Spaces:
Running
Running
Download gpu.mjs from Osama-Ansar/gpu-visualized: direct link, hf CLI and curl.
- Browser
- Download file 18.9 kB
-
https://huggingface.co/spaces/Osama-Ansar/gpu-visualized/resolve/main/gpu.mjs
- Command line
-
hf download hf://spaces/Osama-Ansar/gpu-visualized/gpu.mjs
-
curl -L -o gpu.mjs https://huggingface.co/spaces/Osama-Ansar/gpu-visualized/resolve/main/gpu.mjs
18.9 kB
| /** | |
| * gpu.mjs — the model behind the visualization. | |
| * | |
| * Two kinds of numbers live in this file, and they are kept strictly apart: | |
| * | |
| * 1. SPECIFICATIONS (`GPUS`) — copied from the vendor's own datasheet / | |
| * architecture whitepaper. Each entry carries a `source`. Nothing here is | |
| * measured, estimated, or rounded to taste. | |
| * | |
| * 2. DERIVED QUANTITIES — everything else. Computed from (1) by the functions | |
| * below, which are covered by `gpu.test.mjs`. `derivedFp32Tflops()` exists | |
| * specifically so the test suite can check the spec table against itself: | |
| * SMs x cores x 2 x clock must reproduce the published TFLOPS. | |
| * | |
| * Where a quantity is genuinely an estimate (how many FLOPs a GELU costs), it is | |
| * a named exported constant with the assumption written next to it, so it can be | |
| * seen and changed rather than buried inside a formula. | |
| * | |
| * Runs unmodified in Node and in the browser. No dependencies. | |
| */ | |
| export const WARP_SIZE = 32; | |
| /** Bytes per element, by tensor dtype. */ | |
| export const DTYPE_BYTES = { fp32: 4, tf32: 4, bf16: 2, fp16: 2, fp8: 1, int8: 1 }; | |
| /** | |
| * Allocation granularity on compute capability 7.0+ (Volta, Turing, Ampere, Ada, | |
| * Hopper): registers are handed to a warp in units of 256, shared memory to a | |
| * block in units of 128 bytes. | |
| */ | |
| export const REG_ALLOC_UNIT_PER_WARP = 256; | |
| export const SMEM_ALLOC_UNIT_BYTES = 128; | |
| /* ------------------------------------------------------------------ * | |
| * 1. Published specifications | |
| * ------------------------------------------------------------------ */ | |
| export const GPUS = { | |
| a100: { | |
| id: 'a100', | |
| name: 'A100 SXM4 80GB', | |
| arch: 'Ampere (GA100)', | |
| cc: '8.0', | |
| source: 'NVIDIA A100 Tensor Core GPU datasheet + NVIDIA Ampere GA100 whitepaper', | |
| sms: 108, | |
| fp32CoresPerSm: 64, | |
| tensorCoresPerSm: 4, | |
| tensorCoreGen: '3rd gen', | |
| clockBoostMhz: 1410, | |
| // Peak throughput, dense (no structural sparsity), in TFLOPS. | |
| fp32Tflops: 19.5, | |
| tf32TensorTflops: 156, | |
| fp16TensorTflops: 312, | |
| tensorAccumulateNote: 'FP16/BF16 multiply, FP32 accumulate', | |
| memoryType: 'HBM2e', | |
| memoryGb: 80, | |
| bandwidthGbs: 2039, | |
| l2Mb: 40, | |
| // Unified L1 / shared memory per SM, and the most one block may claim. | |
| l1SmemPerSmKb: 192, | |
| maxSmemPerBlockKb: 164, | |
| registersPerSm: 65536, | |
| maxThreadsPerSm: 2048, | |
| maxWarpsPerSm: 64, | |
| maxBlocksPerSm: 32, | |
| maxThreadsPerBlock: 1024, | |
| }, | |
| h100: { | |
| id: 'h100', | |
| name: 'H100 SXM5 80GB', | |
| arch: 'Hopper (GH100)', | |
| cc: '9.0', | |
| source: 'NVIDIA H100 Tensor Core GPU datasheet + NVIDIA Hopper GH100 whitepaper', | |
| sms: 132, | |
| fp32CoresPerSm: 128, | |
| tensorCoresPerSm: 4, | |
| tensorCoreGen: '4th gen', | |
| clockBoostMhz: 1980, | |
| fp32Tflops: 67, | |
| tf32TensorTflops: 494.5, | |
| fp16TensorTflops: 989.4, | |
| fp8TensorTflops: 1978.9, | |
| tensorAccumulateNote: 'FP16/BF16 multiply, FP32 accumulate', | |
| memoryType: 'HBM3', | |
| memoryGb: 80, | |
| bandwidthGbs: 3350, | |
| l2Mb: 50, | |
| l1SmemPerSmKb: 256, | |
| maxSmemPerBlockKb: 227, | |
| registersPerSm: 65536, | |
| maxThreadsPerSm: 2048, | |
| maxWarpsPerSm: 64, | |
| maxBlocksPerSm: 32, | |
| maxThreadsPerBlock: 1024, | |
| }, | |
| rtx4090: { | |
| id: 'rtx4090', | |
| name: 'GeForce RTX 4090', | |
| arch: 'Ada Lovelace (AD102)', | |
| cc: '8.9', | |
| source: 'NVIDIA RTX 4090 specifications + NVIDIA Ada GPU architecture whitepaper', | |
| sms: 128, | |
| fp32CoresPerSm: 128, | |
| tensorCoresPerSm: 4, | |
| tensorCoreGen: '4th gen', | |
| clockBoostMhz: 2520, | |
| fp32Tflops: 82.6, | |
| // Consumer Ada runs tensor ops at half rate when accumulating in FP32. | |
| fp16TensorTflops: 165.2, | |
| tensorAccumulateNote: | |
| 'FP16 multiply, FP32 accumulate (consumer Ada runs FP32 accumulate at half rate)', | |
| memoryType: 'GDDR6X', | |
| memoryGb: 24, | |
| bandwidthGbs: 1008, | |
| l2Mb: 72, | |
| l1SmemPerSmKb: 128, | |
| maxSmemPerBlockKb: 100, | |
| registersPerSm: 65536, | |
| maxThreadsPerSm: 1536, | |
| maxWarpsPerSm: 48, | |
| maxBlocksPerSm: 24, | |
| maxThreadsPerBlock: 1024, | |
| }, | |
| }; | |
| export const GPU_IDS = Object.keys(GPUS); | |
| /** | |
| * Approximate load-to-use latencies, in SM clock cycles. These are NOT vendor | |
| * specifications — no vendor publishes them. They come from published | |
| * microbenchmark studies (Jia et al., "Dissecting the NVIDIA Volta / Turing / | |
| * Ampere GPU architecture via microbenchmarking") and vary with architecture, | |
| * access pattern and contention, so they are given as ranges and should be read | |
| * as orders of magnitude rather than as measurements of any particular chip. | |
| */ | |
| export const LATENCY_CYCLES = [ | |
| { level: 'Register', min: 1, max: 6, note: 'operand collector / dependent-issue latency' }, | |
| { level: 'Shared memory (L1)', min: 20, max: 40, note: 'conflict-free access' }, | |
| { level: 'L2 cache', min: 190, max: 300, note: 'on-die, shared by every SM' }, | |
| { level: 'Global (HBM)', min: 400, max: 800, note: 'off-chip DRAM, on an L2 miss' }, | |
| ]; | |
| export const LATENCY_SOURCE = | |
| 'Jia et al., microbenchmark studies of Volta/Turing/Ampere. Ranges, not vendor specs.'; | |
| /* ------------------------------------------------------------------ * | |
| * 2. Derived quantities | |
| * ------------------------------------------------------------------ */ | |
| /** | |
| * Peak FP32 throughput implied by the structural specs: | |
| * SMs x FP32 lanes/SM x 2 FLOP (one fused multiply-add) x clock. | |
| * The test suite asserts this reproduces each published `fp32Tflops`, which is | |
| * what keeps the spec table honest. | |
| */ | |
| export function derivedFp32Tflops(gpu) { | |
| return (gpu.sms * gpu.fp32CoresPerSm * 2 * gpu.clockBoostMhz * 1e6) / 1e12; | |
| } | |
| export function totalFp32Cores(gpu) { | |
| return gpu.sms * gpu.fp32CoresPerSm; | |
| } | |
| /** Peak tensor-core throughput used for roofline purposes, in FLOP/s. */ | |
| export function peakTensorFlops(gpu) { | |
| return (gpu.fp16TensorTflops ?? gpu.fp32Tflops) * 1e12; | |
| } | |
| export function peakBandwidthBytesPerSec(gpu) { | |
| return gpu.bandwidthGbs * 1e9; | |
| } | |
| /** | |
| * Arithmetic intensity: FLOPs performed per byte moved to or from off-chip | |
| * memory. The single most useful number in GPU performance work. | |
| */ | |
| export function arithmeticIntensity(flops, bytes) { | |
| if (bytes <= 0) return Infinity; | |
| return flops / bytes; | |
| } | |
| /** | |
| * The roofline: you cannot exceed the hardware's peak FLOP/s, and you cannot | |
| * exceed bandwidth x arithmetic intensity. | |
| */ | |
| export function attainableFlops(ai, peakFlops, peakBandwidth) { | |
| return Math.min(peakFlops, ai * peakBandwidth); | |
| } | |
| /** | |
| * The ridge point — the arithmetic intensity at which a kernel stops being | |
| * limited by memory and starts being limited by arithmetic. Below it, no amount | |
| * of faster math helps. | |
| */ | |
| export function ridgePoint(peakFlops, peakBandwidth) { | |
| return peakFlops / peakBandwidth; | |
| } | |
| export function isMemoryBound(ai, peakFlops, peakBandwidth) { | |
| return ai < ridgePoint(peakFlops, peakBandwidth); | |
| } | |
| /* ------------------------------ GEMM ------------------------------ */ | |
| /** C[MxN] = A[MxK] . B[KxN]. One multiply and one add per inner-product term. */ | |
| export function gemmFlops(m, n, k) { | |
| return 2 * m * n * k; | |
| } | |
| /** Minimum off-chip traffic: read A, read B, write C, each exactly once. */ | |
| export function gemmBytes(m, n, k, bytesPerElement) { | |
| return bytesPerElement * (m * k + k * n + m * n); | |
| } | |
| export function gemmIntensity(m, n, k, bytesPerElement) { | |
| return arithmeticIntensity(gemmFlops(m, n, k), gemmBytes(m, n, k, bytesPerElement)); | |
| } | |
| /* --------------------------- Tiled matmul --------------------------- */ | |
| /** | |
| * Off-chip traffic for an NxN matmul, counted in elements. | |
| * | |
| * Naive — every thread computes one C element by streaming a full row of A and a | |
| * full column of B from global memory: 2N reads per element over N^2 elements. | |
| * | |
| * Tiled — each block cooperatively stages a `tile` x `tile` patch of A and of B | |
| * in shared memory and reuses every staged value `tile` times. A block walks | |
| * N/tile tile-pairs, reading 2 * (N/tile) * tile^2 elements; there are (N/tile)^2 | |
| * blocks, so 2N^3/tile in total. | |
| * | |
| * The reduction is exactly a factor of `tile`. That factor is the entire reason | |
| * shared memory exists. | |
| */ | |
| export function matmulTraffic(n, tile, bytesPerElement = 4) { | |
| const naiveReads = 2 * n ** 3; | |
| const tiledReads = tile > 0 ? (2 * n ** 3) / tile : Infinity; | |
| const writes = n ** 2; | |
| return { | |
| naiveReads, | |
| tiledReads, | |
| writes, | |
| naiveTotal: naiveReads + writes, | |
| tiledTotal: tiledReads + writes, | |
| naiveBytes: (naiveReads + writes) * bytesPerElement, | |
| tiledBytes: (tiledReads + writes) * bytesPerElement, | |
| reuseFactor: tile, | |
| reduction: naiveReads / tiledReads, | |
| tilesPerDim: Math.ceil(n / tile), | |
| blocks: Math.ceil(n / tile) ** 2, | |
| // Both the A tile and the B tile must be resident at once. | |
| smemBytesPerBlock: 2 * tile * tile * bytesPerElement, | |
| }; | |
| } | |
| /* ---------------------------- Attention ---------------------------- */ | |
| /** | |
| * Off-chip traffic for one attention head at sequence length `n`, head dim `d`. | |
| * | |
| * Unfused — the n x n score matrix is materialized in HBM, read back by the | |
| * softmax, written again, then read once more by the PV matmul. Four n^2 trips | |
| * through memory that compute nothing. | |
| * | |
| * Fused (FlashAttention-style) — the scores never leave the SM. Only Q, K, V come | |
| * in and O goes out, so traffic is linear in n while the FLOPs stay quadratic, | |
| * and arithmetic intensity therefore grows with sequence length. | |
| */ | |
| export function attentionTraffic({ seqLen: n, headDim: d, bytesPerElement: b = 2, fused = false }) { | |
| const flops = 4 * n * n * d; // 2n^2*d for QK^T, 2n^2*d for PV | |
| const bytes = fused | |
| ? b * 4 * n * d // read Q, K, V + write O | |
| : b * (4 * n * d + 4 * n * n); // + write S, read S, write P, read P | |
| return { flops, bytes, ai: arithmeticIntensity(flops, bytes) }; | |
| } | |
| /* ------------------- Elementwise / normalization ------------------- */ | |
| /** | |
| * FLOP-per-element estimates for the non-GEMM ops. These are op counts for the | |
| * usual formulation, not measurements — a different GELU approximation shifts | |
| * them. It barely matters: these kernels sit two orders of magnitude below any | |
| * ridge point, so their position is set by bytes, not by FLOPs. | |
| */ | |
| export const FLOPS_PER_ELEMENT = { | |
| gelu: 8, // tanh approximation | |
| softmax: 5, // max, subtract, exp, sum, divide | |
| layernorm: 5, // mean, variance, normalize, scale, shift | |
| residual: 1, // a single add | |
| }; | |
| /** An elementwise kernel reading `reads` tensors and writing `writes` tensors. */ | |
| export function elementwiseKernel({ | |
| elements, | |
| flopsPerElement, | |
| reads = 1, | |
| writes = 1, | |
| bytesPerElement = 2, | |
| }) { | |
| const flops = elements * flopsPerElement; | |
| const bytes = elements * (reads + writes) * bytesPerElement; | |
| return { flops, bytes, ai: arithmeticIntensity(flops, bytes) }; | |
| } | |
| /* ----------------- Transformer layer kernel catalog ----------------- */ | |
| /** | |
| * The kernels of one transformer layer, with arithmetic intensities computed | |
| * from the shapes. `tokens` is batch x sequence — the number of rows flowing | |
| * through the layer, which is exactly what separates training/prefill (many | |
| * rows, GEMM-shaped) from autoregressive decode (one row, matvec-shaped). | |
| */ | |
| export function transformerLayerKernels({ | |
| tokens = 4096, | |
| dModel = 4096, | |
| heads = 32, | |
| seqLen = 4096, | |
| bytesPerElement = 2, | |
| } = {}) { | |
| const b = bytesPerElement; | |
| const headDim = dModel / heads; | |
| const ffn = 4 * dModel; | |
| const kernels = []; | |
| const gemm = (name, m, n, k, note) => | |
| kernels.push({ | |
| name, | |
| kind: 'gemm', | |
| shape: `[${m} x ${k}] . [${k} x ${n}]`, | |
| flops: gemmFlops(m, n, k), | |
| bytes: gemmBytes(m, n, k, b), | |
| ai: gemmIntensity(m, n, k, b), | |
| note, | |
| }); | |
| gemm('QKV projection', tokens, 3 * dModel, dModel, 'One GEMM produces all three projections.'); | |
| const unfused = attentionTraffic({ seqLen, headDim, bytesPerElement: b, fused: false }); | |
| kernels.push({ | |
| name: 'Attention (unfused)', | |
| kind: 'attention', | |
| shape: `n = ${seqLen}, d_head = ${headDim}`, | |
| ...unfused, | |
| note: 'Materializes the n x n score matrix in HBM — four extra n^2 round trips.', | |
| }); | |
| const fused = attentionTraffic({ seqLen, headDim, bytesPerElement: b, fused: true }); | |
| kernels.push({ | |
| name: 'Attention (fused / FlashAttention)', | |
| kind: 'attention', | |
| shape: `n = ${seqLen}, d_head = ${headDim}`, | |
| ...fused, | |
| note: 'Scores stay in SRAM. Traffic is linear in n while FLOPs stay quadratic, so AI = n/2.', | |
| }); | |
| gemm('Attention output proj', tokens, dModel, dModel); | |
| gemm('FFN up', tokens, ffn, dModel, `Expands to 4 x d_model = ${ffn}.`); | |
| gemm('FFN down', tokens, dModel, ffn); | |
| const ew = (name, elements, flopsPerElement, reads, writes, note) => { | |
| const r = elementwiseKernel({ elements, flopsPerElement, reads, writes, bytesPerElement: b }); | |
| kernels.push({ | |
| name, | |
| kind: 'elementwise', | |
| shape: `${elements.toLocaleString('en-US')} elements`, | |
| ...r, | |
| note, | |
| }); | |
| }; | |
| ew('GELU', tokens * ffn, FLOPS_PER_ELEMENT.gelu, 1, 1, 'Pure bandwidth. The classic fusion candidate.'); | |
| ew('LayerNorm', tokens * dModel, FLOPS_PER_ELEMENT.layernorm, 2, 1, 'Two reads: one for the statistics, one to normalize.'); | |
| ew('Residual add', tokens * dModel, FLOPS_PER_ELEMENT.residual, 2, 1, 'One add per element. Very nearly pure memory traffic.'); | |
| return kernels; | |
| } | |
| /** A single decode step: one token, so every GEMM collapses to a matrix-vector product. */ | |
| export function decodeStepKernel({ dModel = 4096, bytesPerElement = 2 } = {}) { | |
| const flops = gemmFlops(1, dModel, dModel); | |
| const bytes = gemmBytes(1, dModel, dModel, bytesPerElement); | |
| return { | |
| name: 'Decode matvec (batch = 1)', | |
| kind: 'gemm', | |
| shape: `[1 x ${dModel}] . [${dModel} x ${dModel}]`, | |
| flops, | |
| bytes, | |
| ai: arithmeticIntensity(flops, bytes), | |
| note: 'Every weight is fetched to be used exactly once. AI ~ 1 — hopelessly memory bound, which is why LLM decoding is a bandwidth problem and why batching helps so much.', | |
| }; | |
| } | |
| /* ----------------------------- Occupancy ----------------------------- */ | |
| export function ceilTo(value, unit) { | |
| return Math.ceil(value / unit) * unit; | |
| } | |
| /** | |
| * How many warps an SM can keep resident, given a block's resource appetite. | |
| * Occupancy is not a goal in itself — it is the SM's supply of other work to | |
| * issue while some warp waits several hundred cycles on HBM. | |
| */ | |
| export function occupancy({ threadsPerBlock, registersPerThread, sharedMemPerBlockBytes = 0 }, gpu) { | |
| const warpsPerBlock = Math.ceil(threadsPerBlock / WARP_SIZE); | |
| const byWarps = Math.floor(gpu.maxWarpsPerSm / warpsPerBlock); | |
| const regsPerWarp = ceilTo(registersPerThread * WARP_SIZE, REG_ALLOC_UNIT_PER_WARP); | |
| const byRegisters = Math.floor(gpu.registersPerSm / (regsPerWarp * warpsPerBlock)); | |
| const smemPerSmBytes = gpu.l1SmemPerSmKb * 1024; | |
| const smemPerBlock = ceilTo(sharedMemPerBlockBytes, SMEM_ALLOC_UNIT_BYTES); | |
| const bySharedMem = smemPerBlock > 0 ? Math.floor(smemPerSmBytes / smemPerBlock) : Infinity; | |
| const limits = [ | |
| { name: 'Warp slots', blocks: byWarps }, | |
| { name: 'Registers', blocks: byRegisters }, | |
| { name: 'Shared memory', blocks: bySharedMem }, | |
| { name: 'Blocks/SM cap', blocks: gpu.maxBlocksPerSm }, | |
| ]; | |
| const blocksPerSm = Math.max(0, Math.min(...limits.map((l) => l.blocks))); | |
| const activeWarps = blocksPerSm * warpsPerBlock; | |
| const limiter = limits.filter((l) => l.blocks === blocksPerSm).map((l) => l.name); | |
| return { | |
| warpsPerBlock, | |
| blocksPerSm, | |
| activeWarps, | |
| activeThreads: activeWarps * WARP_SIZE, | |
| occupancy: activeWarps / gpu.maxWarpsPerSm, | |
| regsPerWarp, | |
| smemPerBlock, | |
| limits, | |
| limiter, | |
| }; | |
| } | |
| /* -------------------------- Memory coalescing -------------------------- */ | |
| /** | |
| * The memory system does not serve threads, it serves 32-byte sectors. A warp's | |
| * 32 addresses are satisfied by however many distinct sectors they touch — 4 if | |
| * the lanes read contiguous 4-byte values, up to 32 if they are scattered. | |
| */ | |
| export function coalescedTransactions(byteAddresses, sectorBytes = 32, elementBytes = 4) { | |
| const sectors = new Set(byteAddresses.map((a) => Math.floor(a / sectorBytes))); | |
| const bytesRequested = byteAddresses.length * elementBytes; | |
| const bytesFetched = sectors.size * sectorBytes; | |
| return { | |
| sectors: sectors.size, | |
| sectorIds: [...sectors].sort((x, y) => x - y), | |
| bytesRequested, | |
| bytesFetched, | |
| efficiency: bytesRequested / bytesFetched, | |
| wasteFactor: bytesFetched / bytesRequested, | |
| }; | |
| } | |
| /** Warp lane addresses for a strided access `base + lane * stride` (in elements). */ | |
| export function stridedWarpAddresses(stride, elementBytes = 4, base = 0, lanes = WARP_SIZE) { | |
| return Array.from({ length: lanes }, (_, lane) => base + lane * stride * elementBytes); | |
| } | |
| /* --------------------------- Warp divergence --------------------------- */ | |
| /** | |
| * A warp has one instruction pointer. When its lanes disagree about a branch the | |
| * hardware executes both sides with the non-participating lanes masked off, so | |
| * the cost is the sum of the two paths rather than the longer of them — and the | |
| * masked lanes did no useful work while the other side ran. | |
| */ | |
| export function divergenceCost(laneTakesBranch, cyclesIfTrue, cyclesIfFalse) { | |
| const taken = laneTakesBranch.filter(Boolean).length; | |
| const notTaken = laneTakesBranch.length - taken; | |
| const divergent = taken > 0 && notTaken > 0; | |
| const cycles = divergent ? cyclesIfTrue + cyclesIfFalse : taken > 0 ? cyclesIfTrue : cyclesIfFalse; | |
| // Lane-cycles that did useful work, over the lane-cycles the warp occupied. | |
| const usefulLaneCycles = taken * cyclesIfTrue + notTaken * cyclesIfFalse; | |
| const totalLaneCycles = laneTakesBranch.length * cycles; | |
| return { | |
| taken, | |
| notTaken, | |
| divergent, | |
| pathsExecuted: divergent ? 2 : 1, | |
| cycles, | |
| idealCycles: Math.max(taken > 0 ? cyclesIfTrue : 0, notTaken > 0 ? cyclesIfFalse : 0), | |
| laneEfficiency: totalLaneCycles === 0 ? 1 : usefulLaneCycles / totalLaneCycles, | |
| }; | |
| } | |
| /* ------------------------------ Formatting ------------------------------ */ | |
| export function formatBytes(n) { | |
| const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; | |
| let i = 0; | |
| let v = n; | |
| while (v >= 1024 && i < units.length - 1) { | |
| v /= 1024; | |
| i += 1; | |
| } | |
| return `${Number.isInteger(v) || v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; | |
| } | |
| export function formatFlops(n) { | |
| const units = ['FLOP', 'KFLOP', 'MFLOP', 'GFLOP', 'TFLOP', 'PFLOP']; | |
| let i = 0; | |
| let v = n; | |
| while (v >= 1000 && i < units.length - 1) { | |
| v /= 1000; | |
| i += 1; | |
| } | |
| return `${Number.isInteger(v) || v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; | |
| } | |
| export function formatCount(n) { | |
| if (n >= 1e12) return `${(n / 1e12).toFixed(1)}T`; | |
| if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`; | |
| if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`; | |
| if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`; | |
| return String(Math.round(n)); | |
| } | |