Skip to content

Part 5. X280 threads · lesson 34 of 42

34. Four cores

A cluster has four X280 cores, and kmx_parallel runs one function on all four, each with its own number. This lesson relies on lesson 32, Hello, thread and lesson 12, Partitions.

Why four cores

kmx_main runs on core 0 alone, so a thread that only uses kmx_main uses a quarter of its cluster. Work that can be cut into independent pieces can use all four, in the way that lesson 12, Partitions cut work among Tensix cores: each core takes a part of the numbers, and one core adds the parts together at the end.

The general form

static void piece(int core, void* argument) {
    BODY
}

int status = kmx_parallel(piece, &VALUE);
  • piece is a function that you write, with exactly this form: it takes the number of the core that runs it, 0 to 3, and a pointer.
  • argument is the pointer you passed. It is the same on all four cores. The function casts it back to what it points to, for instance *(const uint32_t*)argument. Pass 0 when there is nothing to pass.
  • BODY is the work of one core. It uses core to choose its share, as a Tensix core uses its rank.
  • kmx_parallel(piece, &VALUE) starts piece on cores 1, 2 and 3, runs it on core 0, and returns when all four have finished. It returns KMX_OK, or another code when it could not start.
  • Only core 0 calls kmx_parallel, and not from inside a piece.
  • Inside piece, a core may read the arguments, the clock and its regions. It may not submit commands, and it should not call kmx_result.

The cores share memory, so a core hands its piece to core 0 through an ordinary array. The array is a global variable, and a global variable of a thread must start with a value that is not zero, and carry the attribute __attribute__((section(".data"))), because the thread's image has no place for variables that start as zero.

One line at a time

__attribute__((section(".data"))) static uint32_t partial[4 * LINE_WORDS] = {1};

Four lines of 16 numbers, one line for each core: a core writes to the first number of its own line. A line of 64 bytes is the unit in which the cores' caches exchange memory, so each core's piece lies in a line of its own. This lesson does not measure what sharing a line would cost. The = {1} is the non-zero start, and the attribute puts the array where the image allows it.

// A number made from i, with no formula for the sum of them.
static uint32_t scramble(uint32_t i) {
    i ^= i >> 15;
    i *= 2246822519u;
    i ^= i >> 13;
    return i;
}

static uint32_t scrambled_sum(uint32_t first, uint32_t last) {
    uint32_t sum = 0;
    for (uint32_t i = first; i < last; i++) {
        sum += scramble(i);
    }
    return sum;
}

scrambled_sum(first, last) adds up a number made from each i from first up to, but not including, last. The number is scrambled by scramble so that the loop has real work and no formula for its sum. It is ordinary C: a loop, a function, no library.

static void piece(int core, void* argument) {
    const uint32_t n = *(const uint32_t*)argument;
    const uint32_t share = n / 4;
    partial[core * LINE_WORDS] = scrambled_sum(core * share, (core + 1) * share);
}

Every core adds up its quarter of the numbers 0 to n - 1: n comes through the pointer, share is a quarter of it, and core c takes the numbers from c * share up to, but not including, (c + 1) * share. Core 0 and core 3 run the same code on different numbers, and each stores its sum in its own line.

const uint64_t four_start = kmx_time();
if (kmx_parallel(piece, &n) != KMX_OK) {
    return 1;
}
const uint64_t four_ticks = kmx_time() - four_start;

The call, timed with the thread's clock. Before it, the same thread added all the numbers on core 0 alone, and timed that too. After it, core 0 adds the four pieces.

numbers = np.arange(N, dtype=np.uint32)
numbers ^= numbers >> 15
numbers *= np.uint32(2246822519)
numbers ^= numbers >> 13
expected = int(numbers.sum(dtype=np.uint64)) % 2**32
alone, together, one_us, four_us = result.words
print(f"scrambled numbers 0 to {N - 1}, modulo 2**32: {expected}")
print(f"one core:   {alone} in {one_us} microseconds")
print(f"four cores: {together} in {four_us} microseconds")
print(f"four cores took {one_us / four_us:.1f} times less time")

Python computes the same sum with numpy, so that it can check the thread's answer, and prints the two times.

The same sum, alone and on four cores.

The whole program

"""Four cores: an X280 thread adds up numbers on one core, then on all four."""

from pathlib import Path

import numpy as np

import komorebi as km

HERE = Path(__file__).resolve().parent

N = 4_000_000

program = km.Program("four_cores", kernels=HERE / "kernels")
program.thread(
    cluster=1,
    sources=[HERE / "thread" / "four.c"],
    regions={"unused": 64},
    words=[N],
)

with km.Session() as session:
    run = session.load(program)
    result = run.thread_result(timeout=10.0)
    run.stop()

numbers = np.arange(N, dtype=np.uint32)
numbers ^= numbers >> 15
numbers *= np.uint32(2246822519)
numbers ^= numbers >> 13
expected = int(numbers.sum(dtype=np.uint64)) % 2**32
alone, together, one_us, four_us = result.words
print(f"scrambled numbers 0 to {N - 1}, modulo 2**32: {expected}")
print(f"one core:   {alone} in {one_us} microseconds")
print(f"four cores: {together} in {four_us} microseconds")
print(f"four cores took {one_us / four_us:.1f} times less time")

assert result.code == 0 and alone == together == expected
/* An X280 thread that adds up scrambled numbers for 0 to n - 1: on core 0 alone, then
 * on all four cores. */
#include "kmx.h"

#define LINE_WORDS 16 /* a 64-byte cache line holds 16 words */

// Each core stores its piece of the sum in a line of its own.
__attribute__((section(".data"))) static uint32_t partial[4 * LINE_WORDS] = {1};

// A number made from i, with no formula for the sum of them.
static uint32_t scramble(uint32_t i) {
    i ^= i >> 15;
    i *= 2246822519u;
    i ^= i >> 13;
    return i;
}

static uint32_t scrambled_sum(uint32_t first, uint32_t last) {
    uint32_t sum = 0;
    for (uint32_t i = first; i < last; i++) {
        sum += scramble(i);
    }
    return sum;
}


static void piece(int core, void* argument) {
    const uint32_t n = *(const uint32_t*)argument;
    const uint32_t share = n / 4;
    partial[core * LINE_WORDS] = scrambled_sum(core * share, (core + 1) * share);
}


int kmx_main(int core) {
    (void)core;
    uint32_t n = kmx_args_get()->words[0];

    const uint64_t one_start = kmx_time();
    const uint32_t alone = scrambled_sum(0, n);
    const uint64_t one_ticks = kmx_time() - one_start;

    const uint64_t four_start = kmx_time();
    if (kmx_parallel(piece, &n) != KMX_OK) {
        return 1;
    }
    const uint64_t four_ticks = kmx_time() - four_start;

    uint32_t total = 0;
    for (int h = 0; h < 4; h++) {
        total += partial[h * LINE_WORDS];
    }
    const uint32_t words[4] = {
        alone, total, (uint32_t)(one_ticks / 50), (uint32_t)(four_ticks / 50)
    };
    kmx_result(0, words, 4);
    return 0;
}

The kernels are the empty ones of lesson 4, Hello, core. Run it from the folder tutorial/34_four_cores:

python four_cores.py
scrambled numbers 0 to 3999999, modulo 2**32: 4143007725
one core:   4143007725 in 24000 microseconds
four cores: 4143007725 in 6001 microseconds
four cores took 4.0 times less time

Both sums equal the one numpy computed. Four cores did the work in a quarter of the time of one, 4.0 times as fast: the work is arithmetic, every core has its own share of it, and nothing has to wait for another core. Work that mostly moves memory does not scale like this; lesson 36, Twelve cores comes back to it.

Common mistakes

  • A global array that starts as zero. load stops with komorebi.build.BuildError, and the linker's message in it ends with x280.ld: per-hart images must carry no .bss -- entry.S zeroes none. Give the array a non-zero first value and the .data attribute.
  • A number of items that four does not divide. With n = 4000002 the shares are 1000000 each, and the last two numbers are in nobody's piece. The sum comes out wrong, and nothing says so.
  • Calling kmx_submit inside piece. Cores 1 to 3 may not, and the run ends with the library error parallel. Collect what you need in the array and submit from kmx_main.

Next: lesson 35, Sharing memory is about the memory that Python and a thread both use.