Part 4. Cores talking to cores · lesson 31 of 42
31. Streams and graphs¶
A graph describes several commands once, with which one waits for which, and a loop that repeats them until a number that the cores computed says to stop; Komorebi then runs it without Python in between. This lesson relies on lesson 8, Handlers, lesson 9, Many commands and lesson 6, A result.
Why a graph¶
Until now Python sent each command and decided what came next. Look at the cost: every decision sends a result back to your computer, Python thinks, and the next command goes out again. A program that repeats a step many times, each time using what the last step computed, spends most of its time in that round trip.
A graph moves the decisions to where the results are. It is a list of commands with the
order they depend on, after=[...]: a command starts when everything it is after is done,
and two commands that do not depend on each other and use different cores can be in flight
at the same time. A small processor step between commands can do arithmetic on
the results and decide whether to go round again. The graph is built once and then run
with one call.
A stream is the same idea for a straight line of commands without branches; its page is Micro-op streams. This lesson builds a graph.
The general form¶
from komorebi.engines.graph import Graph
graph = Graph(max_runs=ROUNDS)
name = graph.bindings("name")
constant = graph.const(NUMBER)
register = graph.register()
first = graph.command(HANDLER, group=(FIRST, COUNT), args=[...], result=register)
second = graph.command(HANDLER, group=(...), args=[register], after=[first])
step = graph.cpu(after=[second])
step.mov(name, register)
step.lt(graph.again, name, constant)
result = run.run_hostfed(graph, {"name": START})
Graph(max_runs=ROUNDS)makes an empty graph, andROUNDSis the most rounds it may go; more is an error.- A register is a 32-bit number that the graph keeps between its nodes.
graph.register()makes one that starts at 0;graph.bindings("name")makes one that Python fills in when the graph starts;graph.const(NUMBER)makes one that holdsNUMBER. graph.command(...)adds a command node.argsmay hold numbers and registers, and the registers are read when the command is sent.result=registerstores the command's result word (lesson 6, A result) in a register.after=[...]lists the nodes it waits for.graph.cpu(after=...)adds a step that runs a few instructions on the registers:mov,addi,ltand others.step.lt(graph.again, a, b)setsagainto 1 ifais less thanb, and whenagainis not 0 at the end of a round, another round starts.run.run_hostfed(graph, bindings)runs the graph: the executor, the part that sends a command when its predecessors are done, runs on your computer and uses the loaded program. The same graph can be uploaded to run on the X280 core, with the same result.result.runsis the number of rounds, andresult.registersthe registers at the end.
One line at a time¶
graph = Graph(max_runs=20)
value = graph.bindings("value") # a register that Python fills in at the start
limit = graph.const(LIMIT) # a register that holds 100
sum_register, doubled = graph.register(), graph.register()
add = graph.command(
program.handler("add"), group=(0, 1), args=[value, 3], result=sum_register
)
multiply = graph.command(
program.handler("multiply"),
group=(1, 1),
args=[sum_register, 2],
after=[add],
result=doubled,
)
step = graph.cpu(after=[multiply])
step.mov(value, doubled) # the next round starts from the doubled sum
step.lt(graph.again, value, limit) # and goes round again while value < 100
Three registers are made, and two nodes. add is the handler of lesson 8, Handlers, on core 0,
with the argument value and 3, and its result goes into sum_register. multiply
runs on core 1 and waits for add; its arguments are that register and 2. A step
after it copies the doubled number into value for the next round and sets again while
the value is below 100. The value goes 1 -> 8 -> 22 -> 50 -> 106, and stops at 106.
result = run.run_hostfed(graph, {"value": 1})
print(f"the graph went round {result.runs} times, ended with {result.registers[0]}")
One call runs all the rounds. The result has the number of rounds and the registers; register
0 is value, the first one made.
expected, rounds = 1, 0
while True:
expected, rounds = (expected + 3) * 2, rounds + 1
if expected >= LIMIT:
break
assert (result.runs, result.registers[0]) == (rounds, expected)
print(f"the same rounds in Python: {rounds} rounds, value {expected}")
The same rounds in a few lines of Python, as the check.
The whole program¶
"""Streams and graphs: commands that depend on each other, and a loop."""
from pathlib import Path
import komorebi as km
from komorebi.engines.graph import Graph
HERE = Path(__file__).resolve().parent
LIMIT = 100
program = km.Program(
"streams_and_graphs",
kernels=HERE / "kernels",
handlers=HERE / "kernels" / "handlers.h",
)
graph = Graph(max_runs=20)
value = graph.bindings("value") # a register that Python fills in at the start
limit = graph.const(LIMIT) # a register that holds 100
sum_register, doubled = graph.register(), graph.register()
add = graph.command(
program.handler("add"), group=(0, 1), args=[value, 3], result=sum_register
)
multiply = graph.command(
program.handler("multiply"),
group=(1, 1),
args=[sum_register, 2],
after=[add],
result=doubled,
)
step = graph.cpu(after=[multiply])
step.mov(value, doubled) # the next round starts from the doubled sum
step.lt(graph.again, value, limit) # and goes round again while value < 100
with km.Session() as session:
run = session.load(program)
result = run.run_hostfed(graph, {"value": 1})
print(f"the graph went round {result.runs} times, ended with {result.registers[0]}")
run.stop()
expected, rounds = 1, 0
while True:
expected, rounds = (expected + 3) * 2, rounds + 1
if expected >= LIMIT:
break
assert (result.runs, result.registers[0]) == (rounds, expected)
print(f"the same rounds in Python: {rounds} rounds, value {expected}")
// The writer kernel: the handler of the command decides what it computes.
#include "komorebi/device/km_worker.hpp"
#include "handlers.h"
void kernel_main() {
km::Writer writer;
writer.run([&](const km_command_t& command) {
const uint32_t left = command.a0;
const uint32_t right = command.a1;
if (km::handler(command) == kHandlerAdd) {
writer.result(left + right);
}
if (km::handler(command) == kHandlerMultiply) {
writer.result(left * right);
}
return true;
});
}
reader.cpp and compute.cpp are the empty ones, and the kernels are those of
lesson 8, Handlers.
Four rounds, ending at 106; the script's check, the same sum and product in a loop, gives the same.
Common mistakes¶
- A graph that never stops. If
againis still set aftermax_runsrounds, the graph ends with an error. - A node that names itself or a later node in
after. A node may only wait for nodes made before it. - Two nodes that can run at the same time, one writing a register that the other reads. The
result then depends on timing. Put one
afterthe other. - More than four predecessors for a node. Add a
graph.join(...)node to gather them.
That is the end of Part 4. Cores can now hand tiles to each other, add them up, copy one to many, send messages that the data routes, keep them from overrunning anyone, and describe whole chains of commands at once. Part 5 puts a program on the other kind of core.
Next: lesson 32, Hello, thread.