Matrix Multiplication

A tiled matrix multiplication C = A @ B that uses the Ascend cube unit – the dedicated matmul accelerator, distinct from the vector unit used in the previous tutorials.

In this tutorial you will learn about:

  • The Ascend cube memory hierarchy and the required data flow GM -> L1 -> L0A/L0B -> L0C -> GM, and why each level exists.

  • Two different on-chip moves: GM -> L1 (staging) and L1 -> the cube’s operand registers (L0A/L0B).

  • The accumulator pattern: why the result lives in a dedicated L0C register and is updated in place (destination passing) rather than returned by value.

  • Splitting the reduction dimension K into three nested levels so that big L1 tiles feed many small cube calls.

  • Casting the float32 accumulator down to the storage dtype before storing it back to GM.

Motivation

The kernel we write implements the familiar Blocked Matmul algorithm:

for each output tile [M_i, N_j] assigned to a core:
    acc = zeros([single_core_m, single_core_n])
    for k in range(0, K, ...):          # split K into chunks
        a = A[M_i, k]                    # load A tile
        b = B[k, N_j]                    # load B tile
        acc += a @ b                    # accumulate in the cube
    C[M_i, N_j] = acc

The twist on Ascend is that a @ b does not happen in UB (as elementwise ops do) but in the cube unit, which has its own private memories.

The cube memory hierarchy can be presented as the following:

GM  ->  L1  ->  L0A (operand A)   ->  L0C (accumulator)  ->  GM
            ->  L0B (operand B)   ->

Realm

Role

GM

HBM: A, B live here; results are written back here.

L1

A staging cache between GM and the cube operand regs.

L0A

Cube operand register for the left matrix A.

L0B

Cube operand register for the right matrix B.

L0C

The cube accumulator register (always float32).

See also: asctile.TensorLocation enum.

L1 is a required bridge: you cannot go GM -> L0A/L0B directly. Conveniently, calling asctile.copy_in(..., location=L0A) lets the compiler split it into GM -> L1 -> L0A automatically. Even more, explicit tensor locations may be omitted completely in most cases, so they are resolved during compilation. However, in this tutorial we do the two steps explicitly to make the flow visible.

Compute Kernel

 75 from asc.experimental import asctile
 76
 77
 78 @asctile.jit
 79 def matrix_multiplication(a_ptr, b_ptr, c_ptr, a_shape: asctile.ConstExpr, b_shape: asctile.ConstExpr,
 80                           single_core_m: asctile.ConstExpr[int],  # output-tile height one core computes
 81                           single_core_n: asctile.ConstExpr[int],  # output-tile width  one core computes
 82                           step_ka: asctile.ConstExpr[int],  # K-chunk loaded into L1 for A each k_mid step
 83                           step_kb: asctile.ConstExpr[int],  # K-chunk loaded into L1 for B each k_outer step
 84                           base_k: asctile.ConstExpr[int],  # K slice fed to the cube in one matmul_acc call
 85                           quant_type: asctile.ConstExpr,  # dtype to store the result in (e.g. float16)
 86                           ):
 87
 88     m, k = a_shape
 89     _, n = b_shape
 90     a_gm = asctile.global_tensor(a_ptr, a_shape)
 91     b_gm = asctile.global_tensor(b_ptr, b_shape)
 92     c_gm = asctile.global_tensor(c_ptr, [m, n])
 93
 94     # The cube accumulator: a zero-initialised [single_core_m, single_core_n] tensor in L0C. Note it is *not*
 95     # ``asctile.zeros`` (which lives in UB): L0C is a dedicated register that the cube writes to. It is always float32,
 96     # even when A/B are float16, so the K-reduction accumulates without loss.
 97     acc = asctile.zeros_acc([single_core_m, single_core_n], dtype=asctile.float32)
 98
 99     # Map each core to one output tile of the [M, N] grid, in row-major order. Index arithmetic on ``block_idx()``
100     # lowers to signed integer division (``//``) and modulo.
101     n_blocks = asctile.ceildiv(n, single_core_n)
102     m_off = single_core_m * (asctile.block_idx() // n_blocks)
103     n_off = single_core_n * (asctile.block_idx() % n_blocks)
104
105     # Three nested K loops, from coarse to fine. Each ``unroll_factor=2`` lets the compiler overlap the next tile's load
106     # with the current tile's compute.
107     #
108     #   k_outer : step over ``step_kb``      -> load a fresh B tile into L1
109     #   k_mid   : step over ``step_ka``      -> load a fresh A tile into L1
110     #   k_l0    : step over ``base_k``       -> copy A/B slices L1 -> L0A/L0B, then matmul
111     for k_outer in asctile.range(asctile.ceildiv(k, step_kb), unroll_factor=2):
112         # GM -> L1: stage a [step_kb, single_core_n] tile of B.
113         b_l1 = asctile.copy_in(b_gm, [k_outer * step_kb, n_off], [step_kb, single_core_n], asctile.TensorLocation.L1)
114         for k_mid in asctile.range(asctile.ceildiv(step_kb, step_ka), unroll_factor=2):
115             k_off = k_outer * step_kb + k_mid * step_ka
116             # GM -> L1: stage a [single_core_m, step_ka] tile of A.
117             a_l1 = asctile.copy_in(a_gm, [m_off, k_off], [single_core_m, step_ka], asctile.TensorLocation.L1)
118             for k_l0 in asctile.range(asctile.ceildiv(step_ka, base_k), unroll_factor=2):
119                 # L1 -> L0A/L0B: feed the cube operands. ``asctile.copy`` is the local-to-local move (as opposed to
120                 # ``copy_in``/``copy_out`` which touch GM). Each call takes a [.., base_k] / [base_k, ..] slice.
121                 a_l0 = asctile.copy(a_l1, [0, k_l0 * base_k], [single_core_m, base_k], asctile.TensorLocation.L0A)
122                 b_l0 = asctile.copy(b_l1, [k_mid * step_ka + k_l0 * base_k, 0], [base_k, single_core_n],
123                                     asctile.TensorLocation.L0B)
124                 # Accumulate in place: acc += a_l0 @ b_l0. ``matmul_acc`` takes the accumulator by reference
125                 # (destination passing) and returns nothing, because L0C is a fixed register the cube updates directly:
126                 # there is no "new" tensor to return.
127                 asctile.matmul_acc(acc, a_l0, b_l0)
128
129     # The accumulator is float32; cast to the storage dtype (e.g. float16) before leaving L0C.
130     # ``.to`` on an L0C tensor is the cube's quantisation step (F322F16 etc.).
131     result = acc.to(quant_type)
132     # L0C -> GM directly: ``copy_out`` accepts an L0C source, so no extra UB hop is needed.
133     asctile.copy_out(result, c_gm, [m_off, n_off])

Launch and Verify

We run matrix_multiplication on a small float16 matmul and exercise all three nested K loops:

  • Shapes: A = [M, K] = [32, 128], B = [K, N] = [128, 128], C = [M, N] = [32, 128]. Inputs are float16, but the cube accumulates in float32 and the result is stored back as float16.

  • Output grid: single_core_m = single_core_n = 32 gives ceildiv(32,32) x ceildiv(128,32) = 1 x 4 = 4 tiles, one per core (block_num = 4).

  • K reduction (K=128): step_kb = 64, step_ka = 32, base_k = 16 -> k_outer = ceildiv(128,64) = 2, k_mid = ceildiv(64,32) = 2 and k_l0 = ceildiv(32,16) = 2; each tile’s K=128 reduction is split across all three levels, with the cube fed [32,16] / [16,32] L0 slices at the innermost level.

152 if __name__ == "__main__":
153     import torch
154
155     asctile.set_platform(asctile.Backend.Model, asctile.Platform.Ascend950PR_9599)
156     torch.manual_seed(0)
157
158     m, k, n = 32, 128, 128
159     single_core_m, single_core_n = 32, 32
160     step_ka, step_kb, base_k = 32, 64, 16
161     block_num = 4
162     quant_type = asctile.float16
163
164     dtype = torch.float16
165     a = torch.rand((m, k), dtype=dtype)
166     b = torch.rand((k, n), dtype=dtype)
167     c = torch.zeros((m, n), dtype=dtype)
168
169     matrix_multiplication[block_num](a, b, c, a.shape, b.shape, single_core_m, single_core_n, step_ka, step_kb, base_k,
170                                      quant_type)
171
172     # Reference: upcast to float32 for the matmul (as the cube does internally), then cast back to the storage dtype.
173     reference = (a.to(torch.float32) @ b.to(torch.float32)).to(dtype)
174     torch.testing.assert_close(c, reference, atol=1e-5, rtol=1e-5)
175     max_diff = (c.to(torch.float32) - reference.to(torch.float32)).abs().max().item()
176     print(f"matrix_multiplication: PASSED ({m}x{k} @ {k}x{n} -> {m}x{n}, blocks={block_num}, "
177           f"tile={single_core_m}x{single_core_n}, max diff={max_diff:.2e})")

Gallery generated by Sphinx-Gallery