Part 2. Many cores · lesson 11 of 42
11. A core group¶
A command goes to every core of its group, and every core runs the same kernel code on it; the one thing that tells the cores apart is their rank, the core's position in the group. This lesson relies on lesson 4, Hello, core, lesson 5, Commands and lesson 10, When it goes wrong.
Why a group¶
One core is a small part of the card: 120 of them are there. Work that is the same for
every core, such as the same arithmetic on a different piece of data, should go to many
cores with one command, not one command each. The group in submit is how: group=(FIRST,
COUNT) is COUNT cores in a row, starting at core number FIRST, and all of them get the
one command (lesson 4, Hello, core).
If every core did exactly the same, nothing would be gained from having many. The kernel needs a way to ask "which of the cores am I?". The rank is the answer.
The general form¶
In the kernel, inside the function you give to run:
In Python:
nameis the role object of the file,reader,computeorwriter.name.me()is the number of the core this code runs on, 0 to 119.km::first(command)isFIRST, the first core of the command's group.- Their difference is the rank: 0 for the first core of the group, 1 for the next,
and so on to
COUNT - 1. It does not depend on where in the card the group is: a group that starts at core 3 has the same ranks 0, 1, 2, 3 as one that starts at core 40. km::count(command)isCOUNT, how many cores the group has. The rank is always less than it.
group=(3, 4): four cores, from core 3. Core 3 has rank 0, core 6 has rank 3. The other cores let the command pass.One line at a time¶
writer.run([&](const km_command_t& command) {
if (km::handler(command) == kHandlerWork) {
const uint32_t rank = writer.me() - km::first(command);
const uint32_t limit = rank * command.a0;
volatile uint32_t number = 0;
while (number < limit) {
number = number + 1;
}
}
return true;
});
writer.cpp of every core of the group runs this. The line with rank is the one from the
general form. The next line makes a limit from it: the core of rank 0 gets 0, the core of
rank 3 gets three times the first argument. The while loop counts up to the limit, as in
lesson 10, When it goes wrong, and takes time in proportion to it. At the end every core returns
true, so every core reports.
for count in (1, 4, 12):
handle = run.submit("work", group=(0, count), args=[1_000_000])
event = run.wait(handle, timeout=10.0)
took[count] = event.microseconds / 1000
print(f"group of {count:2}: the command took {took[count]:5.1f} milliseconds")
The same command, with the argument 1,000,000, goes to a group of 1 core, then of 4, then
of 12, each starting at core 0. The loop prints how long each command took from submit to
the report, using the event's microseconds. took is a dictionary made before the loop;
the program keeps each time in it to check them at the end.
A command is done when every core of its group has reported. The group of 12 contains a core of rank 11, which counts to 11 million, about 66 milliseconds, and so the command takes about 66 milliseconds even though most of the cores finished long before. The cores worked at the same time; the slowest one set the time.
The whole program¶
"""A core group: one command, many cores; the core of rank r works r times as long."""
from pathlib import Path
import komorebi as km
HERE = Path(__file__).resolve().parent
program = km.Program(
"core_group",
kernels=HERE / "kernels",
handlers=HERE / "kernels" / "handlers.h",
)
with km.Session() as session:
run = session.load(program)
took = {}
for count in (1, 4, 12):
handle = run.submit("work", group=(0, count), args=[1_000_000])
event = run.wait(handle, timeout=10.0)
took[count] = event.microseconds / 1000
print(f"group of {count:2}: the command took {took[count]:5.1f} milliseconds")
run.stop()
assert took[1] < took[4] < took[12]
// The writer kernel: every core of the group counts, a core of rank r up to r times the
// first argument, so that the cores take different times.
#include "komorebi/device/km_worker.hpp"
#include "handlers.h"
void kernel_main() {
km::Writer writer;
writer.run([&](const km_command_t& command) {
if (km::handler(command) == kHandlerWork) {
const uint32_t rank = writer.me() - km::first(command);
const uint32_t limit = rank * command.a0;
volatile uint32_t number = 0;
while (number < limit) {
number = number + 1;
}
}
return true;
});
}
reader.cpp and compute.cpp are the empty ones. Run it from the folder
tutorial/11_core_group:
group of 1: the command took 0.0 milliseconds
group of 4: the command took 17.8 milliseconds
group of 12: the command took 65.2 milliseconds
The group of 1 core has only rank 0, which counts to 0, so its command took a few microseconds, and the line shows 0.0 milliseconds. The group of 4 has a core of rank 3, 3 million counts, 18 milliseconds. The group of 12: 11 million counts, 65. The numbers differ a little on every run, and the script checks only that more cores took longer.
Common mistakes¶
- Taking the rank for the core number. It is the core number minus the group's first core; they are equal only when the group starts at core 0.
- Expecting the command to take the average time. It takes the time of the slowest core.
- A group that runs past the last core.
group=(100, 30)stops atsubmitwithValueError: group (100, 30) is not inside 120 cores. - Giving every core of the group the same work by mistake: if the kernel never looks at the rank, all of them do everything, and the group is a way to do the same thing 12 times.
Next: lesson 12, Partitions uses the rank to give each core its own piece of a job.