A | B means either: reading union type hints in Python

Python · Type hints

def sensor_id(sensor: Sensor | int | None) -> int has three bars in one signature and looks like bit arithmetic. It is not arithmetic and it is not new syntax to memorise: each bar is the word or, and the whole annotation is one sentence — "a Sensor, or an int, or nothing".

MareArts ANPR App: On-Device Plates, MMC Vehicle Info, Team Sync, Web and Desktop Viewers

MareArts ANPR is a license-plate recognition app for iOS and Android, with a web viewer and a desktop viewer on the same account. Plates are read on the phone. Cloud MMC (make, model, colour, type, side, nation), team sync, rules, maps, and stats sit on marearts.com.

API and ABI, and the line between them

C · Python · Linking

Two acronyms one letter apart, describing promises made at two different moments. An API is what you agree to when you write the call. An ABI is what your compiled bytes agree to when they meet somebody else's compiled bytes — and unlike the API, breaking it usually produces no error at all.

The X-macro, from #define to runtime reflection

C · Preprocessor

C forgets the name of every field the moment it finishes compiling. The X-macro is how a C programmer gets those names back — by writing the list once and replaying it as an enum, a string table, a descriptor table, and a set of registration calls that run before main.

The list that remembers: mutable default arguments in Python

Python · Language

def append_to(item, target=[]) looks like it starts from an empty list every time. It does not. One list is created when the def statement runs, and every call for the rest of the program's life shares it.

The loop variable that changed: late binding and default arguments in Python

Python · Language

You define a function inside a loop, collect the functions, call them later — and every single one returns the last value. The usual fix is a strange-looking line where the same name appears twice. Here is what is actually going on, and why the fix works.

pytest, from your first test to your own plugin

Python · Testing

Most pytest tutorials stop right after assert 1 + 1 == 2, which is roughly where the interesting part begins. This one keeps going — through fixtures, parametrization and the configuration that makes a suite pleasant to live with, and out the other side into hooks and plugins.

Understanding Python variable type hints (Dict, dict, and mypy)

When you read Python code, sooner or later you hit a line like this:

data: Dict[str, int] = {}

The : Dict[str, int] part looks strange the first time. In this post we'll figure out exactly what that one line means — and whether a "type hint" is actually enforced — using tiny runnable examples.

1. One line, three things at once

That single line is really three pieces glued together.

data  :  Dict[str, int]  =  {}
 │            │              │
name       type hint       actual
            (a note)        value
PieceMeaning
datathe variable name
: Dict[str, int]a type hint — "keys are str, values are int". Just a note for humans/editors
= {}the actual value = an empty dict

At runtime it is exactly the same as:

data = {}   # drop the hint and this is all that's left

2. = {} is just a dict

A dict is a "key → value" store. It has nothing to do with type hints — it's a basic built-in type that has always existed.

data = {}                 # empty dict
data["gemm"]   = "A"      # key (str) -> value
data["conv2d"] = "B"
print(data)               # {'gemm': 'A', 'conv2d': 'B'}
print(data["gemm"])       # A
print(list(data))         # ['gemm', 'conv2d']

3. : Dict[str, int] is a "type hint"

Dict[str, int] is a note saying "a dict whose keys are strings and values are integers". Python does not enforce that note when it runs.

from typing import Dict

ages: Dict[str, int] = {}   # at runtime this is just ages = {}
ages["alice"] = 30
ages["bob"]   = 25
print(ages)                 # {'alice': 30, 'bob': 25}

4. Is it really not enforced? — break it on purpose

Violating the hint does not raise an error. Let's prove it.

from typing import Dict

ages: Dict[str, int] = {}   # promise: "str keys, int values"
ages["alice"] = 30          # keeps the promise
ages["oops"]  = "not-int"   # value is a string -> breaks it
ages[123]     = 99          # key is an int      -> breaks it
print(ages)

Output:

{'alice': 30, 'oops': 'not-int', 123: 99}
Key point: a type hint is not enforced. The Python interpreter simply ignores it at runtime, so breaking it still runs fine.

5. So why write hints at all?

If they're not enforced, why bother? Because they're a "quality tool", not a rule.

  • Humans — reading the code, you instantly see "this is a str→int dict".
  • Editors (IDEs) — autocomplete, and a red squiggle when you misuse it.
  • Static checkers (mypy) — run mypy file.py separately and it catches violations before you run the code.
A hint is "a promise + documentation", not a check. To actually catch violations, run a tool like mypy yourself — the interpreter won't.

6. Dict vs dict — and when did this syntax appear?

The dict itself is old; the type-hint syntax is the newer part.

SyntaxImport needed?Introduced
Dict[str, int] (capital)from typing import DictPython 3.5 (2015)
dict[str, int] (lowercase)none (built-in)Python 3.9 (2020)
dict (plain)nonealways

Annotating a variable with x: int = 0 has been possible since Python 3.6 (2016). Both forms mean the same thing, so new code usually prefers the lowercase dict[...].

scores: dict[str, float] = {}   # 3.9+ : lowercase, no import

Summary

  • name: Type = value == name = value + (a type note)
  • = {} is just an empty dict (old, ordinary syntax)
  • : Dict[str, int] is a hint that is not enforced — breaking it still runs
  • hints are documentation for humans / editors / mypy; the checking is done by mypy, separately

AMD GPU Programming Primer — Threads, Waves, Tiles & Vector Loads

AMD GPU Programming Primer

Threads · Waves · Memory · Tile Distribution · Vector Loads · MFMA

1. The execution hierarchy: grid → workgroup → wave → thread

A GPU kernel launch is a hierarchy of work units. Bigger units contain smaller ones.

AMD termNVIDIA termWhat it is
GridGridThe whole kernel launch — covers the entire problem.
WorkgroupBlock / threadblockA group of threads on one Compute Unit (CU). Shares LDS (shared memory). Can synchronize via __syncthreads().
Wavefront (wave)Warp64 threads (AMD CDNA) executing the same instruction simultaneously (SIMT).
Thread (work-item)ThreadOne lane in a wave. Has its own thread ID and register state.
GRID (kernel launch — covers the whole problem)
Workgroup 0 (256 threads)
Wave 0 (T0..T63)
Wave 1 (T64..T127)
Wave 2 (T128..T191)
Wave 3 (T192..T255)
Workgroup 1 (256 threads)
Wave 0..3 (64 threads each)
Workgroup N−1
Wave 0..3
Key: 64 threads in a wave always execute the same instruction in lock-step. That is the essence of SIMT (Single Instruction Multiple Threads).

2. Lane vs thread

"Lane" and "thread" are two views of the same physical execution slot.

  • Lane = a hardware ALU slot inside a SIMD unit. There are exactly 64 lanes per SIMD on AMD CDNA.
  • Thread = the software view of one lane. Has its own thread ID and private registers.

One lane runs one thread at a time. They are 1:1 within an executing wave.

1 wave (= 64 threads) running on 1 SIMD:

   Lane 0  ↔  Thread 0    (running my_function with tid=0)
   Lane 1  ↔  Thread 1    (running my_function with tid=1)
   Lane 2  ↔  Thread 2
    ...
   Lane 63 ↔  Thread 63

All 64 lanes execute the same instruction at the same cycle.

3. Hardware: GPU → CU → SIMD → lane

Below the software hierarchy is the physical hardware:

  • GPU contains many CUs (Compute Units). Example: MI300X has 304 CUs.
  • CU contains 4 SIMD units. The 4 SIMDs in a CU operate in parallel.
  • SIMD contains 64 lanes (ALUs) and a register file that can hold up to 8 resident waves.
GPU
├─ CU 0
│   ├─ SIMD 0  (64 lanes, ≤ 8 resident waves)
│   ├─ SIMD 1  (64 lanes, ≤ 8 resident waves)
│   ├─ SIMD 2  (64 lanes, ≤ 8 resident waves)
│   ├─ SIMD 3  (64 lanes, ≤ 8 resident waves)
│   └─ LDS (shared memory, 64 KB)
├─ CU 1
├─ ...
└─ CU 303      ← total 304 CUs on MI300X
SIMD ≠ instant execution. A SIMD holds up to 8 waves in its register file but executes only one wave per cycle. With multiple waves resident, when one wave waits for memory, the SIMD switches to another. This is latency hiding.

4. Registers, VGPRs & occupancy

Register types

TypeSizeScopeNotes
VGPR (vector GPR)32-bit (4 B)Private per laneUp to 256 per lane per wave. Each lane sees its own VGPR.
SGPR (scalar GPR)32-bit (4 B)Shared by 64 lanesUsed for scalar values like loop counters, addresses.
AGPR (accumulator GPR)32-bit (4 B)Private per laneCDNA-only. Used as MFMA accumulators.

How much register memory does one lane have?

1 lane × 256 VGPRs × 4 bytes = 1 KB per lane
1 wave (64 lanes) × 1 KB = 64 KB total register file used by one wave

Occupancy

Occupancy = number of waves resident on a SIMD (1 to 8). Higher occupancy enables better latency hiding.

If a wave uses 256 VGPRs/lane → only 1 wave fits in SIMD → occupancy 1
If a wave uses 128 VGPRs/lane → 2 waves fit                → occupancy 2
If a wave uses  32 VGPRs/lane → 8 waves fit                → occupancy 8 (max)

More resident waves = SIMD can switch when one wave stalls on memory.
Trade-off: using more VGPRs per thread means each thread can hold more data, but fewer waves can be resident, reducing latency hiding.

5. Memory hierarchy: registers → LDS → cache → HBM

GPU memory has multiple levels, similar to CPU cache hierarchy:

LevelSize (per CU / total)Latency (cycles)Managed byCPU analogue
Registers (VGPR/SGPR)~256 KB / CU~1CompilerCPU registers
LDS (shared memory)64 KB / CU~10–30Software (explicit loads/stores)Scratchpad / fast SRAM
L1 cache16 KB / CU~30Hardware (transparent)L1 cache
L2 cache~16 MB total~150HardwareL2 cache
Infinity / L3~256 MB~300HardwareL3 cache
HBM (global memory)192 GB~500–1000HW + softwareDRAM
Key insight: registers are basically free (~1 cycle), HBM is very expensive (~500+ cycles). Performance comes from staging data through LDS and registers, and from hiding HBM latency with high occupancy.

6. Kernel launch <<< grid, block >>>

HIP/CUDA kernel launch syntax:

add_kernel<<< grid, block >>>(A, B, C, N);
ParameterMeaningExample
block (a.k.a. blockSize)Threads per workgroup256 → 4 waves per workgroup
gridNumber of workgroups4 → 4 workgroups total
(implicit)Wave count = blockSize / 64256 / 64 = 4 waves per workgroup

You don't pick the wave count directly — it is derived from blockSize. The hardware always groups threads into waves of 64 on CDNA.

add_kernel<<< grid=4, block=256 >>>(...)

Total threads = 4 × 256 = 1024
Total waves   = 1024 / 64 = 16
Total workgroups = 4

Each workgroup → one CU
Each wave → one SIMD inside that CU

7. Vector loads & the 16-byte rule

A single load instruction can pull up to 16 bytes into a thread's registers. This is the hardware limit on AMD CDNA.

The number of elements per load (called vec size) depends on the data type:

Data typeSize (B)vec=1vec=2vec=4vec=8vec=16
fp16 / bf1622 B4 B8 B16 B (max)
fp3244 B8 B16 B (max)
int811 B2 B4 B8 B16 B (max)
Rule: vec × sizeof(dtype) ≤ 16 byte. Larger vec means fewer load instructions to move the same amount of data — faster.

Example GPU instructions

fp16, vec=1 (2 B):
   global_load_ushort  v0,    v[1:2]    ; load 2 bytes (1 fp16)
fp16, vec=4 (8 B):
   global_load_dwordx2 v[0:1], v[2:3]   ; load 8 bytes (4 fp16)
fp16, vec=8 (16 B):
   global_load_dwordx4 v[0:3], v[4:5]   ; load 16 bytes (8 fp16) - MAX
fp32, vec=4 (16 B):
   global_load_dwordx4 v[0:3], v[4:5]   ; load 16 bytes (4 fp32) - MAX

8. Walking through a simple kernel

__global__ void add_kernel(float* A, float* B, float* C, int N) {
    int tid = blockIdx.x * blockDim.x + threadIdx.x;  // global thread ID
    if (tid < N) {
        float a = A[tid];        // load (HBM → register)
        float b = B[tid];        // load (HBM → register)
        float c = a + b;         // ALU (register-to-register, ~1 cycle)
        C[tid] = c;              // store (register → HBM)
    }
}

add_kernel<<< 4, 256 >>>(A, B, C, 1024);

What happens per cycle (assuming occupancy 1, the worst case):

cycle:  1     2..99    100   101..199   200    201
        ----  ------   ----  --------   ----   -----
inst:   load   wait    load    wait     add    store
        A     (idle)   B      (idle)
                                                  ↓
                                          ALU only busy 2 cycles out of 200.

Instructions in this kernel: 4. Actual cycles: ~200. The reason: each HBM load takes ~100 cycles to complete, even though issuing it takes 1 cycle. With occupancy 1, the SIMD has nothing else to do but wait.

If occupancy were 4, the SIMD would switch to other waves during the wait, keeping the ALU busy on every cycle. This is why high occupancy matters.

9. MFMA: cooperative matrix multiply

MFMA (Matrix Fused Multiply-Add) instructions are wave-cooperative: all 64 lanes work together to compute a small matrix multiply (e.g., 16×16).

v_mfma_f32_16x16x16_f16   acc, a_frag, b_frag, c_frag

  64 lanes cooperatively compute D = A × B + C
  where A is 16×16 fp16, B is 16×16 fp16, D is 16×16 fp32

  Each lane holds a small piece of A, B, and accumulates a small piece of D.
  The hardware exchanges data between lanes during execution.

  Latency: ~8–32 cycles (NOT 1 cycle), but throughput is enormous:
    16×16×16 = 4,096 multiply-adds per instruction per wave
Important: MFMA is a wave-level instruction. It cannot be split across waves — one wave executes one MFMA. To compute a larger matrix multiply, multiple waves issue multiple MFMAs (covering different tiles).

CDNA vs RDNA

  • CDNA (data-center: gfx90a, gfx942, gfx950): MFMA available.
  • RDNA (consumer: gfx11, gfx12): no MFMA. Has WMMA (Wave Matrix Multiply-Accumulate) instead with similar idea.

10. Tiles & how a wave fills a tile (X0, Y0, X1, Y1)

What is a tile?

A tile is a 2D chunk of a matrix that one workgroup (or one wave) processes. GPU kernels divide a big problem into many small tiles.

Big matrix (e.g., 1024 × 1024)
divided into tiles of 64 × 64:

       X →
       ┌────────────────────────┐
   Y   │ t0  t1  t2  ...  t15   │
   ↓   │ t16 t17 ...            │   16 × 16 = 256 tiles
       │ ...                    │   each handled by one workgroup
       │ t240 ...          t255 │
       └────────────────────────┘

How does a 64-thread wave fill a 64×64 tile?

One 64×64 tile = 4,096 elements. One wave = 64 threads. Each thread is responsible for 4096/64 = 64 elements.

Those 64 elements per thread are split between two axes:

SymbolMeaning
X1 (= vec)Number of elements one thread loads in one instruction (X direction).
X0Number of threads placed along the X axis.
Y0Number of threads placed along the Y axis.
Y1Number of times each thread iterates along the Y axis.

Constraints:

X0 × Y0     = 64           ← total threads (wave size)
X0 × X1     = XPerTile     ← X axis fully covered
Y0 × Y1     = YPerTile     ← Y axis fully covered (with iteration)
X1 × sizeof ≤ 16 byte      ← hardware load limit

Why the X axis can use vec loads

Memory is 1D, but we view it as 2D (row-major):

memory:  [a][b][c][d] [e][f][g][h] [i][j][k][l] [m][n][o][p]
         ─────────── ─────────── ─────────── ───────────
            row 0       row 1       row 2       row 3

X direction: addresses +1 (contiguous)  → one instruction can load 4/8/16 bytes
Y direction: addresses +width (strided) → needs separate instructions per row

Worked example: 64×64 tile, fp16, vec=4

X1 = 4 (vec)
X0 = XPerTile / X1 = 64 / 4 = 16
Y0 = wave / X0     = 64 / 16 = 4
Y1 = YPerTile / Y0 = 64 / 4  = 16

Per thread:    X1 × Y1 = 4 × 16 = 64 elements
Per wave:      64 threads × 64 elements = 4,096 elements ✓
Load count:    16 (per thread) → 1024 total vec loads → 4,096 elements
                X axis (16 threads × 4 vec = 64 cols)
              ┌─────────────────────────────────────┐
   row 0..3   │ T0  T1  T2  T3  T4  T5 ... T14 T15  │   ← Y0=0, Y1=0..3
   row 4..7   │ T0  T1  T2  T3  ...                 │   ← Y0=0, Y1=4..7
   ...                                                  Y1 iterates 16 times
   row 16..   │ T16 T17 ...                         │   ← Y0=1
   row 32..   │ T32 T33 ...                         │   ← Y0=2
   row 48..63 │ T48 T49 ... T63                     │   ← Y0=3
              └─────────────────────────────────────┘

Choosing vec size

vecX0Y0Y1Loads / threadQuality
16416464worst (no vec)
23223232poor
41641616good
88888best (fp16 max)
Larger vec → fewer load instructions → faster, up to the 16-byte hardware limit.

11. Tile distribution patterns: thread / warp / block raked

The same tile can be distributed across threads in several ways. The choice depends on the algorithm, the data layout, and the matrix instruction (MFMA) shape.

PatternWho covers one tileWave layout inside tile
thread_raked1 wave (64 threads)n/a (single wave)
warp_rakedMultiple waves cooperate1D stack (waves stripe along one axis)
block_rakedAll waves of the workgroup2D grid (waves arranged in a grid)

warp_raked — 1D wave layout (4 waves stacked along Y)

                X axis (XPerTile)
              ┌─────────────────────────────────┐
              │          Wave 0                 │   each wave covers full X width
              ├─────────────────────────────────┤
              │          Wave 1                 │   1/4 of Y
              ├─────────────────────────────────┤
              │          Wave 2                 │
              ├─────────────────────────────────┤
              │          Wave 3                 │
              └─────────────────────────────────┘

block_raked — 2D wave layout (4 waves in 2×2 grid)

                X axis
              ┌───────────────┬─────────────┐
              │  Wave 0       │  Wave 1     │
              │ (X 0..63)     │ (X 64..127) │
              ├───────────────┼─────────────┤
              │  Wave 2       │  Wave 3     │
              │ (X 0..63)     │ (X 64..127) │
              └───────────────┴─────────────┘
warp_raked (1D)block_raked (2D)
Wave layout1 axis (Y)2 axes (X × Y)
Sub-tile per waveXPerTile × (YPerTile/N)(XPerTile/M) × (YPerTile/M)
X coverage by one wavefullpartial
The choice of pattern affects memory access patterns, MFMA fragment alignment, and register tile shapes. Each is best suited to different scenarios.

12. Cheat sheet

ConceptDefinition
GridThe whole kernel launch.
Workgroup (block)Group of threads on one CU. Shares LDS.
Wave (warp)64 threads (CDNA) executing in lockstep (SIMT).
Thread (work-item)One software unit; runs on one lane.
LaneOne hardware ALU slot in a SIMD. 64 per SIMD.
SIMDHardware execution unit. 4 per CU. Can hold up to 8 resident waves.
CUCompute Unit. Contains 4 SIMDs and 64 KB LDS. ~304 per MI300X.
VGPRVector register, private per lane. 32-bit. Up to 256 per lane.
SGPRScalar register, shared by all 64 lanes in a wave.
AGPRAccumulator register (CDNA), used for MFMA output.
LDSLocal Data Share = software-managed shared memory. 64 KB per CU.
HBMGlobal memory. Large but slow (~500+ cycle latency).
OccupancyNumber of resident waves on a SIMD (1–8). Higher = better latency hiding.
Latency hidingSIMD switches to another resident wave while one waits on memory.
SIMTSingle Instruction Multiple Threads. All 64 lanes run the same instruction.
MFMAMatrix Fused Multiply-Add. Wave-cooperative matrix multiply (CDNA).
WMMARDNA equivalent of MFMA.
Tile2D chunk of a matrix processed by one workgroup.
XPerTile / YPerTileTile dimensions in elements (algorithm-defined).
vec / X1Elements per thread per load. Constrained by 16-byte limit.
X0, Y0Number of threads placed along X / Y axes.
Y1Y-axis iteration count per thread.
CoalescingAdjacent threads accessing adjacent memory → one wide HBM transaction.
Kernel launchkernel<<< grid, block >>>(...) — grid = workgroups, block = threads/wg.
tile_distribution_patternHow threads/waves are distributed across one tile (thread/warp/block raked).
Key takeaways
  • Threads run in waves of 64; one instruction = one wave-step.
  • Memory is the slow part. High occupancy hides memory latency.
  • Vector loads (up to 16 B) reduce instruction count dramatically.
  • Tile dimensions are chosen by the algorithm; thread layout is derived from them.
  • MFMA is wave-cooperative — cannot be split across waves.