Skip to content

Fix batched trajectory-IK CG dot products on long horizons - #17

Open
ooctipus wants to merge 1 commit into
dylanturpin:dylanturpin/trajectory-ikfrom
ooctipus:zhengyuz/batched-cg-dot-reduction
Open

ooctipus wants to merge 1 commit into
dylanturpin:dylanturpin/trajectory-ikfrom
ooctipus:zhengyuz/batched-cg-dot-reduction

Conversation

@ooctipus

@ooctipus ooctipus commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator

Description

IKSolverTrajectory's CG backend under-converges multi-trajectory solves
(n_problems > 1) on long frame chains: batched solves stall at cg_iterations
budgets that are ample for the identical trajectory solved alone.

Root cause (in warp, not in this repo): warp's internal TiledDot picks its
dot-reduction strategy by batch count, not subproblem length. With
batch_count == 1 it reduces through a tiled tree (a bounded-tree variant above
128 * 512 scalar dofs), but with batch_count > 1 every subproblem is reduced
by a single block whose lanes accumulate their strided share serially in fp32 —
about L/512 serial additions per lane for a subproblem of L scalar dofs. The
O(n) rounding growth this injects into every CG scalar (rho, the step
denominator p·Ap, the stopping-test residual norms) stalls CG on long,
ill-conditioned chains. Filed upstream, with a self-contained repro and a
measured error table, as NVIDIA/warp#1700.

Fix (engine-local): swap the CG state's dot reduction for
_SegmentedTiledDot, a segmented tile tree implementing the same
compute/col/cols surface: one 512-wide tile_sum per 512-entry block of
each subproblem, then one further tile reduction per subproblem over its block
partials. The swap engages only when n_problems > 1, the device is CUDA, and
the per-trajectory length exceeds 128 * 512 scalar dofs — the same threshold
at which warp's own single-batch path abandons serial accumulation. Below the
threshold warp's serial path is numerically healthy and is kept, so shorter
multi-trajectory solves remain bitwise-unchanged (verified below). CPU batched
solves also keep warp's reduction: warp's CPU batched dot is one fully serial
lane per subproblem — the same O(n) issue in principle — but the effect is
unmeasured on CPU and deliberately outside this fix's blast radius.

Parity envelope: for subproblems up to 512 * 512 = 262,144 scalar dofs the
segmented tree has the same reduction shape as warp's single-batch bounded tree
(block indexing is relative to each subproblem's own offset), so batched solves
match the equivalent single-trajectory solves bitwise. Above that, accuracy
stays tree-like but bitwise parity with the single-batch path ends. The
reduction order is fixed (no atomics), so batched solves are bitwise
deterministic run to run.

Why not fp64 accumulation instead: accumulating the batched dot in fp64
would also fix the accuracy, but it diverges bitwise from warp's single-batch
fp32 tree at every subproblem length. Bitwise parity with the healthy
single-trajectory path is this PR's strongest validation invariant — the
reproduction below lands all four batched copies bitwise-identical (equal
coordinate sha256) to the solo solve — and it is what lets every batched solve
below the engagement threshold ship bitwise-unchanged. fp64 accumulation
remains listed as a warp-side option in NVIDIA/warp#1700, where its
tradeoff is spelled out.

Workaround status: warp exposes no public hook for the CG dot reduction, so
the swap writes the CG state's private _tiled_dot attribute. The attribute is
validated first (present, expected tile size), so a warp internals change fails
loudly at solver construction instead of silently reverting the fix while tests
stay green. A TODO marks the swap for removal once warp reduces batched dots
with a multi-block tree (NVIDIA/warp#1700).

Checklist

  • New or existing tests cover these changes
  • The documentation is up to date with these changes
  • CHANGELOG.md has been updated (if user-facing change)

Test plan

Measurements on RTX 6000 Ada (sm_89), CUDA 12.9, warp 1.16.0.dev20260720,
python 3.12. newton.tests.test_ik_trajectory and
newton.tests.test_ik_trajectory_dynamics pass on cpu + cuda:

uv run --extra dev -m newton.tests -k test_ik_trajectory

New and changed tests (CUDA):

  • test_trajectory_cg_batched_long_horizon — above the engagement threshold
    (33,792 frames x 2 dofs, inside the parity envelope) the segmented reduction
    engages, the batched solve is bitwise-equal to the single-trajectory
    solve, and reruns are bitwise deterministic. The bitwise assertion
    discriminates the reduction numerically: under warp's serial batched
    reduction, most coordinates drift by up to ~1e-6.
  • test_trajectory_segmented_dot (new) — dot-level accuracy against an fp64
    reference across batch shapes (2-4 segments, partially filled tail block),
    offset independence (identical segment data reduces bitwise-identically to a
    singleton instance's result), repeat-compute determinism, and the two-column
    compute/col interface CG drives (col_offset=1 for the p·Ap column).
  • test_trajectory_cg_dot_swap_guard (new) — the swap raises RuntimeError at
    solver construction when warp's CG state stops exposing a compatible
    _tiled_dot (renamed attribute, or unexpected tile size), and installs the
    segmented reduction when compatible.

Scope note: synthetic long-horizon chains do not reproduce the production
convergence failure (2-dof and 24-dof chains up to 131k frames are too well
conditioned and converge fine either way). The bitwise-parity assertion, which
fails under warp's serial reduction, therefore carries the fails-before
evidence at test scale; the reproduction below carries the convergence proof.

Bug fix

Steps to reproduce (without this PR):

  1. Build an IKSolverTrajectory problem with a long horizon — above 65,536
    scalar dofs per trajectory, e.g. 3,000+ frames at 35 dofs — whose
    Gauss-Newton system is ill-conditioned enough that CG needs its iteration
    budget (real articulations qualify; toy chains do not).
  2. Solve it once with n_problems=1 and once with n_problems > 1 (identical
    copies of the same trajectory), same cg_iterations.
  3. Observe the batched solve stalling at a far higher cost than the solo solve,
    deterministically across reruns.

Measured with four bitwise-identical copies of a 3,188-frame, 35-dof humanoid
motion-retargeting trajectory (CMU clip 55_25; 111,580 scalar dofs per
subproblem), ANALYTIC jacobians, 32 LM iterations, default cg_iterations=64:

configuration final LM cost (fp32) max landmark error
solo (n_problems=1), before = after fix 328.7804260253906 177.1 mm
batched x4, before fix 3.896e8 (LM stalled at the seed cost; all four copies identical) 425.4 mm
batched x4, after fix 328.7804260253906 (all four) 177.1 mm

With identical copies, every per-batch quantity is identical across copies, so
cross-problem coupling cannot explain the difference; swapping only the dot
reduction takes the batched solve from the stall to bitwise equality with the
solo solve (equal coordinate sha256), confirming the mechanism. Two consecutive
batched runs are bitwise-identical.

On the surrounding retargeting workload (32 humanoid motion-capture clips,
300-7,851 frames at 35 dofs), multi-trajectory solves at the default
cg_iterations=64 now match their sequential single-trajectory references —
the 3,188-frame batch bitwise, the rest within noise — where before the fix,
long batched buckets needed 2-3x the default budget (or single-trajectory
routing) to converge at all; the dominant batch (4 x 3,188 frames) dropped from
4.11 s to 1.91 s wall by converging at the default budget. A 16-trajectory
batch of 300-frame clips, below the engagement threshold, is bitwise-unchanged
by this PR (16/16 clips).

Minimal reproduction:

At toy scale the convergence gap does not manifest (see the scope note above),
but the defective reduction does — without this PR the batched solve silently
diverges from the solo solve's coordinates:

import numpy as np
import warp as wp

import newton.ik as ik

# any articulation with n_frames * n_dofs > 128 * 512, e.g. a 2-dof planar
# arm tracking an end-effector arc over 33,792 frames (objectives as in
# newton/tests/test_ik_trajectory.py)


def solve(model, n_frames, objectives, n_problems):
    solver = ik.IKSolverTrajectory(
        model,
        n_frames,
        objectives,
        n_problems=n_problems,
        jacobian_mode=ik.IKJacobianType.ANALYTIC,
        linear_solver="cg",
    )
    joint_q = wp.zeros((n_problems * n_frames, model.joint_coord_count), dtype=wp.float32)
    solver.step(joint_q, joint_q, iterations=10)
    return joint_q.numpy().reshape(n_problems, n_frames, -1)


q_single = solve(model, n_frames, objectives_x1, 1)
q_batched = solve(model, n_frames, objectives_x2, 2)  # two identical copies

# without this PR: False (most coordinates drift by up to ~1e-6)
# with this PR:    True (bitwise, inside the <= 512*512-dof parity envelope)
print(np.array_equal(q_batched[0], q_single[0]))

test_trajectory_cg_batched_long_horizon is this reproduction, made exact.

Status update (2026-08-05)

warp's batched TiledDot (batch_count > 1) reduces every subproblem with
a single block whose lanes accumulate their strided share serially in
the payload dtype -- ~L/512 fp32 additions per lane for a subproblem of
L scalar dofs. The O(n) rounding noise this injects into the CG scalars
(rho, p.Ap, the stopping-test residual norms) stalls convergence of
long trajectory chains at cg_iterations budgets that are ample for the
same trajectory solved alone: four identical copies of a 3,188-frame,
35-dof retargeting clip solved with n_problems=4 at the default budget
of 64 land at LM cost 3.9e8 (425 mm max landmark error) while the
single-trajectory solve of the same problem lands at 328.8 (177 mm).

Swap the CG state's dot reduction for a segmented tile tree above the
same length threshold at which warp's single-batch path switches to its
bounded tree (128 * 512 scalar dofs): one tile_sum per 512-entry block
of each subproblem, then one further tile reduction per subproblem over
the block partials. Below the threshold warp's serial path is well
conditioned and is kept, so shorter multi-trajectory solves stay
bitwise-equal to their sequential counterparts. For subproblems up to
512 * 512 scalar dofs the segmented tree has the same reduction shape
as warp's single-batch bounded tree, so batched solves inside that
parity envelope stay bitwise-equal to the equivalent single-trajectory
solves: with the fix the four-copy reproduction above lands all four
trajectories bitwise-identical to the single-trajectory solve. The
reduction order is fixed (no atomics), so batched solves stay bitwise
deterministic run to run. CPU batched solves keep warp's reduction --
the same serial-accumulation issue exists there in principle, but it is
unmeasured on CPU and outside this fix's scope.

warp exposes no public hook for the dot reduction, so the swap writes
the CG state's private _tiled_dot attribute. The attribute is validated
first (present, expected tile size), so a warp internals change fails
loudly at solver construction instead of silently reverting the fix
while tests stay green.

The convergence failure itself needs a production-scale articulation;
the added tests guard the reduction path at 33,792 frames x 2 dofs:
batched matches single bitwise inside the parity envelope (fails under
warp's serial batched reduction, which drifts most coordinates by up to
~1e-6 there), run-to-run bitwise determinism, dot-level accuracy
against an fp64 reference across batch shapes, and a construction-time
error when warp's CG internals drift.
@ooctipus

Copy link
Copy Markdown
Collaborator Author

Status note: NVIDIA landed the root-cause fix for the underlying warp issue (NVIDIA/warp#1700, commit cb9dc55 — batched dots now use segmented tree reductions across CG/CR/BiCGSTAB/GMRES, plus an optional max_batch_length hint). We verified it at our production sizes (accuracy equivalent to this PR's workaround, batched==singleton bitwise at all sizes) and are running warp 1.17 nightlies in production. This PR remains the right fix for anyone on current warp releases; once a warp release ships the fix, the swap can be retired per the in-code TODO — happy to file that follow-up when the time comes, ideally passing max_batch_length through aslinearoperator for the small extra win.

@jaredmejia

Copy link
Copy Markdown
Collaborator

The targeted long-horizon, segmented-dot and guard tests pass on c3307f99 with Warp 1.16 on CUDA. Disabling only the workaround in the numerical probe reproduces the batched-versus-solo mismatch, so it still has a purpose for the older supported versions.

One update to the retirement note: Warp 1.17.0 is now released and includes the newton-physics#1700 reduction fix. Since this branch still allows Warp >=1.15, should we keep this private-kernel workaround version-bounded, or raise the minimum to the fixed release and remove it? I haven't run a fresh 1.17 runtime comparison, so this isn't a claim that the workaround can simply be deleted under the current dependency range.

This branch was successfully deployed

1 active deployment
external-pr-approval — c3307f99 Deployed Jul 26, 2026 by ooctipus via Require Manual Approval for External PRs #19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants