One sample, one lane: .at, hawk.scatter and sample_index#
Read your neighbour, total a block, and find out which lane you are: per-sample control, written as an ordinary kernel body.
Time: ~8 min · Runs on: CPU · You need: hawk, NumPy and matplotlib; read first: Lookups, scatters and sums
A hawk kernel body describes ONE sample, and every sample runs it. Two names let that one sample reach beyond itself:
Name |
What it gives you |
|---|---|
|
this sample’s own position in the batch, as an integer value |
|
the input |
Together they cover what a plain per-sample body cannot say: “my left neighbour”, “which block am I in”. Here they smooth a signal and total it in blocks of four, and the result is checked against NumPy.
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.
Read your neighbours#
Smoothing replaces each value by a weighted mix of itself and its two neighbours. Sample i needs sample i - 1 and i + 1, so it asks for them by index: x.at(j) is x as sample j holds it. The first and last samples have only one neighbour, so they read themselves instead:
import numpy as np
import hawk
from hawk import Mutable, Scalar
from hawk.math import n_samples, sample_index, select
@hawk.kernel
def smooth(x: Scalar, y: Mutable[Scalar]):
i = sample_index()
left = x.at(select(i > 0, i - 1, i))
right = x.at(select(i < n_samples() - 1, i + 1, i))
y = 0.25 * left + 0.5 * x + 0.25 * right
smooth
<Kernel smooth slots=3>
Build it and run it on a noisy sine, then compare with the same stencil written in NumPy:
import pathlib, tempfile
import hawk.artifact
work_dir = pathlib.Path(tempfile.mkdtemp())
n = 48
rng = np.random.default_rng(0)
x = np.sin(np.linspace(0.0, 2.0 * np.pi, n)) + 0.3 * rng.normal(size=n)
y = np.zeros(n)
hawk.artifact.build(smooth, work_dir, targets=("host",))
hawk.run(hawk.load(work_dir, "smooth"), x=x, y=y)
padded = np.r_[x[0], x, x[-1]]
expect = 0.25 * padded[:-2] + 0.5 * x + 0.25 * padded[2:]
print("matches the NumPy stencil:", np.allclose(y, expect))
matches the NumPy stencil: True
Nothing in the body loops over samples. sample_index() is a value like any other, so i - 1 and the select are ordinary per-sample arithmetic; only the read .at(...) crosses to another sample.
Total by block#
hawk.scatter(...)[key] += value goes the other way: each sample adds its value into the bin key names. Using the sample’s own position as the key, with hawk.math.split_index(4) returning its block (i // 4) and its place in the block (i % 4), totals every four consecutive samples:
from hawk.math import split_index
@hawk.kernel
def block_sums(x: Scalar, totals: Mutable[Scalar]):
block, _place = split_index(4)
hawk.scatter(totals)[block] += x
block_sums
<Kernel block_sums slots=3>
totals = np.zeros(n // 4)
hawk.artifact.build(block_sums, work_dir, targets=("host",))
hawk.run(hawk.load(work_dir, "block_sums"), x=x, totals=totals)
print("matches a reshape and sum:", np.allclose(totals, x.reshape(-1, 4).sum(axis=1)))
matches a reshape and sum: True
What it costs#
A neighbour read is an input read.
x.at(j)reads the input planexat samplej. Only a plane that existed before the launch can be read at another sample, so a value computed in the body is recomputed from the inputs it needs.A by-key
+=is an atomic add. Samples that share a key add in no fixed order, so a floating-point total can differ in its last digits from run to run. A sum to a cell withhawk.runis the way to a fixed order (Across lanes lists the policies).You cannot read what this launch is writing. Another sample’s
Mutableoutput is not ready yet. Reading a reduced total in the body splits the kernel into stages, one launch each.Indexing is by position.
sample_index()is the sample’s place in the whole batch, so a kernel that uses it means the same thing however the batch is split across launches.
When not to use it#
If every sample only needs its own inputs, write the plain body: it is the fastest form and needs neither name. If the neighbours are the whole job on a regular grid, a NumPy slice or a convolution already says it in one line. Reach for .at, hawk.scatter and sample_index when the neighbour read sits inside a body that also does per-sample work (a branch, a stop, a lookup) that you want compiled as one kernel.
What just happened#
sample_index()is the sample’s own position, usable in arithmetic and as a key.x.at(j)reads samplej’sx;hawk.scatter(acc)[key] += vadds into shared bins;split_index(m)turns a position into a block and a place.Both read other samples without a Python loop, and each has a cost you can name.
Try this#
Change the stencil to reach two samples to the left and right (a five-point smooth) and check it against np.convolve.
Next#
Describe your system once — declare a reusable kernel shape once, so a whole family of models shares one vocabulary. Deeper: Across lanes (masks, named axes, vector cells, reduction policies).