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¶
pieceis 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.argumentis 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. Pass0when there is nothing to pass.BODYis the work of one core. It usescoreto choose its share, as a Tensix core uses its rank.kmx_parallel(piece, &VALUE)startspieceon cores 1, 2 and 3, runs it on core 0, and returns when all four have finished. It returnsKMX_OK, or another code when it could not start.- Only core 0 calls
kmx_parallel, and not from inside apiece. - Inside
piece, a core may read the arguments, the clock and its regions. It may not submit commands, and it should not callkmx_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¶
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 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:
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.
loadstops withkomorebi.build.BuildError, and the linker's message in it ends withx280.ld: per-hart images must carry no .bss -- entry.S zeroes none. Give the array a non-zero first value and the.dataattribute. - A number of items that four does not divide. With
n = 4000002the shares are1000000each, and the last two numbers are in nobody's piece. The sum comes out wrong, and nothing says so. - Calling
kmx_submitinsidepiece. Cores 1 to 3 may not, and the run ends with the library errorparallel. Collect what you need in the array and submit fromkmx_main.
Next: lesson 35, Sharing memory is about the memory that Python and a thread both use.