Choosing a dispatch policy#
Pick how a per-sample branch between several formulas is compiled, let the runtime pick it for you, and give the reverse pass its own choice.
Time: ~6 min · Runs on: CPU · You need: hawk and NumPy; read first: Gradients through branches and stops
if / where choose between two values. When a sample picks one of
K formulas by an integer class — a regime, a material, a contact
state — hawk.math.dispatch(kind, [branch_0, ..., branch_K-1]) says so
directly. What it computes never changes; how the branch is compiled
is a policy you can set.
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 as M
from hawk import Index, Mutable, Param, Scalar
@hawk.kernel
def drag(dt: Param, regime: Index, v: Scalar, v_new: Mutable[Scalar]):
a = M.dispatch(regime, [-0.1 * v, # laminar
-0.4 * v * abs(v), # turbulent
-2.0 * v * M.exp(-v * v)], # a costly third law
policy="auto")
v_new = v + dt * a
drag
<Kernel drag slots=5>
The four policies#
policy |
how it compiles |
when it wins |
|---|---|---|
|
one kernel, a |
branches of similar cost |
|
one kernel, every branch evaluated, one kept |
tiny branches, no divergence |
|
one kernel per branch, run over the samples of that class |
a rare, expensive branch |
|
compiled as |
let the runtime pick, from a cost model and the observed branch mix |
A policy you declare is never changed by anything else: "auto" is the
only value a runtime may resolve, so writing "switch" pins it.
Is the class fixed across steps?#
Splitting a branch into one kernel per class pays for itself when the samples can be sorted by class once and stay sorted. That holds when the class is a static selector: it reads nothing the step writes. hawk records this for every dispatch in the built kernel’s sidecar, together with the planes the selector reads — a runtime intersects those with everything its whole step writes before trusting it:
work_dir = pathlib.Path(tempfile.mkdtemp())
bundle = hawk.build([drag], work_dir, targets=("host",))
(entry,) = bundle.artifacts[0].sidecar["dispatch"]
entry
{'name': 'dispatch0',
'K': 3,
'policy': 'auto',
'static_selector': True,
'selector_reads': [['per_sample', 'regime']],
'mix': 'dispatch0_mix'}
regime is an input, so the class is static. A selector computed from
the state the kernel updates is not:
@hawk.kernel
def drag_by_speed(dt: Param, cut: Param, slow: Index, fast: Index,
v: Mutable[Scalar], regime: Mutable[Index]):
v = v + dt * M.dispatch(regime, [-0.1 * v, -0.4 * v * abs(v)], policy="auto")
regime = M.select(abs(v) > cut, fast, slow) # the step rewrites the class
bundle2 = hawk.build([drag_by_speed], pathlib.Path(tempfile.mkdtemp()), targets=("host",))
(entry2,) = bundle2.artifacts[0].sidecar["dispatch"]
entry2["static_selector"], entry2["selector_reads"]
(False, [['mutable', 'regime']])
A uniform the selector reads (cut above, were it in the selector)
is listed too, and still counts as static: the host changes it only
between launches, and a runtime that rebinds it re-sorts first.
Counting the branch mix#
To choose between switch and segmented a runtime needs to know how
the samples split between the branches. An "auto" dispatch counts it as
it runs: one integer counter per branch in the plane the sidecar names
(entry["mix"], here dispatch0_mix) — K uint32 cells at the start of
the plane, bumped once per live sample (on a GPU, once per warp per
branch). The counts are exact integers, the same however the work was
scheduled. A dispatch with a declared policy counts nothing and compiles
to the same code as before. Inside a kernel that takes several steps per
launch the count is per step taken, so the mix weighs every step alike.
Left unbound, the counts go to a scratch plane; bind your own to read
them:
n = 9
regime = np.array([0, 0, 1, 2, 2, 2, 1, 0, 5], dtype=np.int64) # 5 clamps to 2
mix = np.zeros(3, dtype=np.uint32) # one counter per branch
hawk.run(hawk.load(work_dir, "drag"), dt=0.1, regime=regime, v=np.ones(n),
v_new=np.zeros(n), dispatch0_mix=mix)
print("branch counts:", mix, " bincount:",
np.bincount(np.clip(regime, 0, 2), minlength=3))
branch counts: [3 2 4] bincount: [3 2 4]
hawk.ir.dispatch_costs(walk) is the cost view a runtime reads to make that choice. It takes the host profile (host_profile=, default the one in effect) and reports, per dispatch group, whether the fused host loop is fused_vectorised: true for a sample-local kernel whose branches call no library math, or whose host profile vectorises it (native-vector-math). Under an exact profile (native, portable) a call like exp stays scalar, so the fused loop is not counted as running every branch in lockstep.
Resolving "auto"#
hawk.resolve_dispatch(kernel, {group: policy}) is how the choice is
applied: the same kernel, with that group lowered as the policy named.
Only an "auto" group can be resolved; one you declared is refused, so
your choice always stands. A kernel that steps several times per launch
keeps doing so under switch or predicated; resolved to segmented it
becomes its single step, split per branch, one step per launch.
drag_split = hawk.resolve_dispatch(drag, {"dispatch0": "segmented"})
split_bundle = hawk.build([drag_split], pathlib.Path(tempfile.mkdtemp()), targets=("host",))
print([a.name for a in split_bundle.artifacts])
['drag__k0', 'drag__k1', 'drag__k2', 'drag__cls']
Each unit runs over the samples of its own class, which the runtime
keeps sorted. If the class of a sample changes after that sort — a
selector the step writes — the unit does not apply the wrong formula:
it re-reads the selector, keeps the sample’s old value, marks it in
dispatch0_stale and writes its new class to dispatch0_class; the
runtime finishes the stale samples with the switch form and re-sorts.
The reverse pass takes its own structure#
vjp derives a dispatch per dispatch: by default each derived dispatch
inherits the forward’s policy. The reverse pass does different arithmetic,
so the best structure can differ; policy={group: ...} sets it, naming
the group the way the sidecar does (dispatch0, dispatch1, … in
order) — for example vjp(drag, wrt=("v",), policy={"dispatch0": "segmented"}). Here the inherited policy, checked against a finite
difference:
from hawk import Kernel
from hawk.diff import vjp
grad_switch = Kernel("drag_vjp", vjp(drag, wrt=("v",)))
bundle3 = hawk.build([drag, grad_switch], pathlib.Path(tempfile.mkdtemp()), targets=("host",))
n = 6
regime = np.array([0, 1, 2, 0, 1, 2], dtype=np.int64)
v = np.linspace(-1.0, 1.0, n)
bar_v = np.zeros(n)
hawk.run(hawk.load(bundle3.directory, "drag_vjp"), dt=0.1, regime=regime, v=v,
bar_v_new=np.ones(n), bar_v=bar_v)
h = 1e-6
def step(v):
out = np.zeros(n)
hawk.run(hawk.load(bundle3.directory, "drag"), dt=0.1, regime=regime, v=v, v_new=out)
return out
print("vjp :", np.round(bar_v, 8))
print("finite diff :", np.round((step(v + h) - step(v - h)) / (2 * h), 8))
vjp : [0.99 0.952 0.82321474 0.99 0.952 1.07357589]
finite diff : [0.99 0.952 0.82321474 0.99 0.952 1.07357589]
The value of the gradient does not depend on the policy — only how it is compiled. An unknown group or policy is refused by name:
try:
vjp(drag, wrt=("v",), policy={"dispatch3": "switch"})
except hawk.HawkError as err:
print(err)
vjp(policy=...): 'dispatch3' is not a dispatch group of drag; its groups are ['dispatch0']
What just happened#
dispatchpicks one of K formulas per sample; the policy only changes how it is compiled."auto"hands the choice to the launching runtime and counts the branch mix it needs;hawk.resolve_dispatchapplies the choice. Every other policy is yours and stays yours.The sidecar records whether each selector is static and which planes it reads, so a runtime can decide whether sorting by class pays.
vjp(..., policy={group: ...})lets the reverse pass pick its own structure; the gradient is the same either way.
Try this#
Resolve drag to "predicated" instead, build it, and compare the
source with the switch form: every branch is evaluated and one kept.
Next#
Writing a vocabulary — declare a reusable kernel shape once, so a whole family of models shares one vocabulary.