Every way to write and call a kernel#
A kernel is a plain function over one sample. This page is a tour of the forms:
what you can return, how the outputs get their names, what a call looks like,
what happens on the first build, and what hawk refuses. Each line says what it does.
It runs on the CPU; a GPU only changes where the arrays live.
Colab or Kaggle: run this first cell (it installs the packages and downloads the helper files this notebook uses). Elsewhere it only checks the imports.
import logging
import os
import sys
import tempfile
os.environ["HAWK_CACHE_DIR"] = tempfile.mkdtemp() # keep this page's build in a folder of its own
log = logging.getLogger("hawk") # show the one line a first build logs
log.setLevel(logging.INFO)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(type("Short", (logging.Formatter,), {
"format": lambda self, r: super(type(self), self).format(r).replace(os.environ["HAWK_CACHE_DIR"], "<cache folder>")})())
log.addHandler(handler) # (this page prints the folder as <cache folder>)
import numpy as np
import hawk
from hawk import Mutable, Param, Scalar
x = np.arange(4.0)
Call it like a function#
k(x, a, b) runs the kernel over the whole array. The first call builds it (the
log line below says where; delete that folder to rebuild); later calls, and later
runs of your program, reuse the cache.
@hawk.kernel
def scale(x: Scalar, a: Param, b: Param) -> Scalar: # `-> Scalar` is optional; when written, it is checked
return a * x + b # the returned value is the output
print(scale(x, 2.0, 1.0)) # positional, in the order the kernel declares
print(scale(x=x, a=2.0, b=1.0)) # or by name, in any order
building scale (host) in <cache folder> — delete the directory to rebuild
building scale (cuda) in <cache folder> — delete the directory to rebuild
[1. 3. 5. 7.]
[1. 3. 5. 7.]
What a kernel can return#
One value, a tuple, a list, a dict, or any nesting of them. The call gives back the same shape, with an array in place of each value.
@hawk.kernel
def one(x: Scalar):
return x + 1.0 # one value: you get one array
@hawk.kernel
def pair(x: Scalar):
return x + 1.0, x * 3.0 # a tuple: you get a tuple
@hawk.kernel
def many(x: Scalar):
return [x + 1.0, x * 3.0] # a list: you get a list
@hawk.kernel
def named(x: Scalar):
return {"up": x + 1.0, "triple": x * 3.0} # a dict: you get a dict, with your keys
@hawk.kernel
def nested(x: Scalar):
up = x + 1.0
return {"up": up, "rest": [x * 3.0, (x * 4.0, x * 5.0)]} # any nesting of the above
for k in (one, pair, many, named, nested):
print(k.name, "->", k(x))
building one (host) in <cache folder> — delete the directory to rebuild
building one (cuda) in <cache folder> — delete the directory to rebuild
one -> [1. 2. 3. 4.]
building pair (cuda) in <cache folder> — delete the directory to rebuild
building pair (host) in <cache folder> — delete the directory to rebuild
pair -> (array([1., 2., 3., 4.]), array([0., 3., 6., 9.]))
building many (cuda) in <cache folder> — delete the directory to rebuild
building many (host) in <cache folder> — delete the directory to rebuild
many -> [array([1., 2., 3., 4.]), array([0., 3., 6., 9.])]
building named (cuda) in <cache folder> — delete the directory to rebuild
building named (host) in <cache folder> — delete the directory to rebuild
named -> {'up': array([1., 2., 3., 4.]), 'triple': array([0., 3., 6., 9.])}
building nested (cuda) in <cache folder> — delete the directory to rebuild
building nested (host) in <cache folder> — delete the directory to rebuild
nested -> {'up': array([1., 2., 3., 4.]), 'rest': [array([0., 3., 6., 9.]), (array([ 0., 4., 8., 12.]), array([ 0., 5., 10., 15.]))]}
How the outputs are named#
Each returned value is an output slot of the kernel, named after the variable it was
returned from (up above is up). A value that is an expression has no name, so it
is out when it is alone, and out<i>, numbered by its place in the return, when there are several. The names matter
for two things: you can hand an array of your own in by that name, and a gradient is keyed by
them. A name may be returned once, and may not repeat a parameter.
def outputs(k):
return [name for role, name in k.arg_spec if role == "mutable"]
print(outputs(one)) # an expression: `out`
print(outputs(pair)) # several expressions: `out0`, `out1`
print(outputs(nested)) # `up` is named, the others are numbered by place
mine = np.zeros(4)
print(one(x, out=mine)) # your own array for the slot `out`: written in place and returned
print(mine)
['out']
['out0', 'out1']
['out1', 'out2', 'out3', 'up']
[1. 2. 3. 4.]
[1. 2. 3. 4.]
A Mutable is updated in place#
A Mutable parameter is an array the kernel reads and writes. It needs no return;
you pass your array and it holds the answer. Mixed with a return, you get the
returned values back and the Mutable updated.
@hawk.kernel
def bump(x: Scalar, acc: Mutable[Scalar]):
acc = acc + x # update in place: no return, the call gives back None
@hawk.kernel
def bump_and_double(x: Scalar, acc: Mutable[Scalar]):
acc = acc + x # in place, as above
twice = 2.0 * x
return twice # and one returned output, named `twice`
acc = np.zeros(4)
print(bump(x, acc), acc) # None, and acc has moved
print(bump_and_double(x, acc), acc) # the returned array, and acc moved again
building bump (host) in <cache folder> — delete the directory to rebuild
building bump (cuda) in <cache folder> — delete the directory to rebuild
[0. 1. 2. 3.] [0. 1. 2. 3.]
building bump_and_double (host) in <cache folder> — delete the directory to rebuild
building bump_and_double (cuda) in <cache folder> — delete the directory to rebuild
[0. 2. 4. 6.] [0. 2. 4. 6.]
The -> annotation#
Optional. When you write it, hawk checks it against what the body returns, at decoration, so a mismatch fails when you define the kernel.
@hawk.kernel
def checked(x: Scalar) -> tuple[Scalar, Scalar]: # says: two values
return x + 1.0, x * 3.0
print(checked(x))
building checked (host) in <cache folder> — delete the directory to rebuild
building checked (cuda) in <cache folder> — delete the directory to rebuild
(array([1., 2., 3., 4.]), array([0., 3., 6., 9.]))
What it refuses#
Each refusal names the rule and the way out.
def refused(label, define):
try:
define()
except hawk.HawkError as e:
print(f"{label}: {e}\n")
def annotation_mismatch():
@hawk.kernel
def bad(x: Scalar) -> tuple[Scalar, Scalar]: # promises two values ...
return x + 1.0 # ... returns one
def class_instance():
class P:
def __repr__(self): return "P()"
@hawk.kernel
def bad(x: Scalar):
return P() # only values, tuples, lists and dicts
def name_reuse():
@hawk.kernel
def bad(x: Scalar):
return x # returning a parameter under its own name
def buffer_parameter():
@hawk.kernel
def bad(x: Scalar, buffer: Scalar): # `buffer` is the graph builder's keyword
return x + 1.0
for label, f in (("annotation mismatch", annotation_mismatch), ("class instance", class_instance),
("a parameter returned as itself", name_reuse), ("a parameter named buffer", buffer_parameter)):
refused(label, f)
annotation mismatch: kernel 'bad': `-> tuple[Scalar, Scalar]` declares a tuple, but the return is Scalar
class instance: kernel 'bad': the body returned P P(), not a traced value; a class instance is not supported — return a value, a tuple/list, or a dict of them
a parameter returned as itself: kernel 'bad': the return names an output 'x' after a parameter or another output; use another name (x_next)
a parameter named buffer: kernel 'bad': `buffer` is a reserved parameter name — `buffer=` is the keyword a kernel call takes inside an eagle graph builder for the array an output is written to; rename the parameter
Where to read more#
How a kernel is compiled and run: what the decorator and a call do, step by step.
Quickstart: the shortest path to a first answer.
In a graph, a kernel call records a node instead of running: see eagle’s graph model.