01

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.

Key Insight

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:

🚢
HBM — The Cargo Ship

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.

🏢
SBUF — The Dock Warehouse

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.

🔢
PSUM — The Calculator on the Desk

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:

🚢
HBM
🏢
SBUF
Compute
🔢
PSUM
Click "Next Step" to begin
Step 0 / 6

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.

💡
The 128-Row Rule

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":

nki.language (nl) High-level API. Use nl.load, nl.matmul, nl.store. The compiler decides DMA scheduling and buffer allocation.
nki.isa (nisa) Bare-metal API. Use 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?

02

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:

1

Load tile from HBM to SBUF

2

Compute on tile in SBUF

3

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:

Python / NKI
@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
Plain English
"I'm a kernel" — mark this function for Neuron compilation
"Takes two tensors as input"
"Allocate output in HBM" — same shape/type as input, lives in external memory so the caller can read it
 
"Get dimensions" — M rows, N columns
"128 rows per tile" — the partition dimension (hardware-mandated)
"512 cols per tile" — the free dimension (our choice, max 512)
 
"Loop over row-tiles" — compiler can parallelize this
"Loop over col-tiles" — nested tile iteration
"Reserve SBUF space for tile A" — 128x512 scratchpad slot
 
"Reserve SBUF space for tile B" — second scratchpad slot
 
"Forklift: HBM → SBUF" — copy a_input[tile] into a_tile on-chip
 
 
"Forklift: HBM → SBUF" — copy b_input[tile] into b_tile on-chip
 
 
"Reserve SBUF space for result"
"ADD them!" — element-wise addition, result stays in SBUF
 
"Forklift: SBUF → HBM" — copy result back to output in external memory
 
 
 
"Hand back the result"

Line-by-Line Breakdown

1
@nki.jit

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__.

2
nl.affine_range

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.

3
nisa.dma_copy

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.

4
nisa.tensor_tensor

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.

🔎
Why 128 x 512?

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:

0 / 6 messages

Buffer Allocation: Where Things Live

Notice the buffer= parameter in every nl.ndarray call. This tells the compiler where to place the tensor:

nl.shared_hbm Place in HBM. Used for output tensors that the caller needs to read after the kernel finishes.
nl.sbuf Place in SBUF. Used for temporary tiles that only exist during one loop iteration. Fast, private, ephemeral.

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

🏁
Kernel Pattern Mastered

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.

03

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.

Tiling Is a Necessity, Not an Optimization

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:

P
Partition Dimension (Axis 0) — Always 128

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.

F
Free Dimension (Axis 1) — Up to 512

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:

NKI Code
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]
Plain English
"Get dimensions" — M rows, N columns of the full tensor
"128 rows per tile" — hardware-mandated partition size
"512 cols per tile" — max throughput per chunk
 
"Loop over row-groups" — M/128 iterations, compiler can reorder
"Loop over col-groups" — N/512 iterations, nested inside
"Compute which slice" — row start = m*128, row end = (m+1)*128
"of the tensor we need" — col start = n*512, col end = (n+1)*512
"Extract the 128x512 chunk" — this sub-array fits in SBUF
 

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:

Grid Layout: 1024 rows / 128 = 8 tile-rows, 2048 cols / 512 = 4 tile-cols

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:

nl.affine_range(n) Order doesn't matter. The compiler can reorder iterations, overlap DMA with compute, even run multiple iterations simultaneously. Use this when each iteration is independent (most common case).
nl.sequential_range(n) Order matters. Iterations execute strictly in order: 0, 1, 2, ... n-1. Use this when iteration i depends on the result of iteration i-1 (e.g., running accumulations, online algorithms).

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:

NKI Code
# These two are equivalent:

# Python slice notation
data = tensor[:, i*512 : i*512+512]

# Dynamic slice notation
data = tensor[:, nl.ds(i * 512, 512)]
Plain English
 
 
"Standard Python slicing"
"All rows, columns i*512 to i*512+512"
 
"Dynamic slice: start=i*512, length=512"
"Same thing, but clearer when offsets are computed"

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
m=0: 0*128 : 1*128 → rows 0 to 127
1
m=1: 1*128 : 2*128 → rows 128 to 255
2
m=2: 2*128 : 3*128 → rows 256 to 383
...
General: tile m covers rows [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:

Padding Pad the tensor to the next multiple of 128 before the kernel runs. Process 1024 rows (8 tiles), then discard the last 24 rows of the result. Simple and common.
Masking Process 7 full tiles (896 rows), then handle the remaining 104 rows as a partial tile with a mask that ignores the invalid elements. More complex but avoids allocating extra memory.

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

🏁
Tiling Mastered

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.

04

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:

T
nisa.tensor_tensor

Binary ops between two tiles: add, subtract, multiply, min, max. Both tiles must be in SBUF and have the same shape.

S
nisa.tensor_scalar

Op between a tile and a scalar/vector. Supports broadcasting and can chain two ops in one instruction.

A
nisa.activation

Unary ops: exp, log, tanh, reciprocal, sigmoid. Optionally applies scale and bias before the nonlinearity.

C
nisa.tensor_copy

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:

NKI Code
# Element-wise multiply
nisa.tensor_tensor(
  dst=result,
  data1=a_tile,
  data2=b_tile,
  op=nl.multiply
)
Plain English
 
"Multiply two tiles element-by-element"
"Put the result here" — must be in SBUF
"First input tile" — in SBUF
"Second input tile" — same shape, in SBUF
"The operation: multiply" — also: add, subtract, min, max
 

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:

NKI Code
# 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
)
Plain English
"Subtract the maximum from each row"
"row_max has one value per row" — shape (128, 1)
"scores has 512 values per row" — shape (128, 512)
"Apply a scalar operation"
"Store result here" — shape (128, 512)
"The tile to operate on" — full 128x512 tile
"Operation: subtract" — applied per-element
"The scalar/vector to subtract" — broadcast across cols
 

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

One Instruction, Two 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:

NKI Code
# Fused multiply-add: out = x * scale + bias
nisa.tensor_scalar(
  dst=out,
  data=x,
  op0=nl.multiply,
  operand0=scale,
  op1=nl.add,
  operand1=bias
)
Plain English
"Multiply then add in ONE instruction"
"Apply chained scalar ops"
"Result goes here"
"Input tile"
"First op: multiply each element by scale"
"Scale value (broadcast)"
"Second op: add bias to intermediate"
"Bias value (broadcast)"
 

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:

NKI Code
# Compute exp(data * scale)
nisa.activation(
  dst=exp_result,
  op=nl.exp,
  data=delta,
  scale=A_val
)
Plain English
"Apply exponential with a scale factor"
"Activation instruction"
"Store result here"
"Which function: exponential" — also: log, tanh, reciprocal
"Input tile" — computes exp(delta * A_val)
"Multiply input by this before applying exp"
 

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:

0 / 5 messages

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
Scale one tile by another (e.g., attention scores * values)
Subtract row-max for numerical stability
Compute exponentials for softmax
Move results from PSUM to SBUF

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:

PSUM → SBUF nisa.tensor_copy(dst=sbuf_tile, src=psum_tile) — automatically converts float32 to the destination dtype (float16 or bfloat16).
SBUF → SBUF 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

🏁
Element-wise Toolkit Complete

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.

05

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.

📐
Key Dimensions

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.

1
Basic — Single Fixed-Size Multiply

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.

2
Tiled — Loop Over M, N, K

Use nl.affine_range to tile the computation. Loop over the contraction dimension K, accumulate partial sums in PSUM. Now handles arbitrary-size matrices.

3
Hoisted Loads — Reuse LHS Across Columns

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.

4
Blocked Free Dimension — Better Access Patterns

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.

NKI Code
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)
Plain English
Walk down the rows of the output, one 128-row chunk at a time.
Walk across the columns of the output, one 512-column chunk at a time.
Reserve a scratchpad in PSUM to accumulate the result for this output tile (always float32).
Walk along the shared K dimension, one 128-element slice at a time.
Allocate space in SBUF for one tile of the transposed LHS and one tile of RHS.
DMA the LHS tile from HBM into on-chip SBUF memory.
DMA the RHS tile from HBM into on-chip SBUF memory.
Fire the Tensor Engine — multiply stationary LHS by moving RHS, accumulating into PSUM.
After all K tiles are done, PSUM holds the complete result. Copy it to SBUF (converting to output dtype).
DMA the finished tile from SBUF back to HBM at the correct position in the output matrix.

Data Flow: One Tile Iteration

Watch how data moves through the hardware during a single K-tile iteration of the matmul.

H
HBM
S
SBUF
T
Tensor Engine
P
PSUM
Click "Next Step" to begin
Step 0 / 6

The Optimization That Matters Most

💡
Data Reuse is Everything

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

Why is lhsT stored transposed as [K, M] instead of the natural [M, K]?
What does "stationary" vs "moving" mean in the context of the Tensor Engine?
Why does PSUM always store results in float32, even when inputs are fp16 or bf16?
06

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 Code
@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
Plain English
Load Q, K, V from HBM into on-chip SBUF.
Compute Q @ KT using the Tensor Engine. Result lands in PSUM.
Copy QK scores from PSUM to SBUF (now accessible for element-wise ops).
Find the max of each row. This is the stability trick — prevents overflow.
Subtract the max from every element in the row. Now the largest value is 0.
Exponentiate every element. Since max was subtracted, no value exceeds e^0 = 1.
Sum each row and compute 1/sum. This is the softmax denominator.
Multiply each row by 1/sum to get the final attention weights (probabilities summing to 1).
Finally, multiply scores by V to get the weighted output.
⚠️
Numerical Stability is Non-Negotiable

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.

v1
Naive — High-Level API

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.

v2
ISA-Level — Explicit Buffer Management

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.

v3
Tiled — Handle Long Sequences

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.

v5
Fused Operations — Fewer Instructions

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.

v8
Flash Attention Style — Online Softmax

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.

M
Running Max

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).

S
Running Sum

Maintain the sum of exponentials. When the max changes, the old sum gets multiplied by the rescaling factor before adding new exponentials.

O
Delayed Division

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.

🧠
Numerical Stability Throughout

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

Why do we compute the row maximum and subtract it before calling exp()?
What is "online softmax" and why does it matter for flash attention?
In flash attention, why is the division by the softmax sum delayed until the very end?

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.

v1→v2 Move from auto-managed to explicit buffers — gain control
v2→v3 Add tiling — handle arbitrary sequence lengths
v3→v5 Fuse element-wise ops — cut memory traffic in half
v5→v8 Online softmax — eliminate O(n²) memory entirely

Each step is independently understandable. Together, they turn a textbook formula into production-grade hardware-optimized code running on NeuronCore.

07

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:

𝑥
The SSM Recurrence

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:

NKI

            nisa.tensor_tensor_scan(
                dst=scan_res,
                data0=deltaA,
                data1=deltaBu,
                initial=0.0,
                op0=nl.multiply,
                op1=nl.add
            )
          
Plain English
For each position i along the sequence:
out[i] = deltaA[i] * out[i-1] + deltaBu[i]
Starting from initial value 0.0
op0=multiply: decay the previous output by deltaA
op1=add: inject new input contribution deltaBu
All positions computed in parallel via hardware scan unit
Result: the full state history in one instruction

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.

u
Input
ex
Discretizer
Σ
Scanner
C
Projector
y
Output
Click "Next Step" to trace data through the Mamba SSM pipeline.
Step 0 / 6

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:

NKI

            nisa.activation(
                dst=deltaA,
                op=nl.exp,
                data=delta_i,
                scale=A_i
            )
          
Plain English
Compute: deltaA = exp(delta * A)
The scale parameter multiplies the input before applying exp
This fuses two operations (multiply + exp) into one instruction
Result: the per-step decay factor for the recurrence

Mamba'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 Core Insight

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

What does nisa.tensor_tensor_scan(dst, data0, data1, op0=multiply, op1=add) compute?
Why does the kernel compute exp(delta * A) rather than just using A directly?
Why is Mamba O(n) instead of O(n²) like attention?
08

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.

Scatter Transpose

Rearrange elements along free dimensions by copying individual columns to computed positions using index arithmetic.

𝓐
Access Pattern Views

Create virtual views of tensors without copying data. The hardware reads with custom strides, giving you free reshapes.

Software Pipelining

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.

NKI

            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)]
                )
          
Plain English
For each (row, col) pair in the source matrix:
Source position: row * num_cols + col (flattened index)
Destination position: col * num_rows + row (transposed)
nl.ds(offset, 1) selects exactly 1 column at that offset
nisa.tensor_copy moves that single column to its new home
nl.affine_range makes both loops parallelizable
Result: full transpose via element-by-element scatter
Why not a single instruction?

The 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.

1
Create a 5D view of a 3D tensor

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.

2
Reduce over the pool window

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.

3
Divide by pool area for the average

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.

Zero-Copy Reshape

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:

Pipeline Stages (3 groups overlapping)
Time slot 1
load(grp 2)
compute(grp 1)
store(grp 0)
Time slot 2
load(grp 3)
compute(grp 2)
store(grp 1)
Time slot 3
load(grp 4)
compute(grp 3)
store(grp 2)
Each time slot runs three operations simultaneously on different hardware units: DMA loads the next group, the compute engine processes the current group, and the store unit writes back the previous group's results. Three groups are "in flight" at once.

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.

nisa.tensor_copy
.ap()
nisa.tensor_tensor_scan
nl.affine_range
Move individual elements to new positions
Drop here
Create a virtual multi-dimensional view without copying
Drop here
Compute running recurrence along a sequence
Drop here
Generate parallelizable loop iterations
Drop here

Pipelining Concepts

What is the primary benefit of software pipelining in NKI kernels?
How does .ap() achieve a zero-copy reshape?
What is the main challenge when implementing software pipelining?
The Laundry Analogy

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.