Tapes and events#
Record what a stepping kernel saw at every step, and turn its stop rule into a smooth function a runtime can differentiate.
Time: ~8 min · Runs on: CPU · You need: hawk and NumPy; read first: Your own derivative, Stop when you’re done
A gradient through a whole rollout needs, at every step, the state that step read. Keeping every state of every step is the simplest answer and the most expensive one; a launching runtime instead keeps a few checkpoints and, one window of steps at a time, re-runs the window while recording a tape. hawk builds the kernel that does that re-run: the taping twin.
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 pathlib, tempfile
import numpy as np
import hawk
import hawk.math
from hawk import Mutable, Param, Scalar, Terminated
@hawk.kernel
def ball(dt: Param, terminated: Terminated,
y: Mutable[Scalar], v: Mutable[Scalar], t: Mutable[Scalar]):
v_start = v
v = v_start - 9.81 * dt
y = y + dt * v_start
tn = t + dt
t = tn
terminated = y < 0.0 # it hit the ground
ball
<Kernel ball slots=7>
The twin#
hawk.diff.tape(kernel, window) returns <name>_tape: the same step,
plus three planes it reserves.
plane |
kind |
what it holds |
|---|---|---|
|
integer state |
the steps this sample has taken (the finishing step counts) |
|
output, |
row |
|
integer parameter |
record only when non-zero |
The row width w is not chosen: it is every state plane the step both
reads and overwrites — exactly what its one-step vjp needs as input.
from hawk.diff import tape
window = 8
ball_tape = tape(ball, window)
work_dir = pathlib.Path(tempfile.mkdtemp())
bundle = hawk.build([ball, ball_tape], work_dir, targets=("host",))
sidecar = next(a.sidecar for a in bundle.artifacts if a.name == "ball_tape")
print(ball_tape.name)
print(sidecar["arg_spec"])
ball_tape
[['mutable', 'applied_steps'], ['mutable', 't'], ['mutable', 'v'], ['mutable', 'y'], ['out', 'tape'], ['lookup', 'finished_count'], ['lookup', 'fused_steps'], ['terminated', 'terminated'], ['uniform', 'dt'], ['uniform', 'tape_on']]
The taped planes are t, v, y (the order of the kernel’s own
signature listing), so a row is 3 numbers. Let us run four balls from
different heights, one step per call, with the tape on:
def drop(name, tape_on):
n = 4
y = np.array([0.3, 0.5, 0.8, 1.0])
v, t = np.zeros(n), np.zeros(n)
stopped = np.zeros(n, dtype=bool)
planes = dict(y=y, v=v, t=t, terminated=stopped)
if name == "ball_tape":
planes.update(applied_steps=np.zeros(n, dtype=np.int64),
tape=np.full((window * 3, n), np.nan), tape_on=tape_on)
kernel = hawk.load(work_dir, name)
history = []
while not stopped.all():
history.append(np.stack([t, v, y]).copy())
hawk.run(kernel, dt=0.05, **planes)
return planes, history
taped, history = drop("ball_tape", tape_on=1)
print("steps taken:", taped["applied_steps"])
steps taken: [ 6 7 9 10]
Row j of a sample is its state before step j. For the first ball
(fewer steps than the window, so no row is reused):
rows = taped["tape"][:, 0].reshape(window, 3)
for j in range(int(taped["applied_steps"][0])):
print(j, rows[j], " before step", j, ":", history[j][:, 0])
0 [0. 0. 0.3] before step 0 : [0. 0. 0.3]
1 [ 0.05 -0.4905 0.3 ] before step 1 : [ 0.05 -0.4905 0.3 ]
2 [ 0.1 -0.981 0.275475] before step 2 : [ 0.1 -0.981 0.275475]
3 [ 0.15 -1.4715 0.226425] before step 3 : [ 0.15 -1.4715 0.226425]
4 [ 0.2 -1.962 0.15285] before step 4 : [ 0.2 -1.962 0.15285]
5 [ 0.25 -2.4525 0.05475] before step 5 : [ 0.25 -2.4525 0.05475]
Off is the same code#
With tape_on = 0 the twin runs the same compiled entry and stores
nothing: a forward that only needs the stop step (applied_steps) and
the final state takes exactly the result the recording re-run will
reproduce, bit for bit, because it is the same machine code.
quiet, _ = drop("ball_tape", tape_on=0)
same = all(np.array_equal(quiet[k], taped[k]) for k in ("y", "v", "t", "applied_steps"))
print("state identical:", same, "| tape untouched:", bool(np.isnan(quiet["tape"]).all()))
state identical: True | tape untouched: True
The stop rule as a smooth guard#
terminated = y < 0.0 is a flag; its derivative is zero wherever it
exists. To ask when the ball lands, a runtime needs the smooth function
that changes sign at the landing: the guard g.
hawk.diff.event.event_guard reads it out of the comparison, with one
convention — the sample stops where g <= 0:
stop rule |
guard |
|---|---|
|
|
|
|
|
one guard per comparison |
The guard is an ordinary expression of the step’s inputs, so
guard_kernel packages it as a kernel jvp and vjp differentiate like
any other:
from hawk.diff import jvp
from hawk.diff.event import event_guard, guard_kernel
print(event_guard(ball).rules)
ball_guard = guard_kernel(ball)
dg = hawk.Kernel("ball_guard_jvp", jvp(ball_guard, wrt=("y", "v")))
gdir = pathlib.Path(tempfile.mkdtemp())
hawk.build([ball_guard, dg], gdir, targets=("host",))
y, v, t = np.array([0.3, 0.02]), np.array([0.0, -1.0]), np.zeros(2)
g = np.zeros(2)
hawk.run(hawk.load(gdir, "ball_guard"), dt=0.05, y=y, v=v, t=t,
terminated=np.zeros(2, dtype=bool), event_g0=g)
dot_g = np.zeros(2)
hawk.run(hawk.load(gdir, "ball_guard_jvp"), dt=0.05, y=y, v=v, t=t,
terminated=np.zeros(2, dtype=bool), dot_y=np.zeros(2), dot_v=np.ones(2),
dot_event_g0=dot_g)
print("g :", g, " (the second ball stops this step: g <= 0)")
print("dg / dv :", dot_g, " (= dt: the step moves y by dt * v)")
('((y + (dt * v)) < 0.0)',)
g : [ 0.3 -0.03] (the second ball stops this step: g <= 0)
dg / dv : [0.05 0.05] (= dt: the step moves y by dt * v)
Rules that are refused#
A guard has to be a surface the state crosses. A flag, a count, an
equality, an and of two conditions or a rounding on the way to the
comparison has no smooth crossing, and is refused by name instead of
quietly giving a zero:
@hawk.kernel
def bounded(dt: Param, terminated: Terminated, y: Mutable[Scalar]):
y = y - dt
terminated = hawk.math.land(y < 0.0, y > -1.0)
try:
event_guard(bounded)
except hawk.HawkError as err:
print(err)
events="refine": the stop rule of 'bounded' is not a smooth crossing ((((y - dt) < 0.0) and ((y - dt) > -1.0))); refine needs g(x) that changes sign, not a count or a flag
Time is part of the state#
Where a stop happens depends on when as much as on where: a moving target, a fixed horizon. Naming the time plane and the step on the kernel makes time a state component the twin can reason about:
@hawk.kernel(time="t", time_step="dt")
The plain kernel does not change. hawk checks once, at decoration, that
the body advances t as t = t + dt (a body without a t gets one
minted by the twin). The twin then
reads
dtas a per-sample plane, so every sample may step differently, andwrites
tin index form,t = t_0 + k * dtwithkthe steps applied andt_0a per-sample start-time plane — one multiply and one add (a single fused multiply-add where the compiler contracts it), however long the run, instead of a sum that drifts by an ulp here and there at every step. Below, the twin’stsits within one rounding oft_0 + k * dt, while addingdtstep by step lands somewhere else.
@hawk.kernel(time="t", time_step="dt")
def target(dt: Param, a: Param, terminated: Terminated,
y: Mutable[Scalar], v: Mutable[Scalar], t: Mutable[Scalar]):
v_start = v
v = v_start - 9.81 * dt
y = y + dt * v_start
tn = t + dt
t = tn
terminated = y <= a * tn # a surface rising at speed a
target_tape = tape(target, 64)
tdir = pathlib.Path(tempfile.mkdtemp())
hawk.build([target_tape], tdir, targets=("host",))
n = 3
dt = np.array([0.01, 0.02, 0.03])
t0 = np.array([0.0, 0.5, 1.0])
p = dict(y=np.full(n, 1.0), v=np.zeros(n), t=t0.copy(),
terminated=np.zeros(n, dtype=bool), applied_steps=np.zeros(n, dtype=np.int64),
tape=np.zeros((64 * 3, n)), tape_on=0, dt=dt, t_0=t0, a=0.2)
while not p["terminated"].all():
hawk.run(hawk.load(tdir, "target_tape"), **p)
print("steps :", p["applied_steps"])
index = t0 + p["applied_steps"] * dt
summed = t0.copy()
for i in range(n):
for _ in range(int(p["applied_steps"][i])):
summed[i] += dt[i]
print("t - index form, in ulps :", (p["t"] - index) / np.spacing(index))
print("summed t - t, in ulps :", (summed - p["t"]) / np.spacing(index))
steps : [44 21 14]
t - index form, in ulps : [0. 1. 0.]
summed t - t, in ulps : [4. 3. 2.]
Refining the stop#
A step that crosses the surface overshoots it: the sample stopped
somewhere inside its last step. To find where, a runtime re-runs that
step with a partial size tau and looks for the tau at which the guard
is zero. hawk.diff.refine_entry(kernel) builds <name>_refine for exactly this:
one step of size <dt>_tau from the state before the stop step, with
time written as (t_0 + k * dt) + tau — so the derivative of time with
respect to tau is exactly one. It reads the state as plain inputs and
writes <plane>_refined and the guard event_g0, so jvp with respect
to tau gives the slope a Newton iteration needs:
from hawk.diff import refine_entry
refine = refine_entry(target)
slope = hawk.Kernel("target_refine_jvp", jvp(refine, wrt=("dt_tau",)))
rdir = pathlib.Path(tempfile.mkdtemp())
hawk.build([refine, slope], rdir, targets=("host",))
state = dict(y=np.array([0.05]), v=np.array([-2.0]), dt=np.array([0.05]),
t_0=np.zeros(1), applied_steps=np.array([10]), a=0.2)
outs = {k: np.zeros(1) for k in ("dot_y_refined", "dot_v_refined",
"dot_t_refined", "dot_event_g0")}
hawk.run(hawk.load(rdir, "target_refine_jvp"), dt_tau=np.array([0.02]),
dot_dt_tau=np.ones(1), **state, **outs)
print("d t / d tau :", outs["dot_t_refined"])
print("d g / d tau :", outs["dot_event_g0"], " (= v - a: the closing speed)")
d t / d tau : [1.]
d g / d tau : [-2.2] (= v - a: the closing speed)
Stopping on the grid, not one step late#
A stop rule can land exactly on a step: a fixed horizon t > T with
T a whole number of steps away. Rounding then decides whether the
comparison fires on that step or the next, and a strict > fires a
whole step late whenever t equals T exactly. Under refinement the
twin replaces the kernel’s own comparison by a closed, tolerant one,
g <= 4 * spacing(|t|) * |dg/dt| — a few ulps of the time, scaled by how
fast the guard moves with time (spacing is one ulp, hawk.math.spacing).
Ask for it with events="refine":
@hawk.kernel(time="t", time_step="dt")
def horizon(dt: Param, T: Scalar, terminated: Terminated, t: Mutable[Scalar]):
tn = t + dt
t = tn
terminated = tn > T
def stop_step(events):
hdir = pathlib.Path(tempfile.mkdtemp())
hawk.build([tape(horizon, 4, events=events)], hdir, targets=("host",))
h, m = np.array([0.1, 0.25, 0.3]), np.array([10, 4, 7])
p = dict(t=np.zeros(3), terminated=np.zeros(3, dtype=bool),
applied_steps=np.zeros(3, dtype=np.int64), tape=np.zeros((4, 3)),
tape_on=0, dt=h, t_0=np.zeros(3), T=m * h)
while not p["terminated"].all():
hawk.run(hawk.load(hdir, "horizon_tape"), **p)
return p["applied_steps"]
print("grid step m :", [10, 4, 7])
print("kernel's own test :", stop_step("step"))
print("events='refine' :", stop_step("refine"))
grid step m : [10, 4, 7]
kernel's own test : [11 5 8]
events='refine' : [10 4 7]
Changing a parameter without rebuilding#
A Param is handed to the kernel by value, with each launch. A runtime
that records a launch and replays it would have to record it again to
change one. hawk.diff.param_planes(kernel) returns <name>_params, the
same kernel with each Param read from a plane of ONE number instead.
The plane keeps the parameter’s own name, so you rebind k=... as before;
changing it later is a copy of one number, and nothing is recompiled or
re-recorded.
Taking the derivative of that kernel gives the gradient with respect to
each parameter as a per-sample plane (bar_k, one value per sample), not
a single running total. You add the samples up yourself, in the order you
choose, so the result is the same every time.
from hawk.diff import param_planes, vjp
@hawk.kernel
def law(x: Scalar, k: Param, y: Mutable[Scalar]):
y = k * x * x
law_params = param_planes(law)
law_grad = hawk.Kernel("law_grad", vjp(law_params, wrt=("x", "k")))
bundle2 = hawk.build([law_params, law_grad], work_dir, targets=("host",))
print(law_params.name, law_params.walk.arg_spec)
print(next(a.sidecar["param_planes"] for a in bundle2.artifacts
if a.name == "law_params"))
law_params (('mutable', 'y'), ('per_sample', 'x'), ('lookup', 'k'))
['k']
x = np.array([1.0, 2.0, 3.0])
k = np.array([2.0]) # one number, held in a plane of length 1
y = np.zeros(3)
step = hawk.load(work_dir, "law_params")
hawk.run(step, x=x, k=k, y=y)
print("k = 2:", y)
k[0] = 5.0 # same built kernel, new value
hawk.run(step, x=x, k=k, y=y)
print("k = 5:", y)
bar_k, bar_x = np.zeros(3), np.zeros(3)
hawk.run(hawk.load(work_dir, "law_grad"), x=x, k=k, bar_y=np.ones(3),
bar_k=bar_k, bar_x=bar_x)
print("per-sample dJ/dk:", bar_k, " sum:", bar_k.sum())
k = 2: [ 2. 8. 18.]
k = 5: [ 5. 20. 45.]
per-sample dJ/dk: [1. 4. 9.] sum: 14.0
param_planes(kernel, names) promotes only the parameters you name; the
sidecar lists the promoted planes under param_planes. Two limits: a
parameter used inside a for loop of the step body is refused, and an
integer parameter is promoted but has no gradient (a count has no slope).
The plain kernel is never changed.
What just happened#
hawk.diff.tape(kernel, window)builds<name>_tape: the step plusapplied_steps, atapeof pre-step states and atape_onswitch.The tape width is derived from what the one-step
vjpreads; nothing else is stored.tape_on = 0runs the same compiled entry, so recording and not recording agree bit for bit.event_guardturns a stop comparison into a guard withg <= 0at the stop, differentiable like any expression; non-smooth rules are refused.hawk.diff.param_planes(kernel)reads eachParamfrom a one-number plane, so a value can change without a rebuild; itsvjpgives a per-sample gradient plane.time=/time_step=make time a state component: the twin steps by a per-sampledtand writest = t_0 + k * dt;refine_entryre-runs the stop step with a partialtau, withd t / d tau = 1.tape(..., events="refine")swaps the stop comparison for a closed test with a few ulps of time tolerance, so a stop on the step grid is taken on its own step.
Try this#
Tape ball with window = 2 and drop it from 1.0: the run is longer
than the window, so rows are reused — row j mod 2 holds the most
recent step j that landed there.
Next#
eagle’s tutorials drive the twin from the runtime side: recording a rollout, reversing it window by window, and refining the stop time from the guard. Short a primitive instead? See when hawk can’t spell it.