Hard-won, HJCD-IK-specific institutional knowledge. Read before changing the kernel, codegen, or robot
config. Coarse search has one candidate per block and shares candidate state across warps;
LM refinement has one independent candidate per warp. Their synchronization scopes are intentionally different.
Companion docs: CLAUDE.md, STARTUP_PROMPT.md.
The running roadmap / open-work list is docs/open-tasks/TODO.md (local, ignored by Git).
- Regenerate
grid.cuhand rebuild if the URDF or EE target changed:python scripts/codegen/generate_grid.py csrc/urdf/panda.urdf -t panda_grasptarget_hand --collision --spherized-urdf external/foam/assets/panda/smaller_panda_spherized.urdfthenpython -m pip install -e .. Stalegrid.cuh= silently wrong FK/Jacobian. FLANGE_IDX/ target agreement acrossgrid.cuh,csrc/kernel/hjcd_kernel.cu, and any benchmark problem.- Build clean and import:
python -c "import hjcdik; print(hjcdik.num_joints())". - Run the benchmark and compare to the committed baseline (do not eyeball):
python benchmark/hjcd_ik_bench.py --skip-grid-codegen→ solved-rate, pos/ori error, timing. - Thread/warp sweep if you touched the solver loop: validate at 1 warp (32) and multi-warp block sizes; confirm results are batch-size-invariant (divergence at larger blocks ⇒ missing sync).
- No GPU contention during timing. Other agents run heavy GPU work on this machine — isolate timing runs.
Symptom: loss decreases but the final EE pose is wrong / convergence is erratic. Cause: URDF or target changed without re-running codegen. Fix: regenerate + rebuild (checklist #1). The build caches; force a clean rebuild if in doubt.
Symptom: IK "solves" but FK of the solution puts the EE at the wrong link.
Cause: the fixed target index from codegen (panda_grasptarget_hand) disagrees with the kernel.
Fix: capture the index GRiD assigns; verify every FLANGE_IDX/EE_IDX use in hjcd_kernel.cu.
Note: GRiD's named-fixed-target dispatch has had bugs (named handles calling the unsuffixed all-leaf
kernels → off by the flange offset). When validating FK, compare against a Python reference, not just "it ran".
Symptom: diverges erratically; results differ by block/batch size.
Cause: coarse candidate state is block-shared, but LM candidates and coarse pair-evaluation
scratch are warp-local. A warp barrier cannot publish data across warps, and block barriers inside
independently diverging LM iterations can deadlock (including partially populated final blocks).
Fix: use block barriers for shared coarse candidate state; LM's SYNC() is always
__syncwarp(mask). Use compute-sanitizer --tool racecheck, not only numerical comparisons.
Symptom: wrong sizes / OOB after swapping robots.
Cause: NUM_JOINTS, TOPOLOGY_HELPERS_COUNT, transform counts are per-URDF. The GRiD example header
is a 19-DOF robot; Panda regenerates to NUM_JOINTS=7.
Fix: read counts from the generated grid:: symbols; never hardcode.
Symptom: CMake can't find GRiD/GLASS; codegen script missing; or grid.cuh fails to compile with
unknown glass:: symbols.
Cause: external/GRiD is the codegen source and external/GLASS provides the linear-algebra
primitives. Since the header is generated with vendor_glass=False, grid.cuh includes the
top-level external/GLASS instead of vendoring its own copy, so GRiD's nested GLASS pin and
external/GLASS must agree.
Fix: bash scripts/setup/bootstrap.sh (pins all three submodules). After bumping either pin,
regenerate with scripts/codegen/generate_grid.py and run scripts/codegen/check_grid_cuh_fresh.py.
Symptom: free targets flagged in-collision (or vice versa).
Cause: the baked collision spheres don't match the robot — grid.cuh was generated without
--collision (no grid_collision namespace → the Python API rejects collision-free requests; see the
HJCD_HAS_COLLISION guard), or the spherization source is wrong (Panda needs the foam
--spherized-urdf, not on-disk meshes).
Fix: regenerate with --collision (and, for Panda, --spherized-urdf …/smaller_panda_spherized.urdf);
or run without --collision-free.
Symptom: numerical tests and memcheck pass, but Racecheck reports shared-memory WAR hazards.
Cause: a shuffle reduction exchanges registers; it is not a shared-memory fence. Lane 0 must not
overwrite per-warp anchor FK scratch until all lanes finish reading the prior anchor. Similarly, a
block-shared stop flag must not be overwritten while another warp is still making its previous exit decision.
In LM's final output, every lane must finish reading the shared restore condition before lane 0 overwrites
the error used by that condition; the non-restoring path also needs a warp fence.
Fix: fence anchor-buffer reuse with __syncwarp; publish each coarse stop decision once and reuse
that stable value until the next synchronized update. Do not add block barriers to warp-local LM math.
Build an isolated diagnostic wheel with -Ccmake.define.CMAKE_CUDA_FLAGS=-lineinfo to obtain source lines.
A useful regression target is tests/test_multiwarp.py::test_partial_last_block_no_crash under
compute-sanitizer --tool racecheck --error-exitcode=1; require zero warnings as well as zero errors.
- Shrink:
--num-targets 1 --batches "1", print inputs/outputs, check the numbers are sane. - Separate FK from the solver: call FK on the returned solution and compare to a Python reference
(
grid_rbd/ RBDReference) before blaming the optimizer. - Unconstrained first: disable collision, confirm IK converges, then re-enable.
- Symbolic codegen (sympy) is slow on first run; subsequent runs cache.
- The fixed-target name (
-t) must be a real frame in the URDF. - Editing GRiD codegen invalidates the
.socache only if the cache key hashes the codegen source — when in doubt, force a clean rebuild.
HJCD-IK's speed comes from warp-per-candidate parallelism. When refactoring math onto GRiD/GLASS:
- Use warp-scoped primitives (
grid::ee_pose_inner_warp,glass::warp::*) — never block-scoped (glass::default) or cooperative-groups (glass::cgrps::) or vendor (glass::nvidia::) at these tiny, warp-dispatched sizes. - A/B every swap at production thread counts against the hand-rolled version; keep the hand-rolled code if a primitive can't match, and refine the upstream primitive instead of regressing HJCD.
The raw multi-warp / perf-attribution sweeps (June 2026) were local notes and are not tracked; the
tracked timing evidence is docs/development/evidence/targeted_timing_2026-09-27/ and the
audit record docs/development/history/audit_hardening_validation.md.
-
⚠️ STALE-BINARY TRAP (the #1 perf-methodology bug — it invalidated an entire timing/correctness pass):ninja -C buildrebuildsbuild/_hjcdik*.so, but Python imports the editable-install copy under.venv/.../site-packages/hjcdik/, whichninjaNEVER touches. Soninja-only "rebuilds" leave the RUNNING binary stale — env knobs (HJCD_LM_WARPS,HJCD_LM_EPS_*) and code changes silently have no effect. Always rebuild withscripts/setup/rebuild.sh(ninja + copy.sointo site-packages + import check) orpip install -e . --no-build-isolation. Symptoms that you're on a stale binary: a new env knob does nothing; a temporaryprintfin the kernel never prints; results don't change when they obviously should. Verify withpython -c "import hjcdik._hjcdik as m; print(m.__file__)"+ its mtime vs your last build. This trap made multi-warp look "flat" and the tol knob look "dead" — both false. -
Multi-warp (W LM candidates/block) is SLOWER on a big GPU — measured (correct binary): W=1 fastest, W=8 up to 41% (ns=1) / 63% (ns=4) slower.
Krepcandidates is FIXED regardless of W, so regrouping intoceil(Krep/W)blocks × W warps does NOT raise total warps/SM — and the bigger blocks (32·W threads, W·~4 KB smem) cut co-resident blocks/SM, hurting latency hiding. The workload (Krep ≈ 160–2200) is far below saturation, so there's no occupancy deficit to fill. Lesson: "pack more warps/block" raises occupancy only if it raises total resident warps/SM AND you were warp-starved; when the work-item count is fixed and ≪ saturation it does nothing and the bigger blocks cost you. CheckKrep/numSMsvs the warp/block caps first. Default = W=1; W>1 retained only as an opt-in for low-SM devices (Jetson — plausible there, untested). -
fp32-vs-fp64 refine — regime-dependent, the most important perf knob:
num_solutions≥2(early-stop OFF ⇒ throughput-bound, all Krep candidates run full): fp32 is 5–7× FASTER (the 5090's 1/64 fp64 throughput penalty dominates). Sub-micron.num_solutions=1at the tight 1e-8 m tol: fp32 ~1.2× slower (can't reach the tol below its float floor, grinds all iters). BUT with a precision-appropriate looser tol (~1e-6 m) fp32 EARLY-STOPS and is ~2× FASTER than fp64@1e-8, still sub-micron (~330 nm) — so the tolerance IS a lever here. Lesson: classify latency- vs throughput-bound before judging mixed precision (the 1/64 penalty is THROUGHPUT); and a too-tight tol can mask a precision win by forcing full iteration counts. A "negative" result in one regime/tol can be a multi-× win in another. The convergence early-stop itself is correct (verified: a 1 m tol collapses to coarse quality immediately).
-
Timing-harness gotcha (self-contention false-positive): a quiet-GPU guard that samples
nvidia-smi utilization.gpubetween configs of its own sweep will read its OWN recently-run kernels (util is time-averaged) and abort as if another process were contending. Likewise the compute-apps list includes the sweep's own ~0.5 GB CUDA context. Fix (in the since-removedtime_multiwarp_sweep.py;scripts/perf/run_audit_gate.pykeeps the rule): excludeos.getpid()from the foreign-apps check, and only util-gate at STARTUP (before any solving) — mid-sweep, gate on foreign apps only. Lesson: a process can't use util to tell if IT is the one keeping the GPU busy; gate on foreign pids, not aggregate util, once you're running. -
⚠️ nsys-SPLIT before attributing a high-DoF / scaling cost to a kernel. When 24-DoF was ~13× slower than 7-DoF, the "obvious" suspect was the fp64 O(DoF³) warp-Cholesky inlm_tuner— wrong. A fp32-vs-fp64 A/B (fp32 came out slower at every DoF) refuted it, and the per-kernel nsys split (the since-removeddof_scaling_ab.sh --nsys) showedcoarse_searchwas 88% of the 24-DoF wall and scaled ~O(N³), whilelm_tuneronly grew 2.5×. Root cause (since fixed): the greedy candidate loop ran O(N³) work serialized onlane==0with two full O(N)ee_fk_threadchains per candidate. The fix is the current lane-parallel sweep +ee_fk_suffix_thread(recompute only the suffix from the perturbed joint). Lesson: a kernel's name and the most-numerically-scary line are not evidence; split the wall by kernel (nsyscuda_gpu_kern_sum) and A/B the suspected lever before believing a cause. The fix for a warp kernel that scales badly is almost always restoring warp-parallelism, not changing precision. -
Tolerance is a per-regime lever, and looser tol can be a trap. The LM early-stop (
eps_pos/eps_oriinsolve_lm_batched; envHJCD_LM_EPS_POS/ORI) only shortenslm_tuner; at high DoF wherecoarse_searchdominates, a looser tol buys ~1.05× and craters accuracy (11–140 mm — it returns coarse-quality solutions). Keep tight 1e-8 unless you've confirmed via nsys that the LM loop is the bottleneck for your DoF/num_solutions.
Requesting multiple solutions disables LM early-stop, not coarse early-stop. Coarse blocks still publish a first-success flag, so scheduling (including sanitizer instrumentation) can change the candidate pool and returned joint configurations between otherwise identical solves. A test requiring identical arrays with and without statistics should use one coarse block and multiple outputs, removing inter-block early-stop while retaining the strict numerical comparison. Larger-batch tests should check solution quality, collision policy and statistics consistency, not exact candidate identity.
(Append new bug classes / tricks here as they emerge — keep this guide the single source of truth.)
-
Candidate identity is not run-to-run stable, by design (ruled 2026-10-02). Coarse blocks race on the cross-block early-stop flag (
g_stop), and the LM refine does the same when one solution is requested, so two identical calls can return different (equally valid) candidates. Accepted behaviour: do not add a deterministic mode. Consequences: compare builds statistically (returned counts, error distributions,scripts/bench/capture_baseline.pyaggregates), never bitwise; regression tests use slack bands over many targets; do not write tests that pin a specific returned configuration. -
Bit-identical refactors vs. tuned numerics (2026-10). The LM loop applies robust row weights twice on a trial step: once folded into
row_sat the iteration start, and again (fresh, at the trial's own position error) when scoring the backtracking trial — while the dogleg / coordinate fallbacks score withrow_sonly. This asymmetry is part of the tuned paper behaviour and was kept verbatim in the 2026-10 tidy-up;robust_row_weights/ee_residual6just name the shared pieces. Changing it is a numerics experiment that needs the GPU regression + a paper-protocol rerun, not a cleanup. -
A change under
csrc/cannot be "done" without a GPU. The CPU-only CI only verifies the signedgpu-proof.json, whose fingerprint coverscsrc/,tests/,docs/source/, scripts and the submodule gitlinks. Compile-only checks (nvcc -c, available via thenvidia-cuda-nvccpip wheel without a GPU) catch syntax and template errors, but every such change still needspytest tests+ a re-recorded receipt on a real GPU before merge. -
Collision benchmarking is a comparison of collision MODELS first, solvers second (2026-10-03). Things that bit us, with the fix in parentheses: (a) cuRobo's
RobotBuilderrobot from a mesh-less URDF has NO spheres → "collision-free" IK silently unconstrained (use the bundledfranka.ymlre-targeted); (b) PyRoki's IK example has no obstacle term and its URDF capsule model is 16-70 mm too coarse for the MotionBenchMaker gaps (feed it the foam spheres viaRobotCollision.from_sphere_decomposition,--pyroki-collision on); (c)panda_description's collision meshes are convex hulls (link5's is 50 % larger than the link) — a "mesh oracle" built on them is MoveIt's conservative geometry, not ground truth; the visual meshes are (MeshOracle(geometry="visual")); (d) every sphere model under-covers the hulls and over-covers the true link somewhere, so thin obstacles (table_bars) flip verdicts between judges — always report several judges (benchmark/score_collision_oracles.py) and say which one is the headline; (e) on the clearance ladder the foam spheres accept 0 % of the dataset's own hull-feasiblegoal_ikat cage −1.5 cm vs 68 % for cuRobo's spheres: when a solver "fails" a tight scene, first check whether its own model admits ANY solution (benchmark/make_curobo_sphere_urdf.pybuilds HJCD on cuRobo's spheres to separate model from search). (f)MeshOracleonce cached worlds byid(world_dict); transient dicts reuse ids → stale geometry for a different problem. Cache by content. -
MotionBenchMaker problems are reproducible from the public dataset.
tests/mb_problems.jsonis exactly MBM'sproblems/download.sh pandaYAML (scene primitives in the panda_link0 frame, xyzw→wxyz, plus a robot stand under the base; verified bit-for-bit on cage).benchmark/mbm_export.pyconverts any MBM folder, including the mesh scenes (kitchen, table_bars) by exact rectilinear box decomposition / prism→cylinder.goal_poseof an exported set is FK(request joint goal) at panda_hand (MBM's own IK tolerance puts the request goal up to 14 mm from its pose query, so the dataset's pose and our FK differ by that much). -
The early stop was collision-blind (fixed 2026-10-03).
g_stopwas raised by the first pose-accurate candidate whether or not it collided, so cluttered scenes stopped the batch on a candidate the hard filter then discarded. The fix is warp-scoped (grid_collision::warp::config_freeover generated tables on the per-warps_jointX) and must stay so:grid_collision::config_freeis block-cooperative (multi-target FK with__syncthreads) and cannot be called from one warp of a multi-warp LM block. Traps hit on the way: (a) the LM loop counteritis a per-lane register — a restart decided by lane 0 must be signalled through warp-shared scratch (accurate == 2) and applied by every lane, or the warp desynchronises; (b)warp_base(coarse per-warp dynamic smem) is reused as sphere scratch only where the anchor FK buffers are dead, and the host sizes it as max(FK buffers, 3·NUM_SPHERES floats); (c)lm_epsis 1e-8 m, so "accurate" in the LM means fully converged — the practically successful candidates live in the 5 mm band below it, which is why the old kernel's cage "successes" at 1.4–3.5 mm vanished when the stop became collision-aware (warps kept converging into the obstacle) until the band fallback was added. Diagnose stage by stage withHJCD_CC_STOP=0/1/2/3andHJCD_REPAIR_ATTEMPTS=0. -
Before improving the repair step, check what the misses ARE (2026-10-07). The scorer's
pose okvspose & own spherescolumns tell you whether any accurate-but-colliding candidates are being returned at all. With the collision-aware stop + band fallback they are not (equal in every ladder cell), so a smarter repair (nullspace step along contact normals — implemented, measured, archived inevidence/repair_nullspace_2026-10-07/, not shipped) has nothing to act on; the residue is the sphere model. Success is monotone in B now (evidence/c1_batch_monotonic_2026-10-07/). -
Finer conservative spheres are not "better" on this benchmark. Measured with
benchmark/make_bounded_bulge_spheres.py --report: foam bulges 40 mm and leaves 4–35 % of the true surface uncovered; cuRobo's model bulges 75 mm on link 5 and leaves 20–71 % uncovered; a full-cover model with ≤13.5 mm bulge needs 377 spheres and scored lower in archived runs. Shared-GPU latency observations are not valid speed comparisons; conservative-model groups with missing queries require recollection. Compare solvers on identical spheres; if a conservative model is ever required, it needs GRiD's broad→fine cascade. -
Coarse constant-copy WAR hazard (2026-10-04). Copying every transform cell from shared source matrices inside independently progressing warp loops reads cells that FK immediately overwrites. nvcc can reuse the dead source cells' shared slots for scores written by another warp. Initialize each warp's constant transform cells once, before the loops, followed by a block barrier. Racecheck the diagnostic binary, including fp32/fp64, partial multi-warp blocks, and collision repair/stop settings; numerical tests alone missed this. Do not put a block barrier inside the divergent loop.
-
Benchmark accounting and precision (2026-10-04). One record per attempted query, including empty output. Score pose via independent FK with explicit target/frame, reject incomplete groups, and compare both errors on one candidate. Never infer accuracy from count. The eighth positional Python solver argument is
refine_fp64, notwrite_stats; name all options. Seetiming_gate.mdfor the corrected gate and errata.