Library-safe initialization and cleanup#
Every generated grid.cuh builds the per-robot device tables once, on the
host: the spatial transforms and inertias (d_XImats), the topology helpers,
the joint limits and, when opted in, the mutable runtime tables (inertia,
transform, joint dynamics). Historically these initializers used the
gpuErrchk policy: on any CUDA error they printed, called
cudaDeviceReset() and exit()-ed the process. That is right for a
benchmark binary and wrong for a library embedded in a Python interpreter or a
long-lived service. Since 2026-09-22 the header emits a second, nonterminating
spelling of the same construction and destruction logic.
The contract#
#include "grid.cuh"
grid::robotModel<float> *model = nullptr;
const char *failed_op = nullptr;
cudaError_t e = grid::init_robotModel_checked<float>(&model, &failed_op);
if (e != cudaSuccess) {
// model == nullptr; nothing acquired by this attempt is still allocated
throw std::runtime_error(std::string(failed_op) + ": " + cudaGetErrorString(e));
}
float *limits = nullptr;
e = grid::init_joint_limits_checked<float>(&limits, &failed_op);
...
e = grid::free_robotModel_checked<float>(model, &failed_op); // reportable cleanup
init_robotModel_checked/init_joint_limits_checked/init_XImats_checked/init_topology_helpers_checkedand theinit_*_params_checkedruntime-table initializers returncudaError_t, neverexit,abortorcudaDeviceReset. Every owned pointer is null before the first fallible call; construction stops at the first failed operation; every resource acquired by that attempt is released in reverse order;*outis written on complete success only. Host allocations are checked too (a failedcallocreportscudaErrorMemoryAllocation); the joint-limit table no longer touches the heap at all.failed_op(optional) receives a static string naming the operation that failed ("cudaMalloc(d_XImats)","cudaMemcpy(d_robotModel)","calloc(h_inertia_params)", …). The primary error is what you get back: a cleanup failure during rollback never overwrites it.free_robotModel_checked(model, &failed_op):nullptris a no-op returningcudaSuccess. The struct is copied back to recover the nested device pointers; if that copy fails, nothing else is touched, the copy error is returned and the nested arrays leak (documented limitation, it means the context is already unusable). Cleanup continues past a failedcudaFreeand returns the first error.Device affinity. A model is bound to the device that was current when it was built.
free_robotModel_checkedvalidates that withcudaPointerGetAttributesand returnscudaErrorInvalidDevice(freeing nothing) if another device is current. Ownership is never mixed across devices silently.Lost contexts. After a sticky/context-invalidating error (
cudaErrorIllegalAddressand friends) every CUDA call fails; the checked functions report that faithfully and clean up best-effort. They do not, and cannot, recover the context.Threading. The checked API keeps no shared state: each call reports through its own return value and out-parameters, so concurrent construction of different models on one device is well-defined (CUDA runtime calls are thread-safe). The legacy sticky slot (below) is a single non-atomic process-wide first-error slot and is not part of this contract.
Streams, arena and teardown (part 2)#
The same contract covers the rest of the lifecycle, so a whole GRiD instance can be brought up and torn down without a single terminating path:
cudaStream_t *streams = nullptr; grid::robotModel<float> *model = nullptr;
grid::gridData<float> *data = nullptr; const char *op = nullptr;
cudaError_t e = grid::init_grid_checked<float>(&streams, &op); // kernel attrs + streams
if (e == cudaSuccess) e = grid::init_robotModel_checked<float>(&model, &op);
if (e == cudaSuccess) e = grid::init_gridData_checked<float, N>(&data, &op); // or (n, &data, &op)
if (e != cudaSuccess) {
grid::close_grid_checked<float>(streams, model, data); // null stages are no-ops
throw std::runtime_error(std::string(op) + ": " + cudaGetErrorString(e));
}
...
e = grid::close_grid_checked<float>(streams, model, data, &op);
init_grid_kernel_attrs_checkedreturns the firstcudaFuncSetAttribute/ shared-memory fit-check error and names the kernel;init_grid_streams_checkeddestroys every stream it created before reporting a later failure;init_grid_checkedcomposes the two.init_gridData_checkedguards all ~80 device and host allocations of the batch arena (and the memsets/copies between them). Its release list is derived from the allocation list at generation time (one emitted line list feeds the constructor, the rollback andclose_grid_checked), so the two can never drift the way a hand-written free list could. Host staging buffers (pinned, with a pageable fallback) are checked too.close_grid_checked(streams, model, data, &op): every argument may benullptr(a no-op), cleanup continues past a failed free/destroy, the first error is returned and named, and a caller-installed device pool is rewound afterwards exactly as before.The legacy
init_grid/init_grid_streams/init_grid_kernel_attrs/init_gridData/close_gridkeep their signatures and policy as thin wrappers.
The grid_rbd bindings construct streams, model and arena through the
checked calls and release the completed stages on a later failure, so a
failed registration leaves nothing allocated and raises a Python exception
naming the operation.
Owning handle#
grid::robotModel_owner<T> is a noncopyable, movable RAII wrapper:
grid::robotModel_owner<float> owner;
if (owner.init(&failed_op) != cudaSuccess) { ... }
use(owner.get());
cudaError_t ce = owner.free(&failed_op); // explicit, reportable
// or let the destructor free best-effort; release() hands the raw pointer back
Legacy spellings#
init_robotModel(), init_joint_limits(), init_XImats(),
init_topology_helpers(), init_*_params() and free_robotModel() are
thin wrappers over the checked functions that apply the historical policy:
print the failed operation, then gpuAssert (cudaDeviceReset``+``exit
by default; under -DGRID_GPUERRCHK_NO_EXIT record the first error in the
sticky slot readable through grid_last_error() /
grid_consume_last_error() and return nullptr). Their signatures and
behaviour are unchanged, and there is exactly one construction/destruction
implementation underneath. The Python bindings (grid_rbd) construct the
model through the checked path and translate a failure into a Python
exception naming the operation.
Testing the failure paths#
Two host-only macros are the fault-injection seams: GRID_CUDA_CALL(expr)
wraps every CUDA runtime call the checked initializers make and
GRID_HOST_ALLOC(expr) every host allocation. They default to the bare
expression (no runtime cost, never used in device code). A test translation
unit defines them before including grid.cuh to fail the N-th call
deterministically; test/cuda_equivalents/test_cuda_safe_init.py does this
for every call in the construction sequence, for host allocation failure, for
copy-back and nested-free failures during destruction, and for the legacy
exit path (in a subprocess), on a serial arm and on a floating-base robot with
every runtime table enabled.