Part 3. Data · lesson 22 of 42
22. A whole map¶
A map applies the same operation to every tile of a tensor. Here the cores of a group each take their share of the tiles (lesson 12, Partitions) and run the whole read, compute and write path of Part 3 on it, at the same time. This lesson relies on lesson 11, A core group, lesson 12, Partitions, lesson 20, Computing on a tile and lesson 19, Circular buffers.
Why a map¶
Until now one core worked on a handful of tiles. A real tensor has thousands. Each tile is independent of the others, so the tiles can be shared among the cores of a group and all the cores work at once: a group of 12 does the work of one core in about a twelfth of the time. This is the pattern most programs on the card are made of.
The general form¶
In every one of the three kernels, inside the function you give to run:
const uint32_t rank = name.me() - km::first(command);
const km::Partition share = km::partition(TILES, rank, km::count(command));
for (uint32_t index = 0; index < share.len; index++) {
STEP for tile share.start + index
}
TILESis how many tiles the tensor has. It travels as a command argument.- The three lines are lessons 11 and 12 together. Every core works out the same share for itself, with no message to any other core.
STEPisreader.cpp's, the compute kernel's orwriter.cpp's step: lesson 18, The three kernels and lesson 20, Computing on a tile.- The three kernels run the same loop count,
share.len, so that every tilereader.cpppushes is one the compute kernel andwriter.cpptake. - A command's group chooses how many cores share the tiles. The kernels do not change.
One line at a time¶
reader.run([&](const km_command_t& command) {
if (km::handler(command) == kHandlerMap) {
const uint32_t source_address = command.a0;
const uint32_t tile_count = command.a1;
const uint32_t rank = reader.me() - km::first(command);
const km::Partition share = (
km::partition(tile_count, rank, km::count(command))
);
const auto source = TensorAccessor(source_arguments, source_address);
for (uint32_t index = 0; index < share.len; index++) {
const uint32_t tile = share.start + index;
if (
!reader.reserve(kFirstInput, 1) || !reader.reserve(kSecondInput, 1)
) {
return;
}
noc_async_read_page(tile, source, get_write_ptr(kFirstInput));
noc_async_read_page(
tile_count + tile, source, get_write_ptr(kSecondInput)
);
noc_async_read_barrier();
cb_push_back(kFirstInput, 1);
cb_push_back(kSecondInput, 1);
}
}
});
reader.cpp works out its share, then for each tile of it reads the tile from the first
half of the source and the tile tile_count further on, from the second half, into the two
input buffers.
compute.run([&](const km_command_t& command) {
if (km::handler(command) != kHandlerMap) {
return;
}
const uint32_t rank = compute.me() - km::first(command);
const km::Partition share = km::partition(command.a1, rank, km::count(command));
for (uint32_t index = 0; index < share.len; index++) {
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 has the same loop around the body of lesson 20, Computing on a tile. It does
not need the tile numbers, only how many tiles: share.len.
writer.run([&](const km_command_t& command) {
if (km::handler(command) == kHandlerMap) {
const uint32_t destination_address = command.a2;
const uint32_t first_tile = command.a3;
const uint32_t rank = writer.me() - km::first(command);
const km::Partition share = (
km::partition(command.a1, rank, km::count(command))
);
const auto destination = (
TensorAccessor(destination_arguments, destination_address)
);
for (uint32_t index = 0; index < share.len; index++) {
if (!writer.wait_front(kOutput, 1)) {
return false;
}
noc_async_write_page(
first_tile + share.start + index, destination, get_read_ptr(kOutput)
);
noc_async_write_barrier();
cb_pop_front(kOutput, 1);
}
}
return true;
});
writer.cpp writes the tile first_tile + share.start + index of the destination. The
first_tile is an argument that shifts the output, so the program can keep several results
in one tensor.
for round_number, count in enumerate((1, 4, 12)):
arguments = [source.address, TILES, destination.address, round_number * TILES]
handle = run.submit("map", group=(0, count), args=arguments)
microseconds = run.wait(handle, timeout=10.0).microseconds
print(
f"group of {count:2}: {TILES} tiles added in "
f"{microseconds:5.1f} microseconds"
)
The same command with the group of 1, 4 and then 12 cores. Each writes its result to a different third of the destination: a tensor write needs the cores to be idle, and a loaded program never is, so the program keeps the three results apart and checks them at the end.
The whole program¶
"""A whole map: a group of cores adds two tensors, each core its share of the tiles."""
from pathlib import Path
import numpy as np
import komorebi as km
HERE = Path(__file__).resolve().parent
TILES = 60
with km.Session() as session:
rng = np.random.default_rng(1)
first = rng.integers(-8, 9, (TILES * 32, 32))
second = rng.integers(-8, 9, (TILES * 32, 32))
source = session.tensor(np.concatenate([first, second]), "bfloat16", "tile")
destination = session.tensor(np.zeros((3 * TILES * 32, 32)), "bfloat16", "tile")
tile = dict(format="bfloat16", page_bytes=source.page_bytes, pages=2)
program = km.Program(
"a_whole_map",
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)
for round_number, count in enumerate((1, 4, 12)):
arguments = [source.address, TILES, destination.address, round_number * TILES]
handle = run.submit("map", group=(0, count), args=arguments)
microseconds = run.wait(handle, timeout=10.0).microseconds
print(
f"group of {count:2}: {TILES} tiles added in "
f"{microseconds:5.1f} microseconds"
)
run.stop()
total = destination.read()
for round_number in range(3):
rows = slice(round_number * TILES * 32, (round_number + 1) * TILES * 32)
assert (total[rows] == first + second).all()
print("all three results are the sum of the two tensors")
// 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 kHandlerMap = 1;
constexpr uint32_t kFirstInput = 0;
constexpr uint32_t kSecondInput = 1;
constexpr uint32_t kOutput = 16;
// The reader kernel: for each tile of this core's share, copies tile i of the first
// half of the source and tile i of the second half into the two input 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) == kHandlerMap) {
const uint32_t source_address = command.a0;
const uint32_t tile_count = command.a1;
const uint32_t rank = reader.me() - km::first(command);
const km::Partition share = (
km::partition(tile_count, rank, km::count(command))
);
const auto source = TensorAccessor(source_arguments, source_address);
for (uint32_t index = 0; index < share.len; index++) {
const uint32_t tile = share.start + index;
if (
!reader.reserve(kFirstInput, 1) || !reader.reserve(kSecondInput, 1)
) {
return;
}
noc_async_read_page(tile, source, get_write_ptr(kFirstInput));
noc_async_read_page(
tile_count + tile, source, get_write_ptr(kSecondInput)
);
noc_async_read_barrier();
cb_push_back(kFirstInput, 1);
cb_push_back(kSecondInput, 1);
}
}
});
}
// The compute kernel: adds, for each tile of this core's share, the two input tiles.
#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) != kHandlerMap) {
return;
}
const uint32_t rank = compute.me() - km::first(command);
const km::Partition share = km::partition(command.a1, rank, km::count(command));
for (uint32_t index = 0; index < share.len; index++) {
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 each tile of this core's share 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) == kHandlerMap) {
const uint32_t destination_address = command.a2;
const uint32_t first_tile = command.a3;
const uint32_t rank = writer.me() - km::first(command);
const km::Partition share = (
km::partition(command.a1, rank, km::count(command))
);
const auto destination = (
TensorAccessor(destination_arguments, destination_address)
);
for (uint32_t index = 0; index < share.len; index++) {
if (!writer.wait_front(kOutput, 1)) {
return false;
}
noc_async_write_page(
first_tile + share.start + index, destination, get_read_ptr(kOutput)
);
noc_async_write_barrier();
cb_pop_front(kOutput, 1);
}
}
return true;
});
}
group of 1: 60 tiles added in 38.5 microseconds
group of 4: 60 tiles added in 11.3 microseconds
group of 12: 60 tiles added in 5.6 microseconds
all three results are the sum of the two tensors
Sixty tiles took 38 microseconds on one core, 11 on four and 5.6 on twelve: about 3.4 and 7 times faster. Not 4 and 12 times: every command also pays a fixed cost to reach the cores and to hear that they are done. The last line is the program's check: all three results are the sum of the two source tensors.
Common mistakes¶
- Different loop counts in the three kernels. If
reader.cpp's share andwriter.cpp's share differ, one waits for a tile that never comes, and the command ends withCommandTimeout. - Writing the tensor while the program is loaded.
destination.write(...)waits for every core to be idle, and never returns: it fails after a minute. Make tensors beforeload. - More cores than tiles. The last cores get
share.len == 0and their loops run zero times; the command still finishes.
A core can now read tiles from DRAM, work on them with the compute kernel and the matrix unit, write them back, and a group can do that for a whole tensor. One more lesson in this part lets both data-movement CPUs of a core carry one stream.
Next: lesson 23, Both movers.