Skip to content

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:

const uint32_t rank = name.me() - km::first(command);

In Python:

handle = run.submit(KIND, group=(FIRST, COUNT))
  • name is the role object of the file, reader, compute or writer.
  • name.me() is the number of the core this code runs on, 0 to 119.
  • km::first(command) is FIRST, 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) is COUNT, how many cores the group has. The rank is always less than it.
The command 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 handlers of this program: the numbers that the kernels and Python agree on.
#pragma once

#include <stdint.h>

constexpr uint32_t kHandlerWork = 1;
// 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 at submit with ValueError: 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.