Adding a New Algorithm#
This is a practical, end-to-end guide for adding a new rigid-body
dynamics algorithm to GRiD. 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 & Best Practices — 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 (v2.0) — per-tier dispatch and selective spill.
If you internalize those three documents and follow the recipe below, your new algorithm will plug into the existing tier-spill / equivalence / bench infrastructure with no special-casing required.
The shape of the work#
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 |
Plus, you’ll add one AlgoDescriptor row to algo_registry.py — the
descriptor table is the single source of truth for per-algo metadata, and
that one row drives the GridAlgo enum, the launch-config symbol map, and
the KERNEL_ATTR_MANIFEST / mjx manifest heads (previously these were
scattered hand-maintained dicts). If the algorithm gets a Python binding you
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; never hand-edit
inside them (the --check drift gate in
test/test_wrapper_generated_block.py fails CI if you do). The arena/tier
math lives in grid_codegen/_constants_arena.py (the 2026-08-27 monolith
split moved it out of 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 what intermediate values you need 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, use_thread_group=False): 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 rolling your own. 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(use_thread_group) self.gen_minv_inner_function_call(use_thread_group, 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(use_thread_group)
The XImats load is paid ONCE here; every sub-inner gets it for free. That’s the entire 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 — that’s the whole point: big robots spill at SHARED too.Add the host wrapper + 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 single source, 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 your kernel’s output against
RBDReference.X(the Python truth) for several sample states. Match it tortol = 2e-4for float32 at SHARED.Also exercise a forced-spill tier to validate the spill path:
GRID_CUDA_TARGET_SHARED_MEM_BYTES=30000 pytest ...
SHARED validates the math; a forced deep spill validates that the spilled rung is byte-identical (only the pointer moves) — otherwise that rung is shipped untested and will bite later.
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; if your algorithm has a non-standard output shape, add 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#
Caller-side ``s_temp`` repoint or alias. Placement is the inner’s job. A kernel that reassigns
s_tempfrom outside is the bug class the inner-owns-placement design was built to prevent. (See the anti-patterns list at the bottom of Algorithm Design Principles & Best Practices.)Null ``s_temp`` to a load helper. In whole-arena spill rungs the smem
s_tempslot isnullptr; theXImats/XmatsHomload helper dereferences it for sincos scratch. Always repoints_temptod_workspaceBEFORE the first helper call.Single-valued SHARED-pick macro as a per-rung flag. Macros like
GRID_X_USES_SPILLequal the SHARED pick. Using one as the inner’s template arg underif constexpr (RESOURCE_TIER == ...)gives the non-SHARED tier the wrong flag → it tries to write the full band into a selective-sized arena → smem OOB on big robots. Pass the actual per-rung value as a literal.Single-thread Gauss-Jordan inverse. This was a real performance hotspot in
aba/minvfloating-base root invert. Useglass::invertMatrix_dense(block-cooperative). If your algorithm needs an inverse / factor, reach for GLASS first.Spill rung 0..N−1 only relocates code — must be numerically identical to the unspilled path. The only difference is where a pointer points. If your spilled tier produces different numbers, you’ve got a real 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. Don’t assumeblockDim.x == MAX_PERF_LEVEL_THREADS.
Code-generation helpers (cheat sheet)#
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, max_val, use_thread_group=False, block_level=False)— emit a block-stridefor (i = tid; i < max_val; i += blockDim...).gen_add_sync(use_thread_group=False)— emit__syncthreads().gen_add_serial_ops(use_thread_group=False)— 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(description, notes, params, return_val)— emit a Doxygen-style header.gen_kernel_load_inputs(name, stride, amount, ...)andgen_kernel_save_result(name, stride, amount, ...)— boilerplate for global ↔ shared memory transfer in the kernel.grid_linalg_gemm<T, M, N, K>/grid_linalg_gemv<T, M, N>— thin wrappers aroundglass::gemm/glass::gemv. Always prefer these over rolling your own loops.
When you’re done#
Open a PR against the codegen submodule 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 SHARED and at a forced spilled tier.
A bench entry in
run.pyif you want the algorithm timed in the standard sweep.Documentation updates (algorithm description in
docs/source/user_guide/concepts/algorithms/).
The reviewers will mostly look at: does the inner own its placement? Does the kernel size match the per-tier macro? Does the CUDA test pass at SHARED and at a forced spilled tier? If all three are green, the algorithm is on the spill ladder for free and tier-aware composing kernels can build on it directly.