Adding a New Algorithm#
This guide covers adding a new rigid-body dynamics algorithm to GRiD, end
to end. It walks through the canonical pattern using fdsva_so
(second-order forward dynamics) as the worked example. Pair it with the
conceptual docs:
Algorithm design principles: why the codegen looks the way it does (smart inners, thin wrappers, inner-owns-placement, the spill ladder).
Codegen architecture: the four emission layers (
_inner/_device/_kernel/ host) and their composition contract.Resource-tier system: per-tier dispatch and selective spill.
An algorithm that follows the recipe below plugs into the existing tier-spill / equivalence / bench infrastructure with no special-casing.
Generator functions#
Every algorithm X ships as one Python file at
grid_codegen/algorithms/_X.py exposing a set of gen_*
emitter functions. The functions are:
Generator function |
Emits / returns |
|---|---|
|
Python int: number of |
|
A string-emitting helper that callers (other algorithms,
composing kernels) use to invoke |
|
Emits the placement-free C++ |
|
Emits the canonical |
|
Emits |
|
Emits the host launcher |
|
Runs all of the above generators in the right order. The
top-level driver |
Each algorithm also adds one AlgoDescriptor row to algo_registry.py.
The descriptor table holds the per-algo metadata, and that one row drives
the GridAlgo enum, the launch-config symbol map, and the
KERNEL_ATTR_MANIFEST / mjx manifest heads. If the algorithm gets a
Python binding, also add one AbiSpec row to grid_codegen/abi_specs.py
and regenerate the wrapper’s generated regions (.venv/bin/python -m
grid_codegen.wrapper_body_gen): the C-ABI bodies, the kernel_max_threads
branch table, and the mjx twins are all emitted from that table between
BEGIN/END GENERATED markers in wrapper_template.cu. Do not hand-edit
inside them; the --check drift gate in
test/test_wrapper_generated_block.py fails CI on such edits. The arena/tier
math lives in grid_codegen/_constants_arena.py, not in
GRiDCodeGenerator.py. See Codegen architecture for
both tables.
Step-by-step recipe (worked example: fdsva_so)#
Pick the canonical references.
The Python reference for the algorithm should already live in our
RBDReferencesubmodule (or get added there first; see theRBDReferenceREADME). Forfdsva_sothat isRBDReference.fdsva_so(...)returning(d2tau_dqdq, d2tau_dvdv, d2tau_dvdq, dM_dq). The CUDA equivalence test compares the generated GPU output against this reference numerically.Lay out the inner’s scratch budget.
Decide which intermediate values are needed and how big each is. For
fdsva_so, the rank-3 contraction sub-step needs a4·nv³working buffer. Write the size-getter:def gen_fdsva_so_contract_temp_mem_size(self): return 4 * self.robot.get_num_vel() ** 3
The orchestrator (
fdsva_so_device) also embedsminvandforward_dynamicsand the IDSVA-SO sub-algorithms. Sum their smem footprints (or take themaxfor arenas that get reused across phases) to size the outer scratch arena.Write the inner.
gen_fdsva_so_contract(the rank-3 contraction sub-step) emits a block-cooperative loop over thenv²output positions, usingglass::gemv/dot_prodvia thegrid_linalg_*wrappers and the codegen’s parallel-loop helper:def gen_fdsva_so_contract(self): n = self.robot.get_num_vel() # ... function signature emit ... self.gen_add_code_line( "template <typename T, bool SCRATCH_IN_SMEM = true>" ) self.gen_add_code_line("__device__") self.gen_add_code_line(func_def, True) # Inner-owns-placement: the FIRST line of the body. self.gen_add_code_line( "if constexpr (!SCRATCH_IN_SMEM) { s_temp = d_workspace; }" " else { (void)d_workspace; }" ) # ... emit the actual loop using gen_add_parallel_loop / # grid_linalg_gemv / dot_prod / etc. self.gen_add_end_function()
The conventions:
Inputs that already live in shared memory keep the
s_prefix.Globals that get spilled use the
d_prefix.Templated on
SCRATCH_IN_SMEM = trueby default so small robots’ tiers are byte-identical; the first body line is theif constexprrepoint.Use the existing block-cooperative GLASS primitives (
glass::gemv,glass::gemm,glass::invertMatrix_dense,glass::cholDecomp_InPlace,glass::trsm) rather than hand-written loops. The codegen helpers (grid_linalg_gemmetc.) wrap them with a consistent signature.
Write the orchestrator (
gen_X_device).For a composing algorithm like
fdsva_sothat builds onminv,forward_dynamics, and the IDSVA-SO sub-inners, the device wrapper does oneXImatsload and then calls each sub-algorithm’s placement-free_innerwith the sames_temp:# In gen_fdsva_so_device: self.gen_add_code_line( "if constexpr (!SCRATCH_IN_SMEM) { s_temp = d_workspace; }" " else { (void)d_workspace; }" ) self.gen_load_update_XImats_helpers_function_call() self.gen_minv_inner_function_call(f_in_smem_expr="true") self.gen_add_code_line( "forward_dynamics_inner<T, true>(s_qdd, s_q, s_qd, s_u, " + self.gen_insert_helpers_function_call() + "s_temp, nullptr, gravity);" ) # ... IDSVA-SO sub-inners ... self.gen_fdsva_so_contract_function_call()
The XImats load is paid once here; every sub-inner gets it for free. That is the reason the
_inner/_devicesplit exists; see Codegen architecture.Write the kernel (
gen_X_kernel).The kernel is mechanical: allocate
__shared__smem from the per-tier*_DYNAMIC_SHARED_MEM_BYTESmacro, run a grid-stride loop over timesteps, load inputs to smem, callX_device, store outputs:template <typename T, int RESOURCE_TIER = GRID_DEFAULT_RESOURCE_TIER> __global__ void fdsva_so_kernel(/*...*/) { __shared__ T s_temp[fdsva_so_DYNAMIC_SHARED_MEM_BYTES<T, RESOURCE_TIER>() / sizeof(T)]; // ... allocate s_inputs, s_outputs ... for (int k = blockIdx.x; k < NUM_TIMESTEPS; k += gridDim.x) { // ... load inputs ... fdsva_so_device<T, ...>(/* placement flags chosen per RESOURCE_TIER */); // ... save outputs ... } }
The kernel never repoints
s_tempitself; the device owns that. The kernel’s only job is to size the arena, load/save IO, and pass the per-tier placement flags as template arguments.Register the tier picks.
Tell
GRiDCodeGenerator.pyhow to choose a spill rung per tier for this robot:# Inside _constants_arena.py, in the per-robot tier table build: fdsva_so_inner_idsva_so_temp_count = ... # sub-algorithm scratch fdsva_so_contract_temp_count = self.gen_fdsva_so_contract_temp_mem_size() # 3-way selection: SHARED / LITE / MINIMAL → choose a rung (0..N-1). rung_arenas = [...] # list of (rung_name, bytes_needed, flag_tuple) self.fdsva_so_spill_tier_3way = self.select_shared_tier_3way(*rung_arenas)
select_shared_tier_3waypicks the lowest-byte rung that fits the per-tier target.SHAREDfalls through to the most-spilled rung if nothing fits, so big robots spill atSHAREDtoo.Add the host wrapper and the descriptor-table row.
gen_X_hostmirrors any other host wrapper. Add oneAlgoDescriptorrow (and itsALGO_REGISTRYentry) ingrid_codegen/algo_registry.py: the descriptor row carries the algorithm’s irregular metadata (autotune keys,gate_attr,bytes_macrooverrides) and drives theGridAlgoenum, the launch-config symbol map, and the kernel-attr / mjx manifests from a one place, while the registry wires the algorithm into the bench, equivalence runner, and per-algo TUs. Thetest/test_algo_descriptor_parity.pynet locks the table to the generated output.Add a CUDA equivalence test.
For a small robot (start with iiwa14), regenerate the header and run:
PATH="/usr/local/cuda/bin:.../bin:$PATH" \ GRID_CUDA_RANDOM_SAMPLES=2 \ pytest -x -q \ test/cuda_equivalents/test_cuda_executable_equivalence.py \ -k iiwa14-fixed
The runner compares the kernel’s output against
RBDReference.X(the Python truth) for several sample states. Match it tortol = 2e-4for float32 atSHARED.Also exercise a forced-spill tier to validate the spill path:
GRID_CUDA_TARGET_SHARED_MEM_BYTES=30000 pytest ...
SHAREDvalidates the math; a forced deep spill validates that the spilled rung is byte-identical (only the pointer moves). Otherwise that rung ships untested.Cross-check shape conventions.
GRiD outputs are typically column-major (Fortran-order) on
s_temp/s_M/s_d2eePosetc. The CUDA equivalence runner has shape-aware comparators intest/cuda_equivalents/test_cuda_executable_equivalence.py; an algorithm with a non-standard output shape adds it there. The pre-existingend_effector_pose_hessianshape is6 * NUM_VEL² * NUM_EEper timestep (row-major in the EE / column / row axes), for example.
Common pitfalls#
A caller-side
s_temprepoint or alias. Placement is the inner’s job. A kernel that reassignss_tempfrom outside is the bug class the inner-owns-placement design prevents (see the anti-patterns list at the bottom of Algorithm design principles).A null
s_temppassed to a load helper. In whole-arena spill rungs the smems_tempslot isnullptr; theXImats/XmatsHomload helper dereferences it for sincos scratch. Always repoints_temptod_workspacebefore the first helper call.A single-valued
SHARED-pick macro used as a per-rung flag. Macros likeGRID_X_USES_SPILLequal theSHAREDpick. Using one as the inner’s template arg underif constexpr (RESOURCE_TIER == ...)gives the non-SHAREDtier the wrong flag, so it tries to write the full band into a selective-sized arena and overruns shared memory on big robots. Pass the actual per-rung value as a literal.A single-thread Gauss-Jordan inverse. This was a real performance hotspot in
aba/minvfloating-base root invert. Useglass::invertMatrix_dense(block-cooperative). An algorithm that needs an inverse / factor should reach for GLASS first.A spill rung that changes numerics. Spill rung 0..N−1 only relocates code and must be numerically identical to the unspilled path; the only difference is where a pointer points. A spilled tier that produces different numbers has a bug.
Hardcoded assumptions about block size. Use the
gen_add_parallel_loophelper, which emits a block-stride loop; any block size that fits is correct. Do not assumeblockDim.x == MAX_PERF_LEVEL_THREADS.
Code-generation helpers#
Most useful helpers (in grid_codegen/helpers/):
gen_add_code_line(line)/gen_add_code_lines([...]): emit text into the current function.gen_add_parallel_loop(var_name, max_val, block_level=False): emit a block-stridefor (i = tid; i < max_val; i += blockDim...)(block_level=Truestrides overblockIdxinstead).gen_add_sync(): emit__syncthreads().gen_add_serial_ops(): wrap the next block in anif (threadIdx.x == 0 && threadIdx.y == 0)(use sparingly; this is the bottleneck class to avoid, see Single-thread Gauss- Jordan inverse above).gen_add_func_doc(func_desc, notes=[], params=[], return_val=None): emit a Doxygen-style header.gen_kernel_load_inputs(name, amount, name2=None, amount2=1, name3=None, amount3=1, stride=None, stride2=None, stride3=None)andgen_kernel_save_result(store_to_name, amount, load_from_name=None, stride=None, sync=True): boilerplate for global/shared memory transfer in the kernel; batched kernels passstrideto address timestepk’s slot, single-call kernels omit it.Sub-algorithm calls take keyword overrides, e.g.
gen_minv_inner_function_call(updated_var_names=None, f_in_smem_expr="true")andgen_fdsva_so_contract_function_call(updated_var_names=None, scratch_in_smem_expr="true");updated_var_namesrenames the defaults_*operands when the caller’s buffers differ.grid_linalg_gemm<T, M, N, K>/grid_linalg_gemv<T, M, N>: thin wrappers aroundglass::gemm/glass::gemv. Always prefer these over hand-written loops.
The signatures above are the ones in grid_codegen/helpers/ and
grid_codegen/algorithms/ today; there is no thread-group argument.
Submitting the algorithm#
Open a PR against GRiD (the generator lives in grid_codegen/ in this
repository) with:
The new
_X.pyalgorithm file.The
algo_registry.pyentry.Any top-level
GRiDCodeGenerator.py/_constants_arena.pyedits (imports, tier selection).The
abi_specs.pyrow + regenerated wrapper regions (if the algorithm is bound to Python);python -m grid_codegen.wrapper_body_gen --checkmust pass.A CUDA equivalence test that exercises iiwa14 fixed and floating at
SHAREDand at a forced spilled tier.A bench entry in
run.pyto have the algorithm timed in the standard sweep.Documentation updates (algorithm description in
docs/source/user_guide/concepts/algorithms/).
Reviewers check three things: the inner owns its placement, the kernel size
matches the per-tier macro, and the CUDA test passes at SHARED and at a
forced spilled tier. When all three hold, the algorithm is on the spill
ladder and tier-aware composing kernels can build on it directly.