File size: 18,945 Bytes
92916eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
/**
 * 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));
}