Part 3. Data · lesson 20 of 42
20. Computing on a tile¶
The compute kernel takes tiles from buffers, lets the core's arithmetic units work on them, and puts the result tile in another buffer, in three steps: unpack, one operation, pack. This lesson relies on lesson 19, Circular buffers, lesson 18, The three kernels and lesson 15, Tiles and tensors.
Why three steps¶
The arithmetic units do not read L1 themselves. Of the three CPUs that run the compute kernel (lesson 3, The card), the first, unpack, moves tiles from buffers into the units' working registers. The second, math, runs the operation on what is there. The third, pack, moves the result from the registers back into a buffer. You write one function for all three; each CPU runs the part that is its own. Between them they pass the tile through the destination register, a set of working tiles inside the unit, numbered from 0.
The general form¶
compute_kernel_hw_startup(FIRST, SECOND, OUTPUT);
add_init(FIRST, SECOND);
compute.run([&](const km_command_t& command) {
compute.wait_front(FIRST, 1);
compute.wait_front(SECOND, 1);
tile_regs_acquire();
add_tiles(FIRST, SECOND, 0, 0, 0);
tile_regs_commit();
tile_regs_wait();
compute.reserve_back(OUTPUT, 1);
pack_tile(0, OUTPUT);
cb_push_back(OUTPUT, 1);
tile_regs_release();
cb_pop_front(FIRST, 1);
cb_pop_front(SECOND, 1);
});
FIRST,SECONDandOUTPUTare the indexes of the three buffers: two to read, one to write.compute_kernel_hw_startupandadd_initare set-up, done once, beforerun. They tell the units which buffers and which operation to expect.wait_frontwaits untilreader.cpphas pushed a tile into the buffer, aswriter.cppdid in lesson 18, The three kernels.tile_regs_acquire()takes the destination register for this kernel.tile_regs_commit()says "math is done";tile_regs_wait()is where pack waits for it.tile_regs_release()gives the register back.add_tiles(FIRST, SECOND, 0, 0, 0)adds the first tile of bufferFIRSTto the first tile ofSECONDand leaves the sum in register 0. The last number is the register; the two before it are which tile of each buffer.compute.reserve_backandcb_push_backarereader.cpp'sreserveandpushon the output side;pack_tile(0, OUTPUT)copies register 0 into the tile just reserved.- Every tile taken with
wait_frontis released withcb_pop_front.
One line at a time¶
numbers = np.random.default_rng(1).integers(-8, 9, (4 * 32, 32))
source = session.tensor(numbers, "bfloat16", "tile")
destination = session.tensor(np.zeros((32, 32)), "bfloat16", "tile")
The numbers are whole numbers from -8 to 8, in four tiles, stored as "bfloat16", a
2-byte number with a fraction. Whole numbers this small are exact in it, so the sum can be
checked exactly. The destination is one tile of zeros.
tile = dict(format="bfloat16", page_bytes=source.page_bytes, pages=1)
program = km.Program(
"computing_on_a_tile",
kernels=HERE / "kernels",
handlers=HERE / "kernels" / "handlers.h",
buffers=[
km.Buffer(index=0, **tile),
km.Buffer(index=1, **tile),
km.Buffer(index=16, **tile),
],
compile_args={
"reader": source.accessor_args(),
"writer": destination.accessor_args(),
},
)
Three buffers of one tile: 0 and 1 for the two inputs, 16 for the output. dict(...)
holds what the three have in common.
reader.run([&](const km_command_t& command) {
if (km::handler(command) == kHandlerAdd) {
const uint32_t source_address = command.a0;
const uint32_t first_tile = command.a1;
const uint32_t second_tile = command.a2;
const auto source = TensorAccessor(source_arguments, source_address);
if (!reader.reserve(kFirstInput, 1) || !reader.reserve(kSecondInput, 1)) {
return;
}
noc_async_read_page(first_tile, source, get_write_ptr(kFirstInput));
noc_async_read_page(second_tile, source, get_write_ptr(kSecondInput));
noc_async_read_barrier();
cb_push_back(kFirstInput, 1);
cb_push_back(kSecondInput, 1);
}
});
reader.cpp puts one tile of the source in each of the two input buffers.
compute.run([&](const km_command_t& command) {
if (km::handler(command) != kHandlerAdd) {
return;
}
if (
!compute.wait_front(kFirstInput, 1) || !compute.wait_front(kSecondInput, 1)
) {
return;
}
tile_regs_acquire();
add_tiles(kFirstInput, kSecondInput, 0, 0, 0);
tile_regs_commit();
tile_regs_wait();
if (compute.reserve_back(kOutput, 1)) {
pack_tile(0, kOutput);
cb_push_back(kOutput, 1);
}
tile_regs_release();
cb_pop_front(kFirstInput, 1);
cb_pop_front(kSecondInput, 1);
});
The compute kernel is the general form with the checks: if a wait gives up, it returns.
arguments = [source.address, 1, 2, destination.address, 0]
run.wait(run.submit("add", group=(0, 1), args=arguments), timeout=10.0)
run.stop()
One command: add tile 1 and tile 2 of the source, write the result as tile 0 of the
destination. writer.cpp of this lesson is that of lesson 17, Writing to DRAM, reading from
buffer 16.
The whole program¶
"""Computing on a tile: the compute kernel adds two tiles."""
from pathlib import Path
import numpy as np
import komorebi as km
HERE = Path(__file__).resolve().parent
with km.Session() as session:
numbers = np.random.default_rng(1).integers(-8, 9, (4 * 32, 32))
source = session.tensor(numbers, "bfloat16", "tile")
destination = session.tensor(np.zeros((32, 32)), "bfloat16", "tile")
tile = dict(format="bfloat16", page_bytes=source.page_bytes, pages=1)
program = km.Program(
"computing_on_a_tile",
kernels=HERE / "kernels",
handlers=HERE / "kernels" / "handlers.h",
buffers=[
km.Buffer(index=0, **tile),
km.Buffer(index=1, **tile),
km.Buffer(index=16, **tile),
],
compile_args={
"reader": source.accessor_args(),
"writer": destination.accessor_args(),
},
)
run = session.load(program)
arguments = [source.address, 1, 2, destination.address, 0]
run.wait(run.submit("add", group=(0, 1), args=arguments), timeout=10.0)
run.stop()
total = destination.read()
expected = numbers[32:64] + numbers[64:96]
print("tile 1, first row: ", numbers[32, :8].tolist(), "...")
print("tile 2, first row: ", numbers[64, :8].tolist(), "...")
print("destination, first row: ", total[0, :8].astype(int).tolist(), "...")
assert (total == expected).all()
print("every number of the destination is the sum of tile 1 and tile 2")
// What the kernels and Python agree on: the handler, and the numbers of the three
// buffers in L1: the two inputs of the addition and its output.
#pragma once
#include <stdint.h>
constexpr uint32_t kHandlerAdd = 1;
constexpr uint32_t kFirstInput = 0;
constexpr uint32_t kSecondInput = 1;
constexpr uint32_t kOutput = 16;
// The reader kernel: copies two tiles of the source tensor into two buffers.
#include "api/dataflow/dataflow_api.h"
#include "komorebi/device/km_worker.hpp"
#include "handlers.h"
void kernel_main() {
constexpr auto source_arguments = TensorAccessorArgs<0>();
km::Reader reader;
reader.run([&](const km_command_t& command) {
if (km::handler(command) == kHandlerAdd) {
const uint32_t source_address = command.a0;
const uint32_t first_tile = command.a1;
const uint32_t second_tile = command.a2;
const auto source = TensorAccessor(source_arguments, source_address);
if (!reader.reserve(kFirstInput, 1) || !reader.reserve(kSecondInput, 1)) {
return;
}
noc_async_read_page(first_tile, source, get_write_ptr(kFirstInput));
noc_async_read_page(second_tile, source, get_write_ptr(kSecondInput));
noc_async_read_barrier();
cb_push_back(kFirstInput, 1);
cb_push_back(kSecondInput, 1);
}
});
}
// The compute kernel: adds the two input tiles and puts the sum in the output buffer.
#include "api/compute/cb_api.h"
#include "api/compute/compute_kernel_api.h"
#include "api/compute/compute_kernel_hw_startup.h"
#include "api/compute/eltwise_binary.h"
#include "api/compute/pack.h"
#include "api/compute/reg_api.h"
#include "komorebi/device/km_worker.hpp"
#include "handlers.h"
void kernel_main() {
km::Compute compute;
compute_kernel_hw_startup(kFirstInput, kSecondInput, kOutput);
add_init(kFirstInput, kSecondInput);
compute.run([&](const km_command_t& command) {
if (km::handler(command) != kHandlerAdd) {
return;
}
if (
!compute.wait_front(kFirstInput, 1) || !compute.wait_front(kSecondInput, 1)
) {
return;
}
tile_regs_acquire();
add_tiles(kFirstInput, kSecondInput, 0, 0, 0);
tile_regs_commit();
tile_regs_wait();
if (compute.reserve_back(kOutput, 1)) {
pack_tile(0, kOutput);
cb_push_back(kOutput, 1);
}
tile_regs_release();
cb_pop_front(kFirstInput, 1);
cb_pop_front(kSecondInput, 1);
});
}
// The writer kernel: writes the tile of the output buffer to the destination tensor.
#include "api/dataflow/dataflow_api.h"
#include "komorebi/device/km_worker.hpp"
#include "handlers.h"
void kernel_main() {
constexpr auto destination_arguments = TensorAccessorArgs<0>();
km::Writer writer;
writer.run([&](const km_command_t& command) {
if (km::handler(command) == kHandlerAdd) {
const uint32_t destination_address = command.a3;
const uint32_t destination_tile = command.a4;
const auto destination = (
TensorAccessor(destination_arguments, destination_address)
);
if (!writer.wait_front(kOutput, 1)) {
return false;
}
noc_async_write_page(destination_tile, destination, get_read_ptr(kOutput));
noc_async_write_barrier();
cb_pop_front(kOutput, 1);
}
return true;
});
}
tile 1, first row: [-6, 4, 0, -3, 6, 3, -3, 1] ...
tile 2, first row: [3, 4, 5, -6, 1, -2, -4, 6] ...
destination, first row: [-3, 8, 5, -9, 7, 1, -7, 7] ...
every number of the destination is the sum of tile 1 and tile 2
The first row of the two source tiles, and of the destination: -6 + 3 is -3, 4 + 4 is 8, 0 + 5 is 5. The script checks all 1024 numbers against numpy.
Common mistakes¶
- Forgetting
cb_pop_fronton an input. The next command finds the buffer full, as in lesson 16, Reading from DRAM. - Changing the order of the register calls. Keep the order of the general form: acquire, operation, commit, wait, pack, release.
- Expecting a fraction from
"bfloat16"to be exact. It keeps about three decimal digits; whole numbers up to 256 are exact.
Next: lesson 21, The matrix unit replaces the addition with a product of tiles.