Across lanes#
A lane is one work item, and a kernel body is one lane’s code. x is this lane’s value, and nothing in the
body reaches another lane unless you say so. This page shows the three ways to say so: reduce over lanes,
scatter by key, and read another lane’s value. (A kernel with no Grid has one lane per sample.)
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.
Reduce: hawk.sum, max, min, mean#
hawk.sum(x) adds x over every lane. Store the result to a Mutable output and that output is a cell: one
value for the whole run. where= keeps only the lanes where a mask is true.
import warnings
warnings.filterwarnings("ignore")
import numpy as np
import eagle
import hawk
import hawk.math as m
from hawk import Grid, Index, Matrix, Mutable, Param, Scalar, Table, Vector
@hawk.kernel
def stats(x: Scalar, total: Mutable[Scalar], mean: Mutable[Scalar], positive: Mutable[Scalar]):
total = hawk.sum(x)
mean = hawk.mean(x)
positive = hawk.sum(x, where=x > 0.0)
@hawk.kernel
def peak(x: Scalar, hi: Mutable[Scalar]):
hi = hawk.max(x)
stats
<Kernel stats slots=8>
eagle.deploy builds a kernel for the host and the GPU and hands back every cell finished, as a one-element array:
x = np.random.default_rng(1).normal(size=100)
out = eagle.deploy(stats).run(x=x)
print({k: float(v[0]) for k, v in out.items()})
print("numpy:", x.sum(), x.mean(), x[x > 0].sum())
print("max:", float(eagle.deploy(peak).run(x=x)[0]), "numpy:", x.max())
{'mean': -0.0736121212729471, 'positive': 29.154644636394995, 'total': -7.361212127294709}
numpy: -7.361212127294709 -0.07361212127294708 29.154644636395
max: 2.1178387550510482 numpy: 2.1178387550510482
A kernel reduces with one operation: hawk.sum and hawk.mean together (as above), or hawk.max, or hawk.min.
Put a hawk.max in its own kernel, as peak does.
Name the lane axes with Grid#
Declare the lane space with a Grid parameter and the axes get names. Labels go in numpy order, a bare label has its
extent given at run time (the first axis only), and "sample":50 fixes it. The fastest axis is the last one
declared: consecutive lanes along it are consecutive threads (or SIMD lanes).
over= names the axis to add up, and the other axes are kept. You bind a runtime extent by its label, channel=4,
and the result comes back shaped by what is left.
@hawk.kernel
def per_channel(lane: Grid["channel", "sample":50], x: Scalar) -> Scalar:
return hawk.sum(x * x, over="sample")
x = np.random.default_rng(1).normal(size=(4, 50)) # (channel, sample): sample is the fastest axis
out = eagle.deploy(per_channel).run(x=x, channel=4)
print("hawk :", np.asarray(out).round(4))
print("numpy:", (x * x).sum(axis=1).round(4))
hawk : [38.8846 34.1721 43.4679 55.4933]
numpy: [38.8846 34.1721 43.4679 55.4933]
over= also takes a position (over=-1 is the fastest axis) or a tuple of labels. A reduced output is shaped
(*payload, *kept axes): the value’s own shape first, then the lane axes that remain.
Sum inside the lane, or over an axis#
Two sums, two jobs. v.sum(axis=) folds the value’s own axes (a vector’s components, a matrix’s rows). It stays
inside the lane and reaches nobody. hawk.sum(v, over=) folds lanes, and it is the one that combines work from
many lanes.
A least-squares calibration of several channels uses both. Evaluating the fitted response adds up a lane’s own S
basis terms, so it is a sum inside the lane. The gradient of the misfit with respect to the coefficients adds one term
per sample, so it is a sum over the sample axis.
NC, NT, B, S = 3, 4, 64, 2
@hawk.kernel
def response(basis: Vector[S], theta: Table["slot":S]) -> Scalar:
w = m.vec(theta.at(slot=0), theta.at(slot=1))
return (basis * w).sum() # inside the lane: this lane's S terms
@hawk.kernel
def coef_grad(lane: Grid["channel", "term":NT, "sample":B],
feat: Table["slot":S, "term":NT, "sample":B],
resid: Table["channel":NC, "sample":B]) -> Vector[S]:
g = resid.at(channel=lane.channel, sample=lane.sample)
terms = m.vec(feat.at(slot=0, term=lane.term, sample=lane.sample),
feat.at(slot=1, term=lane.term, sample=lane.sample)) * g
return hawk.sum(terms, over="sample") # over an axis: B lanes become one
rng = np.random.default_rng(3)
basis, theta = rng.random((S, 10)), rng.random(S)
print("forward :", np.allclose(eagle.deploy(response).run(basis=basis, theta=theta), theta @ basis))
feat, resid = rng.random((S, NT, B)), rng.random((NC, B))
grad = eagle.deploy(coef_grad).run(feat=feat, resid=resid, channel=NC)
print("gradient:", grad.shape, np.allclose(grad, np.einsum("kib,ob->koi", feat, resid)))
forward : True
gradient: (2, 3, 4) True
Rule of thumb: if the numbers you add all live in one lane, write v.sum(axis=). If each lane holds one of the
terms, write hawk.sum(v, over=).
A value’s own axes are named "components", "rows" and "columns". hawk.sum of a matrix keeps its shape, and
mm.rows[i], mm.columns[j] and v.components[k] read one row, column or component by name. A vector or matrix cell
comes back as one array per entry, gram_0 ... gram_3 (row-major).
@hawk.kernel
def frame(mm: Matrix[2, 2], gram: Mutable[Matrix[2, 2]], top: Mutable[Vector[2]], colsum: Mutable[Vector[2]]):
gram = hawk.sum(mm) # a 2x2 cell
top = hawk.sum(mm.rows[0]) # one row of it
colsum = hawk.sum(mm.sum(axis="rows")) # the value's own axis first, then the lanes
mm = np.random.default_rng(2).normal(size=(4, 50)) # a matrix plane is (rows*columns, lanes)
out = eagle.deploy(frame).run(mm=mm)
print("gram :", [round(float(out[f"gram_{k}"][0]), 4) for k in range(4)], " numpy:", mm.sum(1).round(4))
print("top :", [round(float(out[f"top_{k}"][0]), 4) for k in range(2)], " numpy:", mm[:2].sum(1).round(4))
print("colsum:", [round(float(out[f"colsum_{k}"][0]), 4) for k in range(2)],
" numpy:", (mm[:2] + mm[2:]).sum(1).round(4))
gram : [1.0977, -1.9397, 3.7114, -4.4244] numpy: [ 1.0977 -1.9397 3.7114 -4.4244]
top : [1.0977, -1.9397] numpy: [ 1.0977 -1.9397]
colsum: [4.8091, -6.364] numpy: [ 4.8091 -6.364 ]
Scatter: add or store by key#
hawk.scatter(sink)[key] += v adds each lane’s value into the slot the key names, and = v stores it. The sink is
a Mutable declared with named axes, Mutable["bin":8]; a key is an index, a tuple of coordinates, or an Index
plane.
Many lanes may share a key. The adds are combined (atomics or a segmented sum), never a race, and the order is not fixed, so a floating-point total can differ in its last digits. A sink you pass keeps its contents and the adds land on top; an omitted one starts at zero.
@hawk.kernel
def hist(x: Scalar, bins: Mutable["bin":8]):
hawk.scatter(bins)[m.index(x * 8.0, 8)] += 1.0
@hawk.kernel
def put(key: Index, x: Scalar, buf: Mutable["slot":6]):
hawk.scatter(buf)[key] = x
u = np.random.default_rng(2).uniform(size=1000)
print("omitted :", eagle.deploy(hist).run(x=u))
start = np.full(8, 100.0)
print("passed :", eagle.deploy(hist).run(x=u, bins=start))
print("numpy :", np.histogram(u, bins=8, range=(0, 1))[0])
print("store :", eagle.deploy(put).run(key=np.arange(5, -1, -1, dtype=np.int32), x=np.arange(6.0)))
omitted : eagle.Array(shape=(8,), dtype=float64, device=cpu)
passed : [211. 246. 220. 226. 224. 224. 213. 236.]
numpy : [111 146 120 126 124 124 113 136]
store : eagle.Array(shape=(6,), dtype=float64, device=cpu)
If the keys arrive in order, say so with Sorted[Index] (equal keys sit next to each other). It is a promise that
lets hawk add each run of equal keys once instead of once per lane, and the result is the same. Set
HAWK_CHECK_SORTED=1 to have a bind verify the promise.
from hawk import Sorted
@hawk.kernel
def by_segment(seg: Sorted[Index], x: Scalar, total: Mutable["segment":4]):
hawk.scatter(total)[seg] += x
seg = np.sort(np.random.default_rng(1).integers(0, 4, 100)).astype(np.int32)
v = np.random.default_rng(2).normal(size=100)
print("hawk :", np.asarray(eagle.deploy(by_segment).run(seg=seg, x=v)).round(4))
print("numpy:", np.bincount(seg, v).round(4))
hawk : [-0.9941 2.0919 2.2402 -4.1799]
numpy: [-0.9941 2.0919 2.2402 -4.1799]
hawk.sum(terms, over="sample") and hawk.scatter(out)[lane.channel, lane.term] += terms are the same operation:
one returns the result, the other adds it into a sink you hand over.
Read another lane: .at#
u.at(i) is u as lane i holds it, and on a Grid the axes are named, u.at(row=i - 1, col=j). It is the same
.at a Table has. Only a plane that existed before the launch can be read at another lane, so u must be an
input, not something this kernel computes. An index out of range is undefined, so guard the edge with an if.
N = 6
@hawk.kernel
def reverse(x: Scalar, i: Index, y: Mutable[Scalar]):
y = x.at(i) * 2.0
@hawk.kernel
def laplacian(lane: Grid["row", "col":N], u: Scalar, h2inv: Param) -> Scalar:
i, j = lane.row, lane.col
if m.land(m.land(i > 0, i < N - 1), m.land(j > 0, j < N - 1)):
out = (u.at(row=i - 1, col=j) + u.at(row=i + 1, col=j)
+ u.at(row=i, col=j - 1) + u.at(row=i, col=j + 1) - 4.0 * u) * h2inv
else:
out = 0.0
return out
print("reverse :", eagle.deploy(reverse).run(x=np.arange(6.0), i=np.arange(5, -1, -1, dtype=np.int32)))
u = np.random.default_rng(0).normal(size=(N, N))
lap = eagle.deploy(laplacian).run(u=u, h2inv=1.0, row=N)
want = np.zeros_like(u)
want[1:-1, 1:-1] = u[:-2, 1:-1] + u[2:, 1:-1] + u[1:-1, :-2] + u[1:-1, 2:] - 4.0 * u[1:-1, 1:-1]
print("laplacian:", np.allclose(lap, want))
reverse : eagle.Array(shape=(6,), dtype=float64, device=cpu)
laplacian: True
The sum contract and policy=#
Every sum is within k ULP of the exactly rounded sum; k is on the per-target card. A plain sum’s bound grows with the
number of terms added into a slot (at worst that many times eps * sum(|term|)); the card tests it at the run
lengths it lists. A compensated sum is tested at k up to 1, however many terms. Both are measured as the error over
eps * sum(|term|) against math.fsum.
policy= on the operation picks the contract, with the same three words as eagle.reduce:
|
What you get |
|---|---|
|
for a sum over lanes, the fast form (atomics or a segmented sum), bound growing with the number of terms; for a scatter, the compensated form |
|
the tightest form, k up to 1, a little slower. A scatter keeps its correction in a companion plane |
|
(scatter only) the atomic add alone, no companion, bound growing with the number of terms |
|
the same bits every run, every thread count, CPU and GPU (a sum over every lane, over some axes, and |
max and min are exact, so they ignore the policy. hawk.run takes a cell as the output you bind: a (1,) array
for a scalar cell, a (columns,) array for a vector cell. It adds the partial sums on the host, and the result is
bitwise equal to eagle’s "ordered" policy. Every eagle door (eagle.deploy, a graph, the JAX bridge) hands back
the same finished cell. To add up values yourself, use eagle.reduce.
import tempfile
import hawk.artifact
xs = np.random.default_rng(0).normal(size=1000)
@hawk.kernel
def energy(x: Scalar, total: Mutable[Scalar]):
total = hawk.sum(x * x)
work = tempfile.mkdtemp()
hawk.artifact.build(energy, work, targets=("host",))
cell = np.zeros(1)
hawk.run(hawk.load(work, "energy"), x=xs, total=cell)
ordered = np.zeros(1)
eagle.Graph().add_node(eagle.reduce(xs * xs, ordered, policy="ordered"), name="sum").build().launch()
print("hawk.run cell :", cell[0], " numpy:", (xs * xs).sum())
print("bitwise equal to eagle.reduce(policy='ordered'):", cell[0] == ordered[0])
hawk.run cell : 956.3530648422026 numpy: 956.3530648422026
bitwise equal to eagle.reduce(policy='ordered'): True
Read a total in the same kernel#
Reading a reduced value later in the body is allowed. The total exists only after every lane has been added, so a read
of it is a launch boundary: the kernel is built as stages, one launch each, with the total passed between them as a
cell. hawk.run and the eagle doors (Plan.run, bind, the reverse, the torch, JAX and Warp bridges) run the stages
in sequence. Each stage is compiled and cached on its own, so editing one stage recompiles only that one. A per-lane
value a later stage needs is computed again there. A cell that only a later stage reads is marked internal in the
sidecar’s cells and is not handed back to you.
import json, pathlib
@hawk.kernel
def share(x: Scalar, z: Mutable[Scalar]):
z = x / hawk.sum(x) # reads the total: two stages
split_dir = tempfile.mkdtemp()
hawk.artifact.build_bundle([share], split_dir, targets=("host",)) # build() publishes one launch
sidecar = json.loads((pathlib.Path(split_dir) / "share.json").read_text())
print("stages:", [(s["kernel"], s["cells_in"], s["cells_out"]) for s in sidecar["stages"]])
xs = np.random.default_rng(0).uniform(1.0, 2.0, 10)
z = np.zeros(10)
hawk.run(hawk.load(split_dir, "share"), x=xs, z=z)
print("z sums to one:", np.isclose(z.sum(), 1.0), " matches numpy:", np.allclose(z, xs / xs.sum()))
try:
hawk.run(hawk.load(split_dir, "share"), x=xs, z=z, count=5)
except Exception as exc:
print("refused:", str(exc).split(": ", 1)[-1])
stages: [('share', [], ['cut0']), ('share_2', ['cut0'], [])]
z sums to one: True matches numpy: True
refused: this kernel reads a value reduced over every sample, so it runs over every sample (no base= or count=)
build_bundle is the door for a split kernel; hawk.artifact.build publishes exactly one launch and says so.
until_done and simulate refuse a split kernel, because they step it many times.
Forms that are gone#
hawk.all_samples and the other older spellings are refused, each with the line that replaces it:
import re
import textwrap
def refused(label, build):
try:
build()
except Exception as exc:
text = re.sub(r"^(line \d+|\S+:\d+): ", "", str(exc))
print(f"{label}\n" + textwrap.indent(textwrap.fill(text, 96), " ") + "\n")
def accum():
hawk.Accum
def contribute():
@hawk.kernel
def k(x: Scalar, y: Mutable[Scalar]):
y.contribute(x)
def add_at():
@hawk.kernel
def k(x: Scalar, key: Index, y: Mutable[Scalar]):
y.add(x, at=key)
def own_sum():
@hawk.kernel
def k(x: Scalar, y: Mutable[Scalar]):
y = x.sum()
def samples_axis():
@hawk.kernel
def k(v: Vector[3], y: Mutable[Scalar]):
y = v.sum(axis="samples")
def masked_store():
@hawk.kernel
def k(x: Scalar, y: Mutable[Scalar]):
y = x
hawk.all_samples(y)[x > 0.0] = x
def in_a_branch():
@hawk.kernel
def k(x: Scalar, y: Mutable[Scalar]):
if x > 0.0:
y = hawk.sum(x)
refused("hawk.all_samples", lambda: hawk.all_samples)
refused("Accum", accum)
refused("y.contribute(x)", contribute)
refused("y.add(x, at=key)", add_at)
refused("x.sum() of one lane", own_sum)
refused('v.sum(axis="samples")', samples_axis)
refused("all_samples(y)[mask] = v", masked_store)
refused("hawk.sum inside an if", in_a_branch)
hawk.all_samples
hawk.all_samples is gone. all_samples(x).sum() is hawk.sum(x) (max/min/mean the same;
over="<axis>" keeps the other lane axes); all_samples(x)[mask].sum() is hawk.sum(x, where=mask);
all_samples(m).rows[0].sum() is hawk.sum(m.rows[0]); all_samples(y)[key] += v is
hawk.scatter(y)[key] += v; all_samples(y)[mask] = v is y = m.where(mask, v, y);
all_samples(u)[i] is u.at(i)
Accum
Accum is gone: declare Mutable["bin":n] and write hawk.scatter(bins)[key] += term in the body
y.contribute(x)
'y': write y = hawk.sum(v) (or hawk.max/min/mean)
y.add(x, at=key)
'y': write hawk.scatter(y)[key] += v (accumulate by key) or hawk.scatter(y)[key] = v (store by
key)
x.sum() of one lane
x.sum(): a rank-0 value is this lane's and has no axis to fold; the sum over the lanes is
hawk.sum(x)
v.sum(axis="samples")
x.sum(axis='samples') is gone: the sum over every lane is hawk.sum(x), over some lane axes
hawk.sum(x, over=...)
all_samples(y)[mask] = v
hawk.all_samples is gone: a store by key is hawk.scatter(y)[key] += v (or = v), and a masked
store keeps the old value where the mask is false: y = m.where(mask, v, y)
hawk.sum inside an if
hawk.sum(...) reduces over the lanes, a collective every lane reaches: it may not sit inside a
branch or a `for`; reduce at the top level and fold the condition into where=
The rewrite table#
Retired |
Write instead |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Limits#
A split kernel runs over every lane:
base=andcount=onhawk.runare refused. Doors that run single-stage kernels only (until_done,simulate, the step loops) refuse it by name:'<kernel>' runs in N stages; <door> runs single-stage kernels in this release. Use a plain kernel per step there.A reduction cannot sit under an
ifor in afor: hoist it out and fold the condition intowhere=. A scatter cannot sit under anifeither: addm.where(cond, v, 0.0)instead. A scatter in aforoverrange(...)is fine, each pass is its own scatter.One kernel reduces with one operation (sum or mean, or max, or min). Split the kernel.
maxandminover some of the lane axes, andpolicy="ordered"over some axes or on a scatter, come in the next release. Usehawk.max(x)(one value for the run) orpolicy="auto"for now.A scatter adds with
+=or stores with=; there is no by-key max or min yet.u.at(i)reads the inputuas it was before the launch: not a value computed in this kernel, and not a plane the launch writes.