Writing a Component¶
Before you start
What you write: a
track_gen.tools.Componentsubclass: a declaration (what it reads, its inputs, what it runs after) and five methods (attach,on_bind,on_generate,step,reset). A component is not registered: you pass the object to a course.What you need to know: what a course does each step (Courses, Composing a course), the result protocols you read (Capability protocols), and the rules for code recorded into a CUDA graph (Rules for recording into your graph).
How you test it:
track_gen.testing.check_componentruns the contract on this page in one call (Runtime components: check_component).
A component is a runtime consumer of a generator’s result: what the runtime
utilities (Components) are, packaged so that a composition root can
build it, wire it and drive it without knowing what it is. It declares what it reads,
allocates everything once when it is attached (cold), and runs three hot methods:
on_generate after each generation, step once per sim step and reset
on respawns. Each hot method takes a LaunchContext, launches
through it only, and allocates and synchronizes nothing, so it records into your CUDA
graph as-is.
track_gen.tools holds the base class (Component), what attach is given
(ComponentContext), the declaration of a bound input (InputSpec) and ten
adapters, one per built-in utility (The built-in adapters). A Course takes your
components next to its own (Using it in a Course, Composing a course) and
drives them; you can also attach and drive one yourself (Without a Course).
track_gen.testing.check_component checks a component against the contract on this
page (Checking it). The classes are listed in Courses and runtime utilities.
A component imports only public modules: track_gen.tools, track_gen.results,
track_gen.exec, the utility modules (track_gen.localize, …) and Warp.
Quick start: a curvature lookahead¶
A controller wants to know how sharp the road is a few points ahead of the car, and how
far that point is. CurvatureLookahead computes the centerline curvature once per
generation (localize.curvature into a buffer it owns) and, every step, reads it
ahead points past the segment a Localizer found for the agent:
# acme_tools/lookahead.py
import dataclasses
import types
import warp as wp
from track_gen.localize import curvature
from track_gen.results import HasCenterline, HasValidity
from track_gen.tools import Component, InputSpec
@wp.kernel
def _lookahead_k(kappa: wp.array(dtype=wp.float32), center: wp.array(dtype=wp.vec3f),
count: wp.array(dtype=wp.int32), S: int, ahead: int,
segment: wp.array(dtype=wp.int32), position: wp.array(dtype=wp.vec3f),
out_kappa: wp.array(dtype=wp.float32),
out_distance: wp.array(dtype=wp.float32)):
e = wp.tid()
m = count[e]
if m > S:
m = S
seg = segment[e]
if seg < 0 or m < 3: # no frame (invalid env, NaN position): NaN
out_kappa[e] = wp.nan
out_distance[e] = wp.nan
return
i = (seg + ahead) % m
out_kappa[e] = kappa[e * S + i]
out_distance[e] = wp.length(center[e * S + i] - position[e])
@dataclasses.dataclass
class Lookahead:
kappa: wp.array # [E] float32, 1 / track units
distance: wp.array # [E] float32, agent to that point
class CurvatureLookahead(Component):
"""Centerline curvature ``ahead`` points past the agent, per env."""
requires = (HasCenterline,) # center, count read (arclen, length come with it)
optional = (HasValidity,) # valid: read when the source has it
inputs = types.MappingProxyType({"position": InputSpec(wp.vec3f, doc="the agent")})
def __init__(self, ahead=6, *, window=2, localizer="localizer",
name="acme.lookahead"):
self.ahead, self.window, self.name = int(ahead), int(window), name
self.localizer = localizer
self.after = (localizer,) # reads that component's TrackFrame
def attach(self, source, cc):
lay = self.layout # the source's Layout, set by cc.add
E, S, dev = lay.num_envs, lay.stride, lay.device
self._source = source
self._center, self._count = source.center, source.count # read once, here
self._frame = cc.component(self.localizer).result # public: a TrackFrame
self._kappa = wp.zeros(E * S, dtype=wp.float32, device=dev)
self._scratch = wp.zeros(E * S, dtype=wp.float32, device=dev)
self.result = Lookahead(wp.zeros(E, dtype=wp.float32, device=dev),
wp.zeros(E, dtype=wp.float32, device=dev))
self._position = None
self.on_generate(cc.ctx, None) # the current batch's curvature (cold)
wp.load_module(__name__, device=dev) # step() can be recorded first
def on_bind(self, position):
self._position = position # checked by bind(); pinned from here on
def on_generate(self, ctx, mask):
curvature(self._source, self.window, ctx,
out=self._kappa, scratch=self._scratch)
def step(self, ctx):
lay, r = self.layout, self.result
ctx.launch(_lookahead_k, lay.num_envs,
[self._kappa, self._center, self._count, lay.stride,
self.ahead, self._frame.segment, self._position,
r.kappa, r.distance])
return r
It overrides attach, on_bind, on_generate and step. It keeps no per-env
state between steps (stateful stays False, so reset is the inherited no-op).
Its one input, position, is the same array the Localizer reads: a composition
root binds one array to every component that declares the name. on_generate
recomputes the whole batch, which leaves the rows of an unmasked env unchanged
after a masked regeneration, as the contract requires; it reads no bound input
(Contract in a Course). Its name is namespaced ("acme.lookahead"), as plugin names
are, so it never collides with a built-in component’s. TrackGen’s own suite runs this
class (tests/_component_toys.py) through every check of check_component.
Using it in a Course¶
Pass it to a course preset in extra (or to Course(generator, components)). The
course sorts the components by their after / source names (the order of
extra does not matter: the lookahead runs after the localizer it names), attaches
them, routes one bind to every declared input, refreshes them in its regeneration
body and returns each per-step result by name. With the class above (in your package,
from acme_tools.lookahead import CurvatureLookahead):
from track_gen import tools
from track_gen.course import Course
from track_gen.pipeline import presets
device = "cuda:0" # or "cpu"
course = Course.track(presets.track(num_envs=64, device=device),
checkpoint_spacing=0.6, collision="segments", seeds=0,
extra=[CurvatureLookahead(ahead=6), tools.Localizer()])
list(course.components) # ['checkpoints', 'progress', 'collision',
# 'localizer', 'acme.lookahead']
course.bind(position=pos, orientation=quat, half_extents=he) # your stable buffers
res = course.step()
list(res.keys()) # ['progress', 'collision', 'localizer', 'acme.lookahead']
kappa = res["acme.lookahead"].kappa.numpy() # [E]; NaN on an invalid env
res.frame is res["localizer"] # True: the localizer's result, on a track course too
bind sends pos to Progress, the Localizer and the lookahead (and, at one
box, to the collision’s box_position). Recorded into your graph, a step and a masked
regeneration look like this:
import warp as wp
done = course.inputs["regen_mask"] # your kernels write it
with wp.ScopedCapture(device=device) as cap:
sim_step(pos) # your physics
res = course.step() # every step, in order
look = res["acme.lookahead"] # look.kappa, look.distance
update_done(res, done) # your kernel
course.regenerate(mask=done, advance=True) # new courses + refresh + resets
step = cap.graph
The regeneration body runs on_generate for every component in execution order and
reset in reverse order, inside a CUDA conditional node where the capture
allows one: the localizer and progress of the regenerated envs are reset, and the
lookahead, which keeps no per-env state, inherits the no-op reset. See
Contract in a Course for what that asks of your hot methods, and
Composing a course for the course side (ordering, bind routing, StepResult
access, lifetime: Lifetime).
Without a Course¶
ComponentContext.for_producer reads the generator’s layout and its static capacities;
add checks each component’s declaration against what it reads, then attaches it:
import warp as wp
from track_gen import TrackGenerator
from track_gen.exec import LaunchContext
from track_gen.pipeline import presets
from track_gen.tools import ComponentContext, Localizer
E, device = 64, "cuda:0"
gen = TrackGenerator(presets.track(num_envs=E, device=device), seeds=0)
track = gen.generate()
cc = ComponentContext.for_producer(gen) # layout + capacities, from the plan
loc = cc.add(Localizer(), track) # name "localizer"
look = cc.add(CurvatureLookahead(ahead=6), track)
pos = wp.zeros(E, dtype=wp.vec3f, device=device) # your sim's stable buffer
for c in (loc, look):
c.bind(position=pos)
done = wp.zeros(E, dtype=wp.int32, device=device) # your per-env done mask
ctx = LaunchContext.for_call(device)
with wp.ScopedCapture(device=device) as cap:
sim_step(pos) # your physics
loc.step(ctx) # upstream first
res = look.step(ctx) # res.kappa, res.distance: [E] float32
update_done(res, done) # your kernel
gen.generate(advance=True, mask=done) # new courses where done[e] != 0
for c in (loc, look):
c.on_generate(ctx, done) # refresh, in attach order
loc.reset(ctx, done) # stateful components: reset the masked envs
step = cap.graph
The order is the composition root’s job: on_generate and step in attach order
(upstream first), reset for the stateful components after a regeneration and on
respawns (a Course resets in reverse order). A component never calls another one.
Checking it¶
check_component builds a producer, attaches the chain through
ComponentContext.for_producer (the upstream factories first, in order), and runs every
check of Runtime components: check_component on cpu, and on cuda:0 when Warp sees one:
from track_gen.testing import check_component
from track_gen.tools import Localizer
report = check_component(lambda cc: CurvatureLookahead(ahead=6),
upstream=[lambda cc: Localizer()])
report.raise_for_failures()
The factories take the ComponentContext, so a component that owns device arrays from
construction can build them for the kit’s device and num_envs. In pytest, the opt-in
plugin’s track_gen_check_component fixture runs the same call and fails the test
listing every failed check. Two checks enforce the Contract in a Course:
hot_path.buffers fails an on_generate or reset that references a bound input,
and on CUDA capture.caller records the chain as a Course records its masked
regeneration (any_set of the done mask, then skip_unless around the producer’s
masked enqueue, on_generate and reset), so an operation a conditional body
cannot hold fails there.
The base class¶
Component is a plain base class with immutable class-level defaults (not a
typing.Protocol: the library duck-types a component and never isinstance-checks
one). Set the declaration as class attributes, or as instance attributes in
__init__ when it depends on the arguments (as the adapters’ inputs do):
Attribute |
Default |
Meaning |
|---|---|---|
|
|
Unique within one |
|
|
The read target: |
|
|
Protocols of |
|
|
Protocols whose members are read when present: checked like required ones when
they are there (shape, dtype, device). When the read target has no layout and
nothing is required, the layout is the one its present members give
( |
|
|
|
|
|
|
|
empty mapping |
|
|
|
Names of other components it reads (they are attached first). |
|
|
Set by |
|
|
Set by |
The built-in adapters also expose the legacy utility they wrap as utility: an
adapter attribute, not part of the contract.
The lifecycle methods:
Method |
When |
Contract |
|---|---|---|
|
once, cold, outside any capture, through |
|
|
cold, before recording |
Inherited, never overridden: routes defaults ( |
|
from |
Your hook: keep the (checked) arrays; pointers are pinned from here on. Default: nothing. |
|
after every generation of the source; |
Hot. The rows and state of envs with |
|
every sim step |
Hot. Reads the bound inputs and returns |
|
after a regeneration and on respawns |
Hot. Clears per-env runtime state where |
Hot-path rules (the same as a stage’s enqueue, Hard rules): launch,
copy and fill through ctx only (ctx.launch, ctx.copy, ctx.fill, or a
library call given ctx=); never allocate, synchronize, read device data back, or
branch in Python on device data or on ctx.capturing; never change Python state that
changes a later emission. Stable results: result and every array reachable from
it are allocated in attach (or aliased from the read target) and never replaced.
Contract in a Course¶
A Course runs on_generate and reset inside its regeneration body, which it
records once into its own graph (keyed on advance and the mask array only) and, for a
masked regeneration, under a CUDA conditional node. That adds four rows to the contract
(check_component enforces the first two):
Rule |
Contract |
Enforced by |
|---|---|---|
|
Read and write no bound input ( |
|
|
They run inside a CUDA conditional-node body (the masked regeneration, in the
course’s own graph and in Warp-registered caller captures): kernels, |
|
|
Only its own state and the mask, never another component’s result or state: a course resets in reverse execution order, so a component is reset before the components it reads. |
documented (the built-in resets write constants under the mask) |
|
A component that overrides |
|
Read targets¶
ComponentContext.add(component, source) resolves the read target (source when
component.source is None, else cc.component(component.source).result), and checks
it with check_source(): every member of requires, plus the
members of optional that are present, with optional= naming the latter
(check_source). Error messages name the component:
TypeError: lookahead needs a HasCenterline source (center, arclen, length, count;
optional: valid): SimpleNamespace has no 'arclen'
attach then reads those members (source.center, …), once: what it reads is what
was checked. Declare every member you read. check_component’s
requires.complete attaches your component to a proxy that exposes only the declared
members and expects the same outputs; a read outside the declaration fails there.
Optional members and their fallbacks. The library’s components read two optional capabilities:
HasValidity(valid,[E]int32): absent, every env is valid.HasGeneration(generation,[E]int32): absent, the component never self-resets (Per-generation state and self-reset).
A kernel that reads an optional member takes it with an int flag (has_valid) and,
when it is absent, any [E] int32 array of the source as a placeholder (the library
passes count): no allocation for the missing member, the same launch either way.
Topology. A component that reads a result with closed loops declares
topology = "closed" (the default); one with no read target (requires = (), e.g.
DiscCollision on raw disc arrays) declares "any".
ComponentContext¶
ComponentContext(layout, capacity=None, *, ctx=None, bounds=None)
ComponentContext.for_producer(producer, *, ctx=None)
layout: the producer’sLayout.capacity: a read-only mapping of static capacities (below).bounds: a read-only mapping of the plan’s declared bounds (not capacities, so the kit never clamps them). One key in 0.x,size_hi: the largest course size of the plan, arandomizestage’s constructionhiongenerator.size, else the generator’s constructionsize.BandCollisionandHeightFieldBakeread it with their defaultsize_hi="auto".ctx: the coldLaunchContextofattach.NonemeansLaunchContext.for_call(layout.device): an enclosingctx.scope(), else Warp’s current stream.attachallocates and zero-fills on Warp’s current stream (wp.zeros/wp.full), so attach on that stream, after the producer’s work has completed: a context on another stream races its own allocations. ACoursewith a default stream does this for you.component(name): an attached component (KeyErrorlisting the attached ones).add(component, source): (1) every name inafterandsourceis attached andnameis free (ValueError); (2) an input name an attached component also declares has the same(dtype, slots)(ValueErrornaming both); (3) resolves and checks the read target (a producer result must hold the context’s batch: samenum_envsand device, elseValueError), setscomponent.layout; (4) callsattach; (5) registers the component and returns it.for_producer(producer): aTrackGenerator, aGateGenerator, aProgram, aCourse(its generator), or any producer (track_gen.pipeline.Producer) that declarescapacityand / orbounds. It always reads the producer’slayout, then its owncapacity/boundswhen it has them, else its plan (specandparams), never its device arrays. Only a realCourseis unwrapped to its generator and only a realProgram(a facade’sprogram) is a plan: a third-party producer’s attribute that merely shares one of those names is its own business. A producer with neither a plan nor those members raisesTypeError: buildComponentContext(producer.layout)yourself, as aCoursedoes. So does a result (gen.generate()’sTrack): for a hand-built source, constructComponentContext(layout, {...})yourself.
Capacities. One key in 0.x, max_loop_length: an upper bound, with measured
headroom, on the 3D length of the centerline and of either boundary loop of every valid
env. for_producer derives it from the plan, never from a batch:
max_loop_length = 1.5 * N_max * spacing + dZ
N_max * spacingbounds the plan-view centerline of every env that is not truncated (a longer one is truncated and fails validity). The1.5covers the relax growth and a constant-width boundary’s extra2 pi * half_width: measured, not proven (at most 0.90 ofN_max * spacingjust below truncation; 0.21 to 0.35 at size 1).dZbounds the elevation’s total variation from the elevation knobs’ declaredbounds()(never their current values, which are dynamic):0forelevation.flat;K * (z_max_hi - z_min_lo)foruniform/random_walk(K = z_control_points); fornoisethe smaller of2 * H * (z_max_hi - z_min_lo)and4 * A_hi * H / (1 + 1/2 + ... + 1/H)(H = z_noise_harmonics,A_hithe amplitude’s upper bound); with the built-invalidity.default’sz_valid_grade = g > 0,g * 1.5 * N_max * spacing(a plugin check’s field of that name does not count). The smallest available term is used.The key is omitted when no
dZterm is finite (every elevatedTrackGenConfigtoday:z_min/z_maxdeclare no bounds, and a randomizer’s range does not give them one: itslo/hiare dynamic), when the generator provides its altitudes (providesz: no elevation stage,lift.elevated) and no grade is declared, when nowidth.constantfollows the resample (another width stage, or a generator that provideshalf_width: unbounded per point), when a pluginrelax/width/offset/elevation/lift/resamplestage follows the constant-spacing resample, and without that resample (gate pipelines,external).
At the TrackGenConfig defaults (N_max = 384, spacing = 0.06, flat) it is
34.56. Treat it as a sizing bound, not a guarantee: clamp each env to your capacity and
report what you clipped (the built-in samplers’ truncated). A component that needs the
key and does not find it raises ValueError naming its explicit capacity argument
(tools.Checkpoints(spacing, max_checkpoints=...)).
Inputs¶
InputSpec(dtype, slots=1, required=True, default=None, doc="")
An input is a caller array the component reads in place every step: [E * slots]
elements of dtype (wp.vec3f, wp.quatf, wp.vec2f, wp.float32, …) on
the layout’s device. default names another input used when this one is not given:
the collision adapters declare box_position with default="position" when they
have one box per env, so bind(position=p, ...) feeds both progress and collision (a
routed array’s error names both: input box_position (routed from position) must have
shape ...). required=False makes an input optional: bind without it calls
on_bind with None for it, so the hook can take it as a plain parameter.
doc is not part of the equality. The built-in names are position ([E]
vec3f, the agent) and box_position / orientation / half_extents ([E *
max_boxes] vec3f / quatf / vec2f); reuse them with the same meaning, since a
composition root routes one array to every component that declares a name and refuses two
declarations of one name with different (dtype, slots).
A component keeps its input arrays in on_bind (CurvatureLookahead.on_bind above).
A graph recorded with step keeps reading the arrays bound at the time: rebinding
other arrays afterwards needs a new recording (Rebinding after a capture).
Per-generation state and self-reset¶
Two kinds of state:
Per-generation data (a resampled ring, a baked grid, a curvature profile) is a function of the current course.
on_generaterecomputes it; nothing else needs to.Per-env runtime state (the previous position, the next checkpoint, a warm-start segment) depends on the agent’s history. It is wrong as soon as the env’s course changes. Declare
stateful = Trueand clear it inreset(ctx, mask).
A stateful component can also clear it on its own, on device: read the source’s
generation ([E] int32, an optional HasGeneration member), keep a
seen_generation [E] int32 of your own (a copy of generation made at the end of
attach), and at the top of the step kernel:
if has_gen != 0:
gen = generation[e]
if seen[e] != gen: # the env got a new course since its state was built
seen[e] = gen
# ... write exactly what reset() writes for env e ...
Writing exactly what reset writes, before the state is read, makes it idempotent: a
self-reset and an explicit reset give the same bytes, alone or together. reset does
not touch seen. Progress and Localizer do this (After regenerating).
generation is the env’s epoch, not a counter of new courses: an advancing
regeneration changes it for the envs it advances (masked or all); a regeneration without
advance does not; a reseed changes it only when it zeroes a non-zero epoch
(Course.generate(seeds=), SeedState.set), not on a bare rng.set_seeds_warp; and
reset_epoch() followed by the same number of advances between two steps leaves it
equal. So a caller that reseeds or rolls epochs back must still call reset(mask). A
read target without generation (a hand-built source) never self-resets.
If your per-generation data comes from another component (a ring sampled by
Checkpoints), read the generation that component writes with its data, not the
course’s: a step between the regeneration and the resample must not reset against the
old data. Checkpoints owns the CheckpointSet’s valid / generation for that
reason.
Invalid envs¶
Rule: for an invalid env (valid[e] == 0), every array the component owns
holds exactly what it holds for the same env poisoned (count = 0, NaN geometry,
length = 0, winding = 0): an invalid env reads like a poisoned one, whatever the
generator’s on_invalid (Invalid envs). The library’s table is in
Invalid envs: NaN geometry,
count = 0, no hit, oob = 1, frozen progress. Read valid as an optional member
and take your degenerate branch when it is 0, the same branch a poisoned env reaches
(count < 3, a NaN input). CurvatureLookahead gets it for free: curvature writes
NaN rows and the Localizer writes segment = -1 for an invalid env, so its kernel
takes the NaN branch.
A component whose result only aliases its read target (GateCheckpoints, a
zero-copy view of the gates) owns nothing: its consumers apply the rule.
check_component’s invalid.sentinel fails half the envs of a batch through the
commit’s own kernel twice, once keeping their geometry (valid = 0) and once poisoned,
and compares every owned array of your result over a trajectory.
Rebinding after a capture¶
A recorded graph keeps the pointers it was recorded with: rebinding new arrays afterwards
leaves it reading the old ones. The library’s utilities with bound inputs, their
adapters and Course.bind therefore note, in their hot methods, when they ran under a
capture (a capturing stream, or a CPU capture on the device), and emit
track_gen.exec.RebindAfterCaptureWarning (a UserWarning) at the caller’s line when
a bound array is replaced afterwards. Binding the same arrays again is silent. The
documented recipe “rebind, then record again” warns once too; filter the warning
(warnings.simplefilter("ignore", RebindAfterCaptureWarning)) when you re-record on
purpose. The probe is a host flag set once and changes no launch: the one exception to
“never change Python state in a hot method”.
The built-in adapters¶
Each adapter wraps one legacy utility (adapter.utility), which keeps every legacy name
and method; adapter.result is that utility’s .result.
Adapter ( |
Wraps |
Reads: |
Inputs, state |
|---|---|---|---|
|
|
course: |
|
|
|
posts mode ( |
as |
|
|
course: |
|
|
|
course: |
none; stateless; |
|
|
course: |
none; stateless; |
|
|
course: |
none; stateless |
|
|
the |
|
|
|
course or |
|
|
|
course: |
none; stateless; |
|
|
course: |
none; stateless; |
Every adapter also takes name=. The keywords follow one rule: source= names the
read target (Progress, Localizer), and a dependency read through after is
named by its role (FrameCollision(progress=...)). Props and Checkpoints size
their ring from cc.capacity["max_loop_length"] when their capacity is None
(max(3, ceil(max_loop_length / spacing))) and raise ValueError without the key.
Checkpoints(max_checkpoints="auto") (what the Course presets and
Course(CourseConfig) use) takes the same bound and, without the key, reads the batch
it attaches to back once (the CourseConfig readback of elevated configs); its
defaulted attribute says how a capacity it chose itself was chosen (None for an
explicit one), and a Course warns once when such a capacity clips a valid env of the
first batch. BandCollision(method="sdf") and HeightFieldBake take the largest
course size they will see (size_hi) and warn at attach when the grid is too
coarse for it (Grid resolution and course size); the default size_hi="auto" reads
cc.bounds["size_hi"] (no warning without the key, or at size_hi <= 1), and
size_hi=None turns the check off, as Course(CourseConfig) does.
TrackGen’s own suite runs check_component over every adapter (on CPU, and on
cuda:0 in its GPU lane).
The Producer protocol¶
track_gen.pipeline.Producer documents what a course reads from its generator. It is a
typing.Protocol for documentation and type checkers; the course duck-types it and
never isinstance-checks it (a facade’s program, when it is a Program, is the
producer, so a facade reduces to its Program; an unrelated program / rng
attribute of a third-party producer is ignored). A missing required member is a
TypeError listing it.
Member |
Required |
Used for |
|---|---|---|
|
yes |
The read target of every component with |
|
yes |
|
|
yes |
The generation inside the regeneration body. With |
|
no |
|
|
no ( |
|
|
no (prepared) |
An unprepared producer is prepared first. Without them, the producer must have
loaded every module |
|
no ( |
The course’s default stream when |
|
no |
Read by |
|
no |
The generator’s own inputs (Binding inputs). |
Program satisfies it (its read-only prepared and default_stream are the
runner’s), and the facades’ read-only rng gives course.rng, so a course reads no
private member of a producer. A mixed batch (two generators writing views of one result)
can be a producer too (Mixed batches).
Mixed batches¶
Several generators can write env slices of one result: each is built with out= a
view of one Track (Mixed batches). Wrapped in an object that
satisfies the Producer protocol (result: the whole Track, layout, and an
enqueue that runs every part), they are driven by an unchanged Course. TrackGen
ships no such wrapper: a complete one also needs a whole-batch SeedState, merged
fail_bits, per-part capacities and namespaced inputs, which yours provides as far as
your components need them.