The Chip — Meet AWS Trainium
Why AWS built custom silicon for AI, and how NKI lets you program it at the metal level.
Why Custom Silicon?
NVIDIA GPUs are general-purpose parallel processors. They're great, but they pay a "generality tax" — transistors spent on features AI workloads never use. AWS asked: what if we built a chip that does nothing but tensor math, and does it insanely well?
The answer is Trainium (for training) and Inferentia (for inference). These are purpose-built AI accelerators with a radically different memory hierarchy than GPUs.
NKI is the programming interface that lets you write custom kernels for these chips. If CUDA is how you talk to NVIDIA hardware, NKI is how you talk to Neuron hardware.
NKI is not a framework or a library — it is a kernel language. You control exactly which bytes move where, and when compute fires. This is the lowest level of programming on Trainium, analogous to writing PTX or SASS on NVIDIA.
The Memory Hierarchy (A Shipping Port)
Trainium's memory system has three levels. To understand them, imagine a shipping port:
Massive capacity (tens of GBs), but slow to access. All your model weights and tensors start here. Think: ocean freighter carrying containers across the sea.
Limited space, but lightning-fast access. Data must be here before compute can touch it. Organized as 128 rows x up to 512 columns. Think: warehouse right on the dock.
Tiny capacity, instant access. Holds matrix multiply results (float32 only). Think: the calculator on the dock worker's desk — it can only hold one answer at a time, but it's instant.
The golden rule: data must travel HBM → SBUF before any computation, and results must travel SBUF → HBM to be visible outside the kernel. PSUM is an intermediate stop only for matmul results.
Data Flow: Watch It Move
Every NKI kernel follows this data flow. Click "Next Step" to see how a tile of data moves through the chip:
Tile Semantics: Why 128?
Everything on Trainium operates on tiles. A tile is a 2D block of data with a fixed shape: 128 rows (the partition dimension) by up to 512 columns (the "free dimension").
Why 128? Because SBUF is physically organized into 128 parallel memory banks. Each bank holds one row of your tile. This is not a software choice — it is baked into the silicon.
If your tensor's first dimension is not a multiple of 128, you must pad it. There is no way around this — the hardware demands it. Every load, every store, every compute op works on 128-row chunks.
Two API Levels
NKI gives you two ways to talk to the hardware. Think of them as "Python" vs "Assembly":
nl.load, nl.matmul, nl.store. The compiler decides DMA scheduling and buffer allocation.
nisa.dma_copy, nisa.nc_matmul, nisa.tensor_tensor. You control every data movement explicitly.
In this course, we start with nki.isa so you understand exactly what's happening. Once you've built intuition, the high-level nki.language API will feel like a natural shortcut.
DMA: The Forklift
DMA stands for Direct Memory Access. It is a dedicated hardware engine whose only job is moving tiles between HBM and SBUF. Think of it as an autonomous forklift that carries containers between the cargo ship and the dock warehouse.
Key properties of DMA on Trainium:
- It runs asynchronously — compute can happen while DMA is moving the next tile
- It always moves full 128-row tiles (partition dimension)
- It can handle strided access patterns (non-contiguous memory)
Check Your Understanding
1. Where must data be before the compute engines can operate on it?
2. How many rows does the partition dimension always have?
3. Where do matrix multiply results initially land?
Your First Kernel — Tensor Addition
The simplest possible NKI kernel, explained line-by-line. Every kernel you'll ever write follows this same pattern.
The Universal Kernel Pattern
Every NKI kernel — from simple addition to Flash Attention — follows the same three-step pattern:
Load tile from HBM to SBUF
Compute on tile in SBUF
Store tile from SBUF to HBM
That's it. Load, compute, store. The complexity of advanced kernels comes from what you compute and how you overlap these steps — not from a different pattern.
The Complete Kernel
Here is a tensor addition kernel that adds two matrices element-wise. Left side is code, right side is plain English:
@nki.jit
def nki_tensor_add(a_input, b_input):
c_output = nl.ndarray(
a_input.shape, dtype=a_input.dtype,
buffer=nl.shared_hbm)
M, N = a_input.shape
TILE_M = 128
TILE_N = 512
for m in nl.affine_range(M // TILE_M):
for n in nl.affine_range(N // TILE_N):
a_tile = nl.ndarray(
(128, 512), dtype=a_input.dtype,
buffer=nl.sbuf)
b_tile = nl.ndarray(
(128, 512), dtype=b_input.dtype,
buffer=nl.sbuf)
nisa.dma_copy(
dst=a_tile,
src=a_input[m*128:(m+1)*128,
n*512:(n+1)*512])
nisa.dma_copy(
dst=b_tile,
src=b_input[m*128:(m+1)*128,
n*512:(n+1)*512])
c_tile = nl.ndarray(
(128, 512), dtype=a_input.dtype,
buffer=nl.sbuf)
nisa.tensor_tensor(
dst=c_tile, data1=a_tile,
data2=b_tile, op=nl.add)
nisa.dma_copy(
dst=c_output[m*128:(m+1)*128,
n*512:(n+1)*512],
src=c_tile)
return c_output
Line-by-Line Breakdown
Marks this function as a Neuron kernel. The Neuron compiler will trace it, lower it to hardware instructions, and JIT-compile it for Trainium. Like CUDA's __global__.
Generates a loop the compiler can optimize. "Affine" means the iteration pattern is linear and predictable, so the compiler can unroll it, overlap DMA with compute, or even run iterations in parallel.
The forklift instruction. Moves a tile between HBM and SBUF. The DMA engine handles this asynchronously — it's the only way to get data on-chip or off-chip.
Element-wise operation between two tiles already in SBUF. Supports add, subtract, multiply, etc. Both inputs and the output must be in SBUF — it cannot reach into HBM.
128 rows = the partition dimension (hardware-mandated). 512 columns = the maximum free dimension that fits in one SBUF allocation. You can use fewer columns (e.g., 256), but 512 maximizes throughput per tile.
Watch the Components Talk
Here's how HBM, SBUF, and Compute coordinate during one iteration of our addition kernel. Click "Next" to advance the conversation:
Buffer Allocation: Where Things Live
Notice the buffer= parameter in every nl.ndarray call. This tells the compiler where to place the tensor:
If you forget to specify buffer=nl.sbuf for your working tiles, the compiler won't know where to put them and will error. Always be explicit about where data lives.
Check Your Understanding
1. What does nl.affine_range do?
2. Why are tiles 128x512?
3. What does nisa.dma_copy do?
What You've Learned
You now know the universal NKI pattern: allocate tiles in SBUF, DMA data in from HBM, compute on-chip, DMA results back. Every kernel in this course — including matrix multiply and attention — is just a more sophisticated version of these same three steps.
In the next module, we'll tackle matrix multiplication — where PSUM finally comes into play and we see the systolic array in action.
Tiles & Loops — Thinking in Chunks
Tensors are too big for on-chip memory. Tiling is how we break the problem into pieces the hardware can digest — 128 rows at a time.
Why Tiling Is Not Optional
Tiling is the single most important concept in NKI programming. Without it, nothing works.
Consider a typical tensor in a transformer: 4096 rows by 4096 columns of float16 values. That is 4096 * 4096 * 2 bytes = 32 MB. SBUF holds a few kilobytes. The mismatch is roughly 1000x.
SBUF can only hold a few KB at a time. A typical 4096x4096 float16 tensor is 32MB — roughly 1000x larger than SBUF capacity. Tiling is not an optimization, it is a necessity.
The metaphor: tiling is like reading a newspaper. The full broadsheet page won't fit on your desk, so you fold it into sections and read one section at a time, moving your "window" across the page. The news is the same — you just process it in pieces.
Partition Dimension vs. Free Dimension
Every NKI tile has exactly two axes, and they are not interchangeable:
This maps directly to SBUF's 128 physical memory banks. Each of the 128 rows lives in its own bank, enabling all rows to be accessed simultaneously. You cannot change this number — it is baked into the silicon.
This is how many columns you process per tile. Each memory bank can hold up to 512 elements contiguously. You choose this value — 512 maximizes throughput, but smaller values work when you need to conserve SBUF space.
So a tile is always shaped (128, N) where N is at most 512. The partition dimension is hardware-mandated. The free dimension is your choice (within limits).
The Basic Tiling Pattern
Here is the fundamental loop structure that appears in every NKI kernel. It breaks a large tensor into 128x512 tiles and processes them one at a time:
M, N = tensor.shape
TILE_M = 128 # partition dim
TILE_N = 512 # free dim
for m in nl.affine_range(M // TILE_M):
for n in nl.affine_range(N // TILE_N):
# Slice: rows [m*128..(m+1)*128]
# cols [n*512..(n+1)*512]
tile = tensor[m * TILE_M:(m+1) * TILE_M,
n * TILE_N:(n+1) * TILE_N]
Visualizing the Tile Grid
A 1024x2048 tensor split into 128x512 tiles produces a grid of 8 rows by 4 columns — 32 tiles total. Each tile is one loop iteration:
col: 0-511 512-1023 1024-1535 1536-2047 ┌──────────┬──────────┬──────────┬──────────┐ row 0-127│ (0,0) │ (0,1) │ (0,2) │ (0,3) │ ├──────────┼──────────┼──────────┼──────────┤ row 128-255│ (1,0) │ (1,1) │ (1,2) │ (1,3) │ ├──────────┼──────────┼──────────┼──────────┤ row 256-383│ (2,0) │ (2,1) │ (2,2) │ (2,3) │ ├──────────┼──────────┼──────────┼──────────┤ row 384-511│ (3,0) │ (3,1) │ (3,2) │ (3,3) │ ├──────────┼──────────┼──────────┼──────────┤ row 512-639│ (4,0) │ (4,1) │ (4,2) │ (4,3) │ ├──────────┼──────────┼──────────┼──────────┤ row 640-767│ (5,0) │ (5,1) │ (5,2) │ (5,3) │ ├──────────┼──────────┼──────────┼──────────┤ row 768-895│ (6,0) │ (6,1) │ (6,2) │ (6,3) │ ├──────────┼──────────┼──────────┼──────────┤ row 896-1023│ (7,0) │ (7,1) │ (7,2) │ (7,3) │ └──────────┴──────────┴──────────┴──────────┘
Each (m, n) label is one tile. The loops iterate: m=0..7, n=0..3. With affine_range, the compiler may process these in any order or even in parallel.
affine_range vs. sequential_range
NKI provides two loop constructs. They look similar but give the compiler very different freedoms:
Rule of thumb: default to affine_range. Only switch to sequential_range when you have a true data dependency between iterations — like accumulating a running maximum for online softmax.
Dynamic Slice: nl.ds(start, size)
Standard Python slicing (a:b) works in NKI, but for computed offsets, dynamic slice notation is cleaner:
# These two are equivalent:
# Python slice notation
data = tensor[:, i*512 : i*512+512]
# Dynamic slice notation
data = tensor[:, nl.ds(i * 512, 512)]
nl.ds(start, size) means "starting at start, take size elements." It is especially useful in nested loops where both the start and size are expressions of loop variables.
Index Arithmetic Decoded
The expression m * TILE_M : (m+1) * TILE_M appears everywhere. Here is what it computes for each value of m:
0*128 : 1*128 → rows 0 to 127
1*128 : 2*128 → rows 128 to 255
2*128 : 3*128 → rows 256 to 383
[m*128, (m+1)*128). No overlap, no gaps — they tile the entire dimension.
The same index arithmetic applies to the free dimension with n * TILE_N. Together, the two loop variables select a unique 128x512 rectangle from the full tensor.
When Tiles Don't Divide Evenly
What happens if your tensor has 1000 rows instead of 1024? 1000 is not divisible by 128 (1000 / 128 = 7.8125). You have two options:
In practice, most ML tensors have dimensions that are already multiples of 128 (batch sizes, hidden dimensions, sequence lengths are typically powers of 2). When they are not, padding is the simpler approach.
Check Your Understanding
1. What happens if M (number of rows) is not divisible by 128?
2. What is the difference between nl.affine_range and nl.sequential_range?
3. What does nl.ds(256, 512) mean?
What You've Learned
You now understand the tiling machinery that every NKI kernel uses: the 128-row partition dimension, the up-to-512 free dimension, affine vs. sequential loops, index arithmetic, and dynamic slicing. In the next module, we will use these tiles to perform element-wise operations beyond simple addition.
Element-wise Operations — Beyond Addition
The full toolkit of per-element operations: binary ops, scalar broadcasting, activations, and the secret weapon of chained instructions.
The Four Element-wise Primitives
In Module 2 we used nisa.tensor_tensor for addition. That is just one of four element-wise primitives that NKI provides. Together, they form the complete set of per-element building blocks:
Binary ops between two tiles: add, subtract, multiply, min, max. Both tiles must be in SBUF and have the same shape.
Op between a tile and a scalar/vector. Supports broadcasting and can chain two ops in one instruction.
Unary ops: exp, log, tanh, reciprocal, sigmoid. Optionally applies scale and bias before the nonlinearity.
Copies data between PSUM and SBUF (with optional type casting). The glue that connects compute results in PSUM back to SBUF for further processing.
Binary Operations: tensor_tensor
The simplest element-wise primitive. Two tiles go in, one tile comes out. Every element at position (i, j) is combined independently:
# Element-wise multiply
nisa.tensor_tensor(
dst=result,
data1=a_tile,
data2=b_tile,
op=nl.multiply
)
Supported operations: nl.add, nl.subtract, nl.multiply, nl.minimum, nl.maximum. All inputs and output must be in SBUF with matching shapes.
Scalar Broadcast: tensor_scalar
Often you need to apply the same value across an entire row — subtract the row maximum, multiply by a scale factor, add a bias. This is where nisa.tensor_scalar shines:
# Subtract row-max for numerical stability
# row_max shape: (128, 1)
# scores shape: (128, 512)
nisa.tensor_scalar(
dst=normalized,
data=scores,
op0=nl.subtract,
operand0=row_max
)
The key insight: row_max has shape (128, 1) but gets broadcast to (128, 512). Each row's single max value is subtracted from all 512 columns in that row. This is identical to NumPy broadcasting — just happening on custom hardware.
The Secret Weapon: Chained Operations
The chained tensor_scalar instruction is the secret weapon of NKI optimization. Instead of two separate instructions (multiply then add), one instruction does both — halving the memory reads and pipeline bubbles.
nisa.tensor_scalar can perform two operations in sequence with a single instruction. First op0 is applied, then op1 is applied to the intermediate result:
# Fused multiply-add: out = x * scale + bias
nisa.tensor_scalar(
dst=out,
data=x,
op0=nl.multiply,
operand0=scale,
op1=nl.add,
operand1=bias
)
Without chaining, you would need two instructions: one tensor_scalar for multiply, then another for add. Each instruction reads the tile from SBUF and writes it back. Chaining eliminates the intermediate read-write cycle.
Activations: The Nonlinear Functions
Activation functions are the nonlinear transformations that make neural networks powerful. NKI provides them as a single instruction:
# Compute exp(data * scale)
nisa.activation(
dst=exp_result,
op=nl.exp,
data=delta,
scale=A_val
)
Available activations: nl.exp, nl.log, nl.tanh, nl.reciprocal, nl.sigmoid. The optional scale and bias parameters let you compute op(data * scale + bias) in a single instruction.
The Operations Argue
Each element-wise operation has its own specialty. Here they are, debating who is the most important:
Match the Operation to Its Use Case
Drag each NKI instruction to its correct real-world application:
nisa.tensor_tensor(op=nl.multiply)
nisa.tensor_scalar(op0=nl.subtract, operand0=max_val)
nisa.activation(op=nl.exp)
nisa.tensor_copy
Type Conversion: float32, float16, bfloat16
NeuronCore uses mixed precision internally. The Tensor Engine always outputs float32 into PSUM. But SBUF often holds float16 or bfloat16 to save space. You need to convert between them:
nisa.tensor_copy(dst=sbuf_tile, src=psum_tile) — automatically converts float32 to the destination dtype (float16 or bfloat16).
nisa.tensor_copy can also convert between float16 and bfloat16 within SBUF when you need a specific format for the next operation.
The general rule: compute in float32 (for precision), store in float16/bfloat16 (for space). The Tensor Engine enforces this — PSUM is always float32.
Check Your Understanding
1. When operand0 has shape (128, 1) in tensor_scalar, what happens?
2. What is the advantage of chaining op0 and op1 in nisa.tensor_scalar?
3. After a matrix multiply puts results in PSUM, how do you use them in element-wise ops?
What You've Learned
You now command the four element-wise primitives: tensor_tensor for binary ops, tensor_scalar for broadcast and chained ops, activation for nonlinear functions, and tensor_copy for shuffling data between PSUM and SBUF. In the next module, we will combine these with tiling to build a full matrix multiplication kernel.
Matrix Multiplication — The Heart of AI
From a single hardware multiply to fully optimized tiled matmul in four progressive steps.
The Hardware That Does the Heavy Lifting
Every neural network is, at its core, a chain of matrix multiplications. NeuronCore has a dedicated Tensor Engine that can multiply a 128×128 matrix by a 128×512 matrix in one shot — producing a 128×512 result.
But the Tensor Engine has rules. The left-hand side (LHS) is stationary — it stays in place inside the engine. The right-hand side (RHS) is moving — it streams through the engine. The result always lands in PSUM as float32, regardless of input types.
TILE_M = 128 (stationary free dimension) · TILE_K = 128 (contraction dimension) · TILE_N = 512 (moving free dimension). These are hardware constants — every NKI matmul works within these tile sizes.
Four Steps to an Optimized Matmul
NKI matmul kernels evolve through four stages. Each version addresses a limitation of the previous one.
Load lhsT[128,64] and rhs[128,512] into SBUF, call nisa.nc_matmul, copy result back to HBM. Works only for matrices that fit in one tile.
Use nl.affine_range to tile the computation. Loop over the contraction dimension K, accumulate partial sums in PSUM. Now handles arbitrary-size matrices.
Pull the lhsT load out of the inner N-loop. Each LHS tile gets loaded once and reused for every column tile of the output. Cuts memory traffic significantly.
Load RHS in bigger contiguous chunks to improve DMA efficiency. Aligns memory accesses with the hardware's preferred access patterns for maximum bandwidth.
Reading the Tiled Matmul
Here is the tiled version (Version 2) — the foundation every later optimization builds upon.
for m in nl.affine_range(M // TILE_M):
for n in nl.affine_range(N // TILE_N):
res_psum = nl.ndarray(
(TILE_M, TILE_N), nl.float32,
buffer=nl.psum)
for k in nl.affine_range(K // TILE_K):
lhsT_tile = nl.ndarray(
(TILE_K, TILE_M), dtype=lhsT.dtype,
buffer=nl.sbuf)
rhs_tile = nl.ndarray(
(TILE_K, TILE_N), dtype=rhs.dtype,
buffer=nl.sbuf)
nisa.dma_copy(dst=lhsT_tile,
src=lhsT[k*TILE_K:(k+1)*TILE_K,
m*TILE_M:(m+1)*TILE_M])
nisa.dma_copy(dst=rhs_tile,
src=rhs[k*TILE_K:(k+1)*TILE_K,
n*TILE_N:(n+1)*TILE_N])
nisa.nc_matmul(dst=res_psum,
stationary=lhsT_tile,
moving=rhs_tile)
res_sb = nl.ndarray(
res_psum.shape, dtype=result.dtype,
buffer=nl.sbuf)
nisa.tensor_copy(dst=res_sb,
src=res_psum)
nisa.dma_copy(
dst=result[m*TILE_M:(m+1)*TILE_M,
n*TILE_N:(n+1)*TILE_N],
src=res_sb)
Data Flow: One Tile Iteration
Watch how data moves through the hardware during a single K-tile iteration of the matmul.
The Optimization That Matters Most
The key optimization insight in NKI matmul is data reuse — by hoisting the LHS load outside the N-loop, each LHS tile gets used for every column of the result instead of being re-loaded. If your output has 8 column tiles, you save 7 redundant DMA transfers of the same LHS data per K iteration.
In the basic tiled version (Version 2), both LHS and RHS are loaded inside the innermost loop. Version 3 restructures the loops so the LHS load sits in the M-K loop, outside of N. The Tensor Engine keeps the Tensor Engine's stationary operand loaded while new RHS tiles stream through.
Version 4 goes further by loading RHS in larger contiguous blocks. Instead of fetching one 128×512 tile per iteration, it grabs multiple adjacent tiles in a single DMA burst. This keeps the memory controller busy and hides latency behind computation.
Check Your Understanding
Attention — The Optimization Journey
From a naive 10-line attention to flash-attention-style online softmax, in five progressive versions.
The Formula That Powers Every LLM
Attention computes softmax(Q @ KT) @ V. That single formula is responsible for the magic of ChatGPT, image generators, and protein folders. It is also the most expensive operation in any transformer.
The challenge: softmax requires knowing the maximum value across an entire row before you can compute any single element. This creates a data dependency that makes tiling and fusion difficult.
The NKI samples show how to evolve from a straightforward implementation to one that never materializes the full attention matrix — cutting memory from O(n²) to O(n).
Version 1: The Naive Attention
Version 1 uses high-level NKI operations. It is slow but crystal-clear about what attention actually computes.
@nki.jit
def attn_fwd_v1(q, k, v):
q_sbuf = nl.load(q)
k_sbuf = nl.load(k)
v_sbuf = nl.load(v)
qk_psum = nl.matmul(
x=q_sbuf, y=k_sbuf,
transpose_x=True)
qk = nl.ndarray(qk_psum.shape,
dtype=nl.float32, buffer=nl.sbuf)
nisa.tensor_copy(dst=qk, src=qk_psum)
row_max = nl.max(qk, axis=1,
keepdims=True)
norm_row = nl.ndarray(qk.shape,
dtype=nl.float32, buffer=nl.sbuf)
nisa.tensor_scalar(dst=norm_row,
data=qk, op0=nl.subtract,
operand0=row_max)
exp_row = nl.exp(norm_row)
sum_row = nl.sum(exp_row,
axis=1, keepdims=True)
inv_sum = nl.reciprocal(sum_row)
scores = nl.ndarray(exp_row.shape,
dtype=nl.float32, buffer=nl.sbuf)
nisa.tensor_scalar(dst=scores,
data=exp_row, op0=nl.multiply,
operand0=inv_sum)
# ... then scores @ V
Without subtracting the max before exp(), large values would overflow to infinity. This single line prevents your entire attention computation from producing garbage. Every version of the kernel keeps this step — it is never "optimized away."
The Optimization Journey: v1 to v8
Each version addresses a specific bottleneck. The jump from v1 to v8 delivers orders-of-magnitude improvement.
Uses nl.matmul, nl.max, nl.exp, nl.sum. Clear and correct, but the compiler cannot optimize across operation boundaries. Each op is a separate kernel launch internally.
Drops to nisa.nc_matmul, nisa.tensor_copy, and explicit SBUF/PSUM allocation. The programmer controls exactly where every byte lives. This unlocks manual scheduling of operations.
Sequences longer than 128 tokens cannot fit in a single tile. This version tiles Q along the sequence length dimension, computing attention for 128 queries at a time against the full K and V.
Combines softmax steps using nisa.tensor_scalar with chained op0 / op1 fields. Subtract-and-exp becomes one instruction. Multiply-by-reciprocal becomes one instruction. Halves the memory traffic.
Never materializes the full attention matrix. Maintains a running max and running sum across K-tiles. When a new tile reveals a larger max, previous partial results are rescaled. Memory drops from O(n²) to O(n).
The Trick Behind Flash Attention
Standard softmax needs two passes: one to find the max, another to compute exp and sum. Online softmax does it in one pass by maintaining corrections.
Track the maximum value seen so far. When a new tile has a larger max, update it and rescale all previous exponentials by e^(old_max - new_max).
Maintain the sum of exponentials. When the max changes, the old sum gets multiplied by the rescaling factor before adding new exponentials.
Do not divide by the sum until all tiles are processed. Accumulate unnormalized scores @ V, then divide once at the end. One division instead of many.
Every version of online softmax preserves the subtract-max trick. The running max ensures that at every point in the computation, the largest exponent being computed is e^0 = 1. No overflow is ever possible, regardless of how many tiles are processed.
Check Your Understanding
The Big Picture
The attention optimization journey is really about one thing: avoiding materialization. Version 1 creates the full N×N attention matrix in memory. Version 8 never creates it at all.
Each step is independently understandable. Together, they turn a textbook formula into production-grade hardware-optimized code running on NeuronCore.
Mamba — State Space Models on Custom Silicon
How Mamba's selective state space model maps to NKI hardware, and why the associative scan is the single most important operation.
Beyond Attention: The Recurrence Alternative
Transformers dominate sequence modeling, but their O(n²) attention cost becomes painful at long sequences. Mamba offers a fundamentally different approach: instead of letting every token look at every other token, it propagates information forward through a recurrence.
The core equation of a state space model is deceptively simple:
x[t] = A * x[t-1] + B * u[t] — state update
y[t] = C * x[t] — output projection
Each position updates a hidden state by decaying the previous state (multiply by A) and injecting new information (add B*u). The output is a linear read of that state.
This runs in O(n) time — linear in sequence length. But naive sequential execution wastes hardware parallelism. The key insight: this recurrence can be computed as an associative scan.
The Associative Scan: Parallel Recurrence
An associative scan is like a prefix sum, but instead of just addition, we use the pair of operations (multiply, add). This lets us compute all positions of the recurrence in parallel.
NKI provides direct hardware support via a single instruction:
nisa.tensor_tensor_scan(
dst=scan_res,
data0=deltaA,
data1=deltaBu,
initial=0.0,
op0=nl.multiply,
op1=nl.add
)
Think of it like a relay race. Each runner (position) inherits speed from the previous runner (multiply by deltaA) and adds their own boost (add deltaBu). Normally you'd wait for each runner to finish before the next starts. The hardware scan unit does all handoffs simultaneously — computing the entire race result in one shot.
Anatomy of the Mamba Kernel
The full Mamba kernel on NKI has five stages. The discretization step converts continuous parameters to discrete ones. Then the scan does the heavy lifting.
Notice the loop structure: the kernel iterates over batch, channel tiles, and state dimensions. For each state dimension, it runs the full discretize-scan-project pipeline, then accumulates results. This maps naturally to NKI's tile-based execution.
Why exp(delta * A)?
The matrix A represents a continuous-time decay rate. To use it in discrete steps, we need to convert it: if A says "decay at rate -0.5 per unit time" and our step size is delta, then the per-step decay factor is exp(delta * A).
NKI handles this elegantly with a fused instruction:
nisa.activation(
dst=deltaA,
op=nl.exp,
data=delta_i,
scale=A_i
)
scale parameter multiplies the input before applying expMamba's "selective" innovation is that B and C depend on the input — they are not fixed matrices. This means the model can choose what to remember and what to forget at each step, giving it the expressiveness to match Transformers while keeping linear cost.
The associative scan is to Mamba what matrix multiply is to Transformers — the single most important hardware operation. NKI gives you direct access to it via tensor_tensor_scan, letting you express the entire recurrence in one instruction. No manual loop unrolling, no approximations — the hardware computes the exact parallel prefix in its scan unit.
Check Your Understanding
nisa.tensor_tensor_scan(dst, data0, data1, op0=multiply, op1=add) compute?Advanced Patterns — Transpose, Pooling & Pipelining
Three patterns that reveal the full power of NKI: element scatter for transposition, access patterns for virtual views, and software pipelining for overlapped execution.
Three Power Patterns
With the fundamentals in place, we can explore the patterns that separate competent NKI code from truly efficient kernels. Each exploits a different axis of the hardware.
Rearrange elements along free dimensions by copying individual columns to computed positions using index arithmetic.
Create virtual views of tensors without copying data. The hardware reads with custom strides, giving you free reshapes.
Overlap load, compute, and store across loop iterations so DMA, compute, and accumulation units all stay busy simultaneously.
Pattern 1: 2D Transpose via Element Scatter
NKI has no built-in transpose instruction for free dimensions. Instead, you scatter individual elements to their transposed positions using index math.
For a matrix conceptually shaped (sz_f1, sz_f2) stored in a tile's free dimension, transposing means moving element at position (i, j) to position (j, i). In flattened coordinates: position i*sz_f2 + j moves to j*sz_f1 + i.
for i_f1 in nl.affine_range(sz_f1):
for i_f2 in nl.affine_range(sz_f2):
nisa.tensor_copy(
dst=out[:, nl.ds(
i_f2*sz_f1 + i_f1, 1)],
src=in_tile[:, nl.ds(
i_f1*sz_f2 + i_f2, 1)]
)
nl.ds(offset, 1) selects exactly 1 column at that offsetnisa.tensor_copy moves that single column to its new homenl.affine_range makes both loops parallelizableThe partition dimension (128 elements) has a hardware transpose via nisa.nc_transpose. But free dimensions are arbitrarily sized and stored linearly in SBUF — the only way to rearrange them is explicit copy. The affine_range loops tell the compiler all iterations are independent, so it can schedule them efficiently.
Pattern 2: Average Pooling with Access Patterns
Pooling requires reading overlapping windows from a 2D grid. Rather than copying data into a reshaped buffer, NKI's access pattern feature creates a virtual multi-dimensional view.
pool_view = in_tile.ap([...]) — the hardware will read with custom strides as if the data were physically reshaped into (batch, out_h, out_w, pool_h, pool_w). No bytes move.
sum_tile = nl.sum(pool_view, axis=[3, 4]) — sum across the last two dimensions (the pool window), collapsing them. The hardware reads the strided view and accumulates.
nisa.tensor_scalar(dst=out, data=sum_tile, op0=nl.multiply, operand0=1.0/(pool_h*pool_w)) — multiply by the reciprocal of pool size. One fused scalar-multiply instruction.
The .ap() call is the NKI equivalent of NumPy's as_strided. It tells the hardware "read this flat memory as if it had these dimensions and strides." The data never moves — only the interpretation changes. This is how you get complex windowed access patterns without paying copy costs.
Pattern 3: Software Pipelining
Software pipelining is the NKI equivalent of doing laundry efficiently. Instead of waiting for each load to wash, dry, and fold before starting the next, you overlap them: while load 2 washes, load 1 dries.
The hardware has separate units for DMA (data movement), compute (matrix math), and accumulation that can all run simultaneously. A pipelined Flash Attention kernel exploits this:
The key constraint: you must manage the pipeline prologue (filling the pipe) and epilogue (draining it). The first two iterations only load and partially compute. The last two only compute and store. The steady state in between is where you get full overlap.
Match the Operation
Drag each NKI operation to its purpose.
Pipelining Concepts
.ap() achieve a zero-copy reshape?Software pipelining is the NKI equivalent of doing laundry efficiently — instead of waiting for each load to wash, dry, and fold before starting the next, you overlap them: while load 2 washes, load 1 dries. The hardware has separate units for DMA, compute, and accumulation that can all run simultaneously. Your job as the kernel author is to keep all three busy at all times.
With these three patterns — scatter transpose, access pattern views, and software pipelining — you have the tools to write kernels that fully exploit NKI hardware. Transpose handles data layout. Access patterns eliminate copy overhead. Pipelining hides latency. Combined, they let you write kernels that approach the theoretical peak of the silicon.