Composing a course¶
A course (Course) is a generator plus an ordered list
of components (track_gen.tools, or your own). The course owns
what ties them together: the order they run in, one bind for every input, the
regeneration recorded as ONE graph, the per-env resets, and the lifetime of every
buffer a recorded graph reads. Courses covers using a course; this page covers
building your own: the presets’ arguments, the components and their order, input
binding, results, and the regeneration body.
Building a course¶
from track_gen import TrackGenConfig, TrackGenerator, tools
from track_gen.course import Course, CourseConfig
from track_gen.pipeline import presets
E, device = 64, "cuda:0" # or "cpu"
# 1. A preset, over a config, a PipelineSpec, or a built generator
course = Course.track(presets.track(num_envs=E, device=device),
checkpoint_spacing=0.6, collision="segments", seeds=42)
course = Course.gates(presets.gates(num_envs=E, device=device, gate_width=0.1),
collision=0.02, seeds=42) # a post radius
# 2. Any generator and any components
gen = TrackGenerator(presets.track(num_envs=E, device=device), seeds=42)
course = Course(gen, [tools.Checkpoints(0.6, max_checkpoints="auto"),
tools.Progress(),
tools.BandCollision("segments")])
# 3. A flat CourseConfig (Course.from_config(config) and Course(config=...) are the same)
course = Course(CourseConfig(mode="track", gen=TrackGenConfig(num_envs=E, device=device),
seeds=42, collision="segments", checkpoint_spacing=0.6))
Course(generator, components=(), *, stream=None, prepare=True) takes a built
generator: a TrackGenerator, a GateGenerator, a Program (pipeline.build(...),
including one built with prepare=False, which the course prepares first), or
an object of your own with the same surface. A config or a
spec there is a TypeError naming the preset to use. stream= is the course’s
default stream (the generator’s own default stream when not given); prepare= is
Preparing.
Every way runs Course.__init__ and ends in the same setup, so a subclass’s
__init__ runs for MyCourse.track(...) too: it must accept (generator,
components, *, stream=None, prepare=True).
The presets¶
Course.track(source, *, checkpoint_spacing, collision=None, max_checkpoints=None,
extra=(), seeds=None, stream=None, prepare=True)
Course.gates(source, *, collision=None, samples_per_gate=8, localize_window=None,
extra=(), seeds=None, stream=None, prepare=True)
The presets build the components of a track or gate course, then
append extra:
Preset |
Components, in order ( |
|---|---|
|
|
|
|
Sources. A
TrackGenConfig/GateGenConfigor a PipelineSpec is built by the preset, withseeds=(anythingTrackGenerator(seeds=)takes: anint, an[E]int32 array bound by pointer, aPerEnvSeededRNG, aSeedState) andstream=. A builtTrackGenerator/GateGenerator/Programis used as is (pipeline.build(spec, registry=...),out=views, generators you built yourself); passingseeds=orstream=with one is aTypeError(they configure the generator the preset builds), and a generator of the other family is aTypeErrornaming the other preset.The collision slot.
track:None(the default),"segments","sdf", or a component.gates:None, a post radius, or a component. A component in this slot must be named"collision": it fillsStepResult.contactsandcourse.collision. A collision under another name goes inextra(ValueErrorotherwise). Collision backends compares the backends.Checks before anything is built.
checkpoint_spacinga number> 0, thecollisionvalue (aboolor another string is refused), a post radius> 0,max_checkpointsan int>= 3("auto"isNone),samples_per_gate >= 2,localize_window >= 1,extraa sequence (a single component is aTypeError, as forCourse(generator, components)), the source’s family, andextrasorted together with the preset’s own names (duplicate names, an unknownafter/source, a cycle, an attached component) raise before any generator is built.gatesalso reads the constructiongate_frames.gate_widthfrom the config, the spec or the built generator and raisesValueErrorwhen it is0(a width-0 gate can never be passed); when a randomizer draws the width (with_randomize({"gate_frames.gate_width": (lo, hi)})), its constructionlomust be> 0instead and the construction width is not used. After building, it refuses a scalarsetof a width<= 0, on a built generator too (a randomizer’s livelois not checked: keep it> 0).presets.gatesdefaultsgate_width=0: pass one, or randomize it.Checkpoint capacity (
track).max_checkpoints, elseCheckpoints(..., max_checkpoints="auto"): the longest track the generator can produce,max(3, ceil(max_loop_length / checkpoint_spacing)), else (an elevated config, which declares no bound) the readback of the construction batch (Checkpoint capacity). When a capacity the course chose clips a valid env of the first batch, construction warns once (...; pass a larger max_checkpoints).Resolution checks. A preset’s
BandCollision("sdf")andHeightFieldBakeread the largest course size the generator can draw (a size randomizer’s constructionhi, else the construction size) and warn at construction when their grid does not cover it (Grid resolution and course size). A curriculum that widens the range later is not checked again: pass its finalhiassize_hi=.Seeds and the rng.
course.rngis thePerEnvSeededRNGyou passed asseeds=(the seeds), elseNone;course.seedsis the generator’sSeedStateeither way (course.seeds.seedsholds the per-env seeds).generate(seeds=...)follows the rule of whoever owns the seeds: through the rng when there is one (it writes the rng’s array),SeedState.seton seeds the generator owns, and aRuntimeErrorfor seeds bound to your array with no rng (the library never writes a bound seed array: write it, thenregenerate()). Withseeds=PerEnvSeededRNG(...),Course.gates(cfg, collision=r, seeds=rng)is byte-identical toCourse(CourseConfig(mode="gates", gen=cfg, seeds=s, post_radius=r)).
Components and their order¶
The components run in a stable topological order of their after and source
names over the order you give (an already-ordered list is kept as is):
Phase |
Order |
|---|---|
|
sorted |
|
sorted: each component refreshes after what it reads |
|
sorted |
|
reversed: a component is reset before the components it reads |
course.components is a read-only name -> component mapping in execution order.
Construction refuses (before attaching anything): a name that is not a non-empty str
(TypeError); two components with one name (ValueError: names are unique within a
course); an after / source naming no component of the course; a cycle; a component
already attached to another course or context (ValueError: build a new instance); a
component lacking any of attach, bind, on_generate, step, reset
(TypeError: subclass tools.Component for the defaults). Then each component goes
through ComponentContext.add (its declared reads, requires / optional,
topology, input conflicts, then attach; Writing a Component).
Name a third-party component with a namespaced name ("acme.lookahead"), as plugins
name their stages, so it never collides with a built-in one. The built-in
names back the course’s attributes: checkpoints, progress, collision,
localizer, centerline, heightfield. On any course: course.progress,
course.collision, course.heightfield and course.localizer are the
utility of the component of that name; course.checkpoint_sampler the
utility of checkpoints (course.checkpoints is that component’s
result); course.course_line the utility of centerline; each is None
when the component is absent.
Binding inputs¶
course.bind(**arrays) binds every component input at once, by name:
course = Course.track(presets.track(num_envs=E, device=device), checkpoint_spacing=0.6,
extra=[tools.Localizer()], seeds=42) # no collision
course.bind(position=pos) # Progress and Localizer both read it
boxed = Course.track(presets.track(num_envs=E, device=device), checkpoint_spacing=0.6,
collision="segments", seeds=42)
boxed.bind(position=pos, orientation=quat, half_extents=he) # + a box collision
One array, every declarer. An array goes to every component that declares its name (
Progress,Localizerand a component of yours can all takeposition). AnInputSpec.defaultroutes an input that is not given from another one (box_positionfrompositionat one box); a name given explicitly wins over a default route. Two declarations of one name with different(dtype, slots)are refused at construction.Validated, then bound, atomically. Every route is checked (unknown and missing names, shape, dtype, device) before anything is rebound; if a component’s
on_bindraises part-way, the components already rebound by the call, and the one that raised, get their previous arrays back, their rebind-after-capture probes are restored, and the error propagates. A refused bind leaves every previous binding in place, and a later rebind after a capture still warns.Names.
position,orientation,half_extentsandbox_position(also accepted positionally, in that order) are accepted on every course and ignored when no component declares them. Any other unknown name is aTypeErrorlisting the component inputs.Generator inputs are not bound by the course. A name of
course.generator.input_info("centerline.xy", a stage input) raisesTypeErrorwith the recipe: writecourse.generator.inputs[name]in place (a stable array that every regeneration and every replay reads), e.g. awp.copyinto it on the regeneration’s stream.step()raisesRuntimeError("call bind() before step()")while a component with a required input is unbound.
Calling bind again replaces every binding. The course’s own graphs never read a bound
input (The component contract in a course), so a
rebind never replays a stale course graph; a graph of yours that recorded step()
keeps the old arrays: record it again (the utilities emit RebindAfterCaptureWarning
once, Rebinding after a capture).
Reading results¶
course.step() returns the same StepResult instance every
call. Besides its four fields it gives read-only mapping access to the result of every
per-step component, by name:
res = course.step()
res["progress"] # ProgressEvents (the same object as res.events)
res["localizer"].s # the Localizer's TrackFrame
"collision" in res, list(res.keys()), dict(res)
res.get("collision") # None: this course has no collision
res.valid # the generator result's [E] int32 valid
course.components["checkpoints"].result # a per-generation result: not an entry
Entries are the components that override
Component.step(theirresult): progress, collisions, the localizer, your step components (res["acme.lookahead"]). Per-generation components (checkpoints, the course line, bakes, props) keep the basestepand are read throughcourse.components[name].result(or the course attributes).res[name]on another name raisesKeyErrorlisting the entries.The four fields are the entries by name:
eventsisres["progress"],contactsres["collision"],frameres["localizer"](Nonewhen absent), and valid iscourse.result.valid(generator state: never write it; it is live, so read it before a regeneration). On aCourseConfigcourse the entries are exactly the sources of these fields.Not a
collections.abc.Mapping:bool(res)isTrue,iter(res)raisesTypeError(usekeys()/items()), and it stays a dataclass (dataclasses.fields,==, positional construction, its generated repr, whichpprintlays out), so generic tree utilities (Lightning’sapply_to_collection,dm-tree) treat it as a dataclass.dataclasses.replace(res, ...)keeps the entries of the four fields only. Assigning a field (res.events = x, as those utilities do on their copy) updates its entry on that instance only (Noneremoves it). Visible to duck typing:hasattr(res, "__iter__")(it isNone),isinstance(res, collections.abc.Container)(__contains__), and**res/dict.update(res)(keys()+__getitem__) succeed.res.clone()deep-copies every entry (awp.array, an object withclone(), a dataclass’swp.arrayfields) andvalid; an entry it cannot copy raisesTypeErrornaming the component. It allocates: call it outside your graph.
Regeneration: one body, one graph¶
regenerate(), enqueue_generate() and generate() run ONE body:
regenerate() generator.enqueue(ctx, advance=a)
regenerate(advance=True) -> on_generate(ctx, None) for each component (sorted)
-> reset(ctx, all envs) for each component (reversed)
regenerate(mask=m, advance=a) any_set(m) -> flag
skip_unless(flag):
generator.enqueue(ctx, advance=a, mask=m)
(advance(m) -> stages -> masked commit -> generation)
-> on_generate(ctx, m) for each component (sorted)
-> reset(ctx, m) for each component (reversed)
With an all-zero mask the skippable part is an exact identity: the masked commit
restores every env’s rows, on_generate(ctx, m) leaves every unmasked env’s rows and
state unchanged (the component contract), and reset(ctx, m) writes only masked envs.
So recording it unconditionally is correct; skipping it on device only saves the cost.
What a masked regeneration costs lists where the skip applies (a CUDA conditional node) and where
the body runs in full.
Outside a capture on cuda, regenerate records each variant once into a CUDA graph
of the course’s own and replays it:
Call |
The course’s own graph |
|---|---|
|
|
|
one more |
|
one per |
Besides course.graph, at most 16 variants get a graph of their own; calls with a
further new variant run eagerly, with one RuntimeWarning. enqueue_generate never builds one. A generator with a step that
runs on the host (a plugin registered with capturable=False) runs the body eagerly
and raises NotCapturableError under a capture.
Recorded into your own graph next to step(), the masked regeneration is a per-env
auto-reset: RL auto-reset has the recipe (The recipe inside your graph) and
CUDA graphs in a sim loop the recording patterns.
Preparing¶
Construction (prepare=True, the default) runs the regeneration body once as a
warm-up, so every module is loaded before you record anything (a capture). prepare=False skips
it: the first regeneration call outside a capture prepares, one inside a capture raises
CaptureError (a CPU capture included), and course.prepare() prepares explicitly.
prepare() is a no-op on a prepared course; on an unprepared one it runs the masked
body on an all-zero private mask, the exact identity above, so it changes no env’s course
or state even after steps.
Ownership and lifetime¶
One course per generator. Building a Course over a generator that a live course
drives (the same object) takes it over once its construction completes: the older
course raises RuntimeError at its next step, reset, regenerate,
enqueue_generate or generate (its components would read a source the new course
regenerates); a construction that raises (a warning turned into an error included)
takes nothing over. Rebuilding (course = Course(gen, ...) run twice) works. Calling
course.generator.generate() yourself regenerates the source without refreshing the
components: regenerate through the course.
Lifetime. The course holds the generator, every component and the StepResult;
each component holds its buffers and its bound arrays. A graph that records a course call
keeps the course and the arrays that call reads alive in most captures, but not under a
plain torch.cuda.graph: Lifetime has the rules.
Your own generator, a curriculum¶
A course drives any object with the generator surface (
result,layout,enqueueand a few more): The Producer protocol lists it, and Using it in a Course builds a component end to end.A curriculum changes the generator’s dynamic fields between regenerations (
course.generator.params[...]): A course curriculum grows the course size of a course, with the checkpoint ring and the grids sized for it.
Troubleshooting¶
cudaErrorStreamCaptureUnjoined(the capture is invalidated) atregenerate(mask=...): a side stream forked in your capture is not joined yet. Join it first, or record the call insidetrack_gen.exec.unconditional().A graph that holds the masked regeneration cannot be added as a CUDA child graph (it holds a conditional node): record it inside
exec.unconditional().CaptureErrornaming the components atregenerate(mask=...): one of them uses an operation a conditional body cannot hold (a copy to or from pageable host memory, an event, another stream) inon_generateorreset;check_component(capture.caller, Runtime components: check_component) finds which.exec.unconditional()records the body without the node.CaptureError“built with stream=”: the course has a default stream and is called while another stream records; passstream=.CaptureError“built with prepare=False”: callcourse.prepare()before recording.RuntimeError“driven by another Course”: a newer course took the generator over; use it.TypeError“is an input of the generator”: writecourse.generator.inputs[name]in place.Torch ops failing to capture after the masked regeneration under
registered(conditional=True): record the masked regeneration after every allocating torch op.
Troubleshooting capture covers the errors of recording in general.
API: track_gen.course.Course, track_gen.course.StepResult,
track_gen.tools (Courses and runtime utilities).