Elementwise Operation Tutorial

This tutorial walks through building a fused elementwise operation in TT-Lang, introducing one concept at a time. Each step is a self-contained runnable script.

The Goal

We want to compute y = (a * b + c) * d on 2048×2048 bfloat16 tensors. The inner expression a * b + c is the target for kernel fusion: instead of dispatching three separate TT-NN operations that each read and write DRAM, a custom TT-Lang operation reads each input once, computes the result in L1, and writes output once. It is possible to vary the expression as well as the size of tensors and the data type, for example float32. We ecougarge the user to do this.

Step 0 — TT-NN Baseline

Script: examples/elementwise-tutorial/step_0_ttnn_base.py

The starting point uses TT-NN directly, with no custom operation:

y = ttnn.multiply(ttnn.add(ttnn.multiply(a, b), c), d)

Each call dispatches a separate operation and writes an intermediate tensor back to DRAM. This is the reference we’ll verify against as we build the custom operation.

Step 1 — Single Node, Single-Tile Block

Script: examples/elementwise-tutorial/step_1_single_node_single_tile_block.py

This step introduces the complete TT-Lang programming model. The operation fuses a * b + c into a single pass, processing one 32×32 tile at a time on one node.

Operation function and grid

An operation is a Python function decorated with @ttl.operation(). The grid argument selects how many nodes (Tensix cores) to run on. grid=(1, 1) means a single node.

@ttl.operation(grid=(1, 1))
def __tutorial_operation(a: ttnn.Tensor, b: ttnn.Tensor, c: ttnn.Tensor, y: ttnn.Tensor):
    ...

The function arguments are the tensors the operation operates on. They live in DRAM on device and are passed by the host at call time.

Dataflow buffers

A dataflow buffer (DFB) is an L1 buffer shared between kernel functions within a node. It is created once in the operation scope from a tensor likeness and a block shape:

a_dfb = ttl.make_dataflow_buffer_like(a, shape=(1, 1), buffer_factor=2)

shape=(1, 1) means each buffer entry holds one 32×32 tile. buffer_factor=2 allocates two entries in L1 so that the reader and compute kernels can work concurrently — while compute processes one entry, the reader fills the other (double-buffering).

Kernel functions

Three kernel functions run concurrently inside the operation:

@ttl.compute()
def tutorial_compute(): ...

@ttl.datamovement()
def tutorial_read(): ...

@ttl.datamovement()
def tutorial_write(): ...

Compute kernel — waits for filled input blocks and reserves output blocks, then runs the fused expression:

with (
    a_dfb.wait() as a_blk,
    b_dfb.wait() as b_blk,
    c_dfb.wait() as c_blk,
    y_dfb.reserve() as y_blk,
):
    y_blk.store(a_blk * b_blk + c_blk)

wait() blocks until the reader has pushed a filled tile. reserve() blocks until the writer has freed an entry. The with block automatically calls pop() on inputs and push() on the output when the scope exits.

Reader DM kernel — copies tiles from DRAM into the input DFBs:

with (
    a_dfb.reserve() as a_blk,
    b_dfb.reserve() as b_blk,
    c_dfb.reserve() as c_blk,
):
    tx_a = ttl.copy(a[row, col], a_blk)
    tx_b = ttl.copy(b[row, col], b_blk)
    tx_c = ttl.copy(c[row, col], c_blk)
    tx_a.wait(); tx_b.wait(); tx_c.wait()

ttl.copy starts a transfer; tx.wait() waits for it to complete. The index a[row, col] selects a tile in tile coordinates (not element coordinates). The with block calls push() on exit, signalling the compute kernel.

Writer DM kernel — copies computed output tiles from L1 back to DRAM:

with y_dfb.wait() as y_blk:
    tx = ttl.copy(y_blk, y[row, col])
    tx.wait()

Step 2 — Single Node, Multi-Tile Block

Script: examples/elementwise-tutorial/step_2_single_node_multitile_block.py

Processing one tile at a time incurs a synchronization (via dataflow buffers) round-trip per tile. This step groups tiles into larger blocks so that each transfer and compute iteration covers a GRANULARITY × GRANULARITY patch of tiles.

GRANULARITY = 4  # each block is a 4×4 patch of 32×32 tiles = 128×128 elements

a_dfb = ttl.make_dataflow_buffer_like(
    a, shape=(row_tiles_per_block, col_tiles_per_block), buffer_factor=2
)

The iteration counts change from individual tiles to blocks:

rows = a.shape[0] // TILE_SIZE // row_tiles_per_block
cols = a.shape[1] // TILE_SIZE // col_tiles_per_block

The reader selects a tile range (not a single tile) per transfer:

tx_a = ttl.copy(
    a[start_row_tile:end_row_tile, start_col_tile:end_col_tile],
    a_blk,
)

The operation structure, synchronization pattern, and compute expression are unchanged from Step 1.

Step 3 — Multi-Node, Fixed Grid

Script: examples/elementwise-tutorial/step_3_multinode.py

This step parallelizes the operation across a 4×4 grid of nodes. Each node processes an independent rectangular region of the tensor. To familiarize the user with Tenstorrent hardware architecture we recommend reading TT Architecture and Metalium Guide.

Declaring a multi-node grid

@ttl.operation(grid=(4, 4))
def __tutorial_operation(...):

All nodes execute the same operation body. They differentiate their work using their coordinates in the grid as explained in the next sections.

Querying grid size and node position

ttl.grid_size(dims=2) returns (cols, rows) — the number of nodes along each grid dimension. ttl.node(dims=2) returns the (col, row) coordinates of the current node, zero-based.

grid_cols, grid_rows = ttl.grid_size(dims=2)

rows_per_node = a.shape[0] // TILE_SIZE // row_tiles_per_block // grid_rows
cols_per_node = a.shape[1] // TILE_SIZE // col_tiles_per_block // grid_cols

Mapping local to global indices

Each DM kernel uses its node coordinates to offset into the global tensor:

node_col, node_row = ttl.node(dims=2)

for local_row in range(rows_per_node):
    row = node_row * rows_per_node + local_row
    ...
for local_col in range(cols_per_node):
    col = node_col * cols_per_node + local_col
    ...

In this particular example, the compute kernel is unaware of node coordinates — it simply processes all blocks that the DM kernels deliver to it.

This version requires the tensor dimensions to be evenly divisible by the grid. See Step 4 for a version that handles arbitrary sizes.

Step 4 — Multi-Node, Auto Grid

Script: examples/elementwise-tutorial/step_4_multinode_grid_auto.py

This step removes two constraints from Step 3: the hard-coded grid size and the requirement for even divisibility.

Auto grid

@ttl.operation(grid="auto")

grid="auto" lets the compiler select the largest grid that fits available hardware resources. The operation must work correctly for any grid the compiler may choose as elaborated next.

Ceiling division

When the number of blocks does not divide evenly across the grid, nodes at the trailing edge would be left without work. Ceiling division ensures every block is assigned to some node:

rows_per_node = -(-rows // grid_rows)  # ceil(rows / grid_rows)
cols_per_node = -(-cols // grid_cols)  # ceil(cols / grid_cols)

Bounds checking

Nodes at the trailing edge may be assigned more iterations than there are actual blocks. All three kernel functions guard per-block work:

for local_row in range(rows_per_node):
    row = node_row * rows_per_node + local_row
    if row < rows:          # skip if past the end of the tensor
        for local_col in range(cols_per_node):
            col = node_col * cols_per_node + local_col
            if col < cols:  # skip if past the end of the tensor
                ...

The guard must appear in every kernel function — compute, read, and write — so that they all agree on exactly which blocks to process.