Repository navigation
optim.linear: batched CG dot products (batch_count > 1) always use serial-lane fp32 accumulation â accuracy degrades with subproblem length and stalls CG on long, ill-conditioned subproblems #1700
Description
Summary
warp.optim.linear.cg with a batched LinearOperator (batch_offsets,
batch_count > 1) computes every CG scalar â rho, the step denominator
p·Ap, and the stopping-test residual norms â through batch_dot_kernel,
which assigns one block per subproblem and lets each lane accumulate its
strided share serially in the payload dtype (subproblem_length / block_dim fp32 additions per lane before a single wp.tile_sum). The code
states its own assumption: "(Assumes all subproblems are small compared to
total length)".
For a single batch, warp already abandons serial accumulation above
128 * tile_size scalars in favor of a bounded tile-tree reduction â but that
switch is gated on batch_count == 1, so batched subproblems stay serial at
every length. The result is a structural accuracy asymmetry that grows with
subproblem length (measured below: up to ~94x the tree-reduction error at
8,192 serial additions per lane), and in our production use it makes batched
CG on long subproblems fail to converge at iteration budgets that are ample
for the identical subproblem solved alone.
The error is deterministic (fixed reduction order, no atomics) â bitwise
repeatable run to run â so it presents downstream as a stable convergence
regression, not as noise.
Environment
- GPU: NVIDIA RTX 6000 Ada Generation (sm_89, 48 GB)
- CUDA: Toolkit 12.9, Driver 13.2
- warp
1.16.0.dev20260720; re-verified on1.17.0.dev20260725(nightly
built frommain, fetched 2026-07-25): the code paths cited below are
unchanged and the measured table is identical on both versions - python 3.12.13, numpy 2.5.1, Linux x86_64
We found no existing issue mentioning batch_dot_kernel; the closest related
change (GH-1608, inactive trailing-DOF indexing in batch_dot_kernel)
addresses out-of-range access, not accumulation accuracy.
Where in the code
warp/_src/optim/linear.py (line numbers identical at 1.16.0.dev20260720
and 1.17.0.dev20260725):
-
Lines ~285-292:
use_bounded_treerequiresbatch_count == 1, so the
bounded tile-tree reduction is unreachable for batched operators:use_bounded_tree = ( batch_offsets is not None and self.batch_count == 1 and self.device.is_cuda and max_length > 128 * self.tile_size )
-
Lines ~346-373: every
batch_count > 1TiledDottakes the
batch_dot_kernelbranch, commented "(Assumes all subproblems are small
compared to total length)". -
Lines ~489-508:
batch_dot_kernelâ per-lane serial accumulation in
a.dtype:acc = a.dtype(0.0) for i in range(batch_start, batch_end, wp.block_dim()): acc += a[col, i] * b[col, i] total = wp.tile_sum(wp.tile(acc))
Serial accumulation has O(n) worst-case rounding growth versus O(log n) for
the tree, and CG recycles these scalars into its direction updates every
iteration, so the per-dot error does not stay put â it steers the Krylov
recurrence.
Measurement (self-contained, warp + numpy only)
warp_dot_repro.py (inline below) computes per-subproblem dot products of
identical fp32 data four ways â the batched serial path under test
(batch_count = 4), warp's own batch_count == 1 policy on each subproblem
alone, the unbatched tiled tree, and an fp64 numpy reference â and reports the
max relative error against fp64 over subproblems and 10 seeded trials. Regime
norm is a == b with N(0,1) entries (the shape of rho = r·r and the
stopping-test norms); widerange is a == b with magnitudes log-uniform in
[1e-4, 1] (residual vectors whose components span scales â the late-CG /
ill-conditioned regime). 10,500 / 43,155 / 111,580 are the 300-, 1,233- and
3,188-frame 35-dof trajectory lengths from the downstream report below
(healthy / healthy / failing).
Measured output (python warp_dot_repro.py --device cuda:0), warp
1.16.0.dev20260720, RTX 6000 Ada (sm_89) â table bitwise-identical on
1.17.0.dev20260725:
| regime | length L | serial adds/lane | batched (B>1) | single (B=1) | tree | batched/tree |
|---|---|---|---|---|---|---|
| norm | 10,500 | 20 | 2.279e-07 | 2.279e-07 | 1.016e-07 | 2.2x |
| norm | 43,155 | 84 | 2.232e-07 | 2.232e-07 | 1.078e-07 | 2.1x |
| norm | 65,536 | 128 | 1.816e-07 | 1.816e-07 | 1.115e-07 | 1.6x |
| norm | 111,580 | 217 | 1.175e-07 | 1.075e-07 | 1.075e-07 | 1.1x |
| norm | 262,144 | 512 | 1.510e-07 | 1.346e-07 | 1.346e-07 | 1.1x |
| norm | 1,048,576 | 2048 | 2.149e-07 | 1.161e-07 | 1.161e-07 | 1.9x |
| norm | 4,194,304 | 8192 | 4.954e-07 | 1.215e-07 | 1.215e-07 | 4.1x |
| widerange | 10,500 | 20 | 1.531e-07 | 1.531e-07 | 1.098e-07 | 1.4x |
| widerange | 43,155 | 84 | 1.934e-07 | 1.934e-07 | 8.972e-08 | 2.2x |
| widerange | 65,536 | 128 | 2.279e-07 | 2.279e-07 | 7.094e-08 | 3.2x |
| widerange | 111,580 | 217 | 2.676e-07 | 1.416e-07 | 1.416e-07 | 1.9x |
| widerange | 262,144 | 512 | 4.855e-07 | 1.247e-07 | 1.247e-07 | 3.9x |
| widerange | 1,048,576 | 2048 | 1.748e-06 | 1.126e-07 | 1.126e-07 | 15.5x |
| widerange | 4,194,304 | 8192 | 6.635e-06 | 7.085e-08 | 7.085e-08 | 93.6x |
What the table shows:
- The
single (B=1)column is the smoking gun for the gate. Below
128 * 512 = 65,536scalars it matchesbatchedexactly (same serial
kernel); above, it drops to the tree's error level (the bounded tree
engages). The identical data loses accuracy purely because sibling
subproblems share the launch. - The batched error grows with subproblem length while the tree stays flat
(~1e-7 at every size). At 8,192 serial adds/lane the gap is 93.6x and still
widening. - Consecutive batched
compute()calls are bitwise-identical (asserted in the
script): this is deterministic rounding, not atomics noise.
Read honestly, the per-dot relative error at our failing production size
(L = 111,580) is small in these synthetic regimes â the point of the table is
the structural, unbounded asymmetry, not any single row. The convergence
consequence needs an ill-conditioned system and CG's feedback loop, which is
what we measured downstream:
Downstream consequence (how we found it) â controlled intervention
In the trajectory-IK solver of a Newton fork (newton-collab, branch
dylanturpin/trajectory-ik @ 6daeb38), CG solves block-Jacobi-preconditioned
banded J^T J systems whose conditioning worsens with horizon length. On an
RTX 6000 Ada with the environment above, solving four bitwise-identical
copies of one 3,188-frame, 35-dof motion clip (111,580 scalar dofs per
subproblem) as one batched operator, 32 outer Levenberg-Marquardt iterations,
CG budget 64 per linearization:
| configuration | final LM cost (fp32) | max landmark error |
|---|---|---|
solo (batch_count = 1), same code path |
328.7804260253906 | 177.1 mm |
batched x4, warp batch_dot_kernel reduction |
3.896e8 â LM stalls at the seed cost (all four copies identical) | 425.4 mm |
| batched x4, dot reduction replaced by a segmented tile tree (nothing else changed) | 328.7804260253906 (all four) â bitwise-equal to solo | 177.1 mm |
The design isolates the reduction: with four identical subproblems, every
per-batch quantity is identical across copies, so cross-problem coupling
cannot explain a batched-vs-solo difference; the solo control through the same
code path is bitwise-equal to a sequential reference, exonerating padding and
layout. The intervention then confirms the mechanism: swapping only the dot
reduction (a two-stage segmented tile-tree with the same shape as warp's
single-batch bounded tree, everything else untouched) takes the batched solve
from a hard stall at 3.9e8 to bitwise equality with the solo solve. Raising
the CG budget to 128 also recovers the batched solve (with warp's serial
reduction unchanged); batches of shorter clips (300 and 1,233 frames at
35 dofs) are healthy at budget 64, while a 16-clip batch padded to 3,188
frames needed the raised budget too â the failure is thresholded on
subproblem length, not on batch composition or clip content. All of this is
deterministic across reruns and orderings.
These runs use a motion-retargeting pipeline and capture-data corpus that we
cannot attach; we summarize them here as evidence and rely on the
self-contained script above for the mechanism-level reproduction.
Negative result, stated proactively: we tried to build a self-contained
batched-CG convergence repro and failed â synthetic trajectory chains (2-dof
and 24-dof, horizons up to 131k frames) converge fine in batched CG at default
budgets; their systems are too well-conditioned for the extra rounding to
bite. A toy CG demo is therefore not a meaningful test of this defect in
either direction; the dot-level table plus the controlled intervention above
are the reproducible evidence.
Our current workaround (downstream)
The Newton fork now ships an engine-local _SegmentedTiledDot implementing
the compute/col/cols surface of TiledDot: stage 1 reduces every
512-entry block of each subproblem with wp.tile_sum (blocks aligned per
subproblem, never straddling batch boundaries); stage 2 runs one further tile
reduction per subproblem over its block partials. It engages only for
batch_count > 1 on CUDA when the per-subproblem length exceeds
128 * tile_size â the same threshold at which warp's own single-batch path
abandons serial accumulation â and is swapped into the CG solver state after
construction. Because there is no public hook for the dot reduction, the swap
writes the private _tiled_dot attribute, guarded and carrying a TODO to
revert to a plain cg(...) call once warp reduces batched dots with a
multi-block tree. For subproblems up to tile_size^2 scalars the segmented
tree has the same reduction shape as warp's single-batch bounded tree, which
is what makes the batched-equals-solo bitwise parity above possible.
Suggested direction
- Extend the existing bounded-tree variant to
batch_count > 1â a
segmented multi-block tree: per-512-blocktile_sumpartials, then a
per-subproblem combine (exactly the shape of the workaround above). This
preserves the reduction shape of thebatch_count == 1path, so downstream
users who validate batched against solo solves keep bitwise parity where it
holds today. - Cheaper micro-fix: accumulate
accin fp64 (or Kahan-compensated fp32)
insidebatch_dot_kernelfor fp32 payloads; the loop is memory-bound, so
the cost should be negligible. Tradeoff: this changes batched results at
every subproblem length and cannot be made bitwise-consistent with the
single-batch tree paths, so batched-vs-solo parity checks downstream break
even where the current kernel is accurate enough. - Either way, choose the strategy by subproblem length â
batch_offsets
deltas are known atTiledDotconstruction â instead of assuming
subproblems are small.
Downstream impact (footnote)
Before any workaround, long batched subproblems in our retargeting workload
needed 2-3x the default CG iteration budget (or routing to batch_count == 1
solves) to converge at all; with the segmented-tree workaround they converge
at the default budget, and the dominant batch of our capacity benchmark
(4 x 3,188 frames) dropped from 4.11 s to 1.91 s at unchanged results. A warp-side
fix lets us delete the private-attribute swap entirely.
Reproduction script (warp_dot_repro.py)
Click to expand (174 lines, warp + numpy only)
#!/usr/bin/env python
"""Accuracy of warp's batched TiledDot (serial-lane accumulation) vs its own tree reductions.
``warp.optim.linear`` reduces CG dot products with ``TiledDot``. With
``batch_offsets`` describing more than one subproblem (``batch_count > 1``),
every dot product goes through ``batch_dot_kernel``: one block per subproblem,
each lane serially striding its subproblem range and accumulating in the
payload dtype (``subproblem_length / block_dim`` fp32 additions per lane
before a single ``wp.tile_sum``). The unbatched path instead reduces through a
tiled tree (one 512-wide ``tile_sum`` per block, then log-rounds of block
sums), and for a *single* batch longer than ``128 * tile_size`` warp itself
abandons serial accumulation for a bounded-tree variant â but that switch is
gated on ``batch_count == 1``, so batched subproblems stay serial at every
length.
This script measures what that asymmetry costs. For a batch of B fp32
subproblems of equal length L it computes every per-subproblem dot product
four ways on identical data:
batched : one ``TiledDot(batch_offsets=...)`` with ``batch_count == B``
(the serial-lane ``batch_dot_kernel`` under test)
single : per-subproblem ``TiledDot(batch_offsets=[0, L])`` with
``batch_count == 1`` â warp's own policy for the same data when
it is not batched with siblings (serial up to ``128 * 512``,
bounded tree above)
tree : per-subproblem unbatched ``TiledDot`` (plain tiled tree)
fp64 : numpy float64 reference on the same fp32 inputs
and reports the maximum relative error of each warp reduction against fp64
across subproblems and trials. It also checks that consecutive batched
``compute()`` calls are bitwise identical (the error is deterministic, not
atomics noise).
Two CG-relevant input regimes:
norm : a == b, entries ~ N(0, 1) â the shape of rho = r.r and the
stopping-test residual norms (positive summands)
widerange : a == b, |entries| log-uniform in [1e-4, 1] â residual vectors
whose components span scales, the late-CG / ill-conditioned
regime in which CG direction updates are most sensitive to
noise in rho / alpha
Requires only warp and numpy. Usage:
python warp_dot_repro.py [--device cuda:0] [--batch 4] [--trials 10]
"""
from __future__ import annotations
import argparse
import numpy as np
import warp as wp
try: # warp >= 1.10 module layout (the public shim does not re-export TiledDot)
from warp._src.optim.linear import TiledDot
except ImportError: # older layouts exposed the implementation module directly
from warp.optim.linear import TiledDot
# Per-subproblem scalar lengths. 65,536 = 128 * 512 is the threshold above
# which warp's own single-batch path abandons serial accumulation for its
# bounded tree; 10,500 / 43,155 / 111,580 are 300-, 1,233- and 3,188-frame
# 35-dof trajectory chains from the downstream report (healthy / healthy /
# failing in batched CG at the default iteration budget).
DEFAULT_LENGTHS = (10_500, 43_155, 65_536, 111_580, 262_144, 1_048_576, 4_194_304)
def make_inputs(rng: np.random.Generator, length: int, regime: str) -> tuple[np.ndarray, np.ndarray]:
if regime == "norm":
a = rng.standard_normal(length).astype(np.float32)
return a, a
if regime == "widerange":
mag = 10.0 ** rng.uniform(-4.0, 0.0, length)
a = (rng.choice((-1.0, 1.0), length) * mag).astype(np.float32)
return a, a
raise ValueError(regime)
def batched_dot(a_np: np.ndarray, b_np: np.ndarray, offsets_np: np.ndarray, device) -> np.ndarray:
"""All subproblem dots through one batched TiledDot (serial batch_dot_kernel)."""
offsets = wp.array(offsets_np, dtype=wp.int32, device=device)
dot = TiledDot(int(offsets_np[-1]), wp.float32, device=device, batch_offsets=offsets)
a = wp.array(a_np, dtype=wp.float32, device=device)
b = wp.array(b_np, dtype=wp.float32, device=device)
dot.compute(a, b)
first = dot.col(0).numpy().copy()
dot.compute(a, b) # determinism check: bitwise-equal on recompute
assert np.array_equal(first, dot.col(0).numpy()), "batched dot not deterministic"
return first
def single_dot(a_np: np.ndarray, b_np: np.ndarray, offsets_np: np.ndarray, device) -> np.ndarray:
"""Each subproblem through warp's batch_count == 1 policy.
Serial up to 128 * tile_size scalars, bounded tree above â i.e. what warp
itself does with the identical data when it is not batched with siblings.
"""
out = np.empty(len(offsets_np) - 1, dtype=np.float32)
a = wp.array(a_np, dtype=wp.float32, device=device)
b = wp.array(b_np, dtype=wp.float32, device=device)
for k in range(len(offsets_np) - 1):
lo, hi = int(offsets_np[k]), int(offsets_np[k + 1])
offsets = wp.array(np.array([0, hi - lo], dtype=np.int32), device=device)
dot = TiledDot(hi - lo, wp.float32, device=device, batch_offsets=offsets)
dot.compute(a[lo:hi], b[lo:hi])
out[k] = dot.col(0).numpy()[0]
return out
def tree_dot(a_np: np.ndarray, b_np: np.ndarray, offsets_np: np.ndarray, device) -> np.ndarray:
"""Each subproblem dot through warp's unbatched tiled tree reduction."""
out = np.empty(len(offsets_np) - 1, dtype=np.float32)
a = wp.array(a_np, dtype=wp.float32, device=device)
b = wp.array(b_np, dtype=wp.float32, device=device)
for k in range(len(offsets_np) - 1):
lo, hi = int(offsets_np[k]), int(offsets_np[k + 1])
dot = TiledDot(hi - lo, wp.float32, device=device)
dot.compute(a[lo:hi], b[lo:hi])
out[k] = dot.col(0).numpy()[0]
return out
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--device", default="cuda:0")
parser.add_argument("--batch", type=int, default=4, help="subproblems per batched dot")
parser.add_argument("--trials", type=int, default=10, help="random seeds per row")
parser.add_argument("--lengths", type=int, nargs="+", default=list(DEFAULT_LENGTHS))
args = parser.parse_args()
wp.init()
device = wp.get_device(args.device)
print(f"warp {wp.config.version}, device {device.name} (sm_{device.arch}), "
f"batch={args.batch}, trials={args.trials}")
print()
print("| regime | length L | serial adds/lane | batched (B>1) | single (B=1) | tree | batched/tree |")
print("|---|---|---|---|---|---|---|")
for regime in ("norm", "widerange"):
for length in args.lengths:
offsets_np = np.arange(args.batch + 1, dtype=np.int32) * length
err = {"batched": 0.0, "single": 0.0, "tree": 0.0}
for trial in range(args.trials):
rng = np.random.default_rng(1000 * trial + length)
a_np, b_np = make_inputs(rng, args.batch * length, regime)
ref = np.array(
[
np.dot(
a_np[offsets_np[k] : offsets_np[k + 1]].astype(np.float64),
b_np[offsets_np[k] : offsets_np[k + 1]].astype(np.float64),
)
for k in range(args.batch)
]
)
for name, fn in (("batched", batched_dot), ("single", single_dot), ("tree", tree_dot)):
got = fn(a_np, b_np, offsets_np, device)
err[name] = max(err[name], float(np.max(np.abs(got - ref) / np.abs(ref))))
ratio = err["batched"] / err["tree"] if err["tree"] > 0 else float("inf")
print(
f"| {regime} | {length:>9,} | {length // 512:>4} "
f"| {err['batched']:.3e} | {err['single']:.3e} | {err['tree']:.3e} | {ratio:>7.1f}x |"
)
print()
print("batched = TiledDot(batch_offsets=..., batch_count=B) serial-lane accumulation (batch_dot_kernel)")
print("single = TiledDot(batch_offsets=[0, L], batch_count=1) on each subproblem alone:")
print(" warp's own switch â serial <= 128 * 512 scalars, bounded tree above")
print("tree = unbatched TiledDot tiled tree reduction on the same subproblem data")
print("rel err = |result - fp64 reference| / |fp64 reference|, max over subproblems and trials")
print("determinism: consecutive batched compute() calls bitwise-equal at every row (asserted)")
if __name__ == "__main__":
main()
Activity
ooctipus commentedon Jul 26, 2026
Downstream fix/workaround for the affected consumer (Newton trajectory-IK solver): dylanturpin/newton-collab#17 â swaps the batched dot for a segmented tiled tree reduction (
_SegmentedTiledDot) behind a guard, with a revert-TODO pointing back at this issue once the strategy switch keys on subproblem length rather than batch count.gdaviet commentedon Jul 27, 2026
@ooctipus I created the internal MR 2714
Improve batched reduction accuracy [NVIDIAGH-1700]
shi-eric commentedon Jul 28, 2026
Progress update: cb9dc55 landed segmented tree reductions for batched CUDA dot products.
Long batched subproblems no longer go through the serial per-lane fp32 accumulation path. Warp now reduces each batch in blocks, then combines the partial sums with a tree. This code is shared by CG, CR, BiCGSTAB, and GMRES. Existing callers that use
batch_offsetsget the new path without any changes.There is also an optional
max_batch_lengthargument onLinearOperatorandaslinearoperator(). When the maximum batch size is known, passing it avoids extra reduction levels and keeps the direct path when every batch fits in one tile. The argument can be left out. Reused solver state must keep the samebatch_offsetsarray andmax_batch_lengthvalue.The new tests in
warp/tests/test_linear_solvers.pycover:test_tiled_dot_batched_tree: mixed batch sizes, equality with separate tree reductions, and CUDA graph capturetest_tiled_dot_max_batch_length: planning with and without the optional size boundtest_tiled_dot_plan_offsets_int_max: offset planning near the int32 limittest_functor_compat_errors: incompatible batch layouts and changed planning metadataAn issue-shaped probe using the widerange fp32 case from this report was run at subproblem lengths 111,580 and 1,048,576. The batched results matched separate tree reductions bit for bit, both with and without
max_batch_length. Repeated runs matched as well.The remaining check is the original Newton trajectory-IK workload. The issue should stay open until the reporter confirms that the
_SegmentedTiledDotworkaround can be removed.ooctipus commentedon Jul 30, 2026
Verified your fix downstream â thank you for the fast turnaround. On warp-lang 1.17.0.dev20260729, RTX 6000 Ada (sm_89), at our production subproblem sizes (22,500 and 274,785 scalars, batch 4-16): the batched path now matches an fp64 reference at ~1e-7 relative error at every size (the old serial path degraded to 5.9e-7 at 274,785 and kept growing), and batched results are bitwise-identical to the singleton path at every shape we tested. We have adopted 1.17.0.dev20260729 in production; our full retargeting workload reproduced byte-identically everywhere except the routes that previously flowed through the serial kernel, which moved exactly as expected and pass all quality gates. The engine-side workaround from dylanturpin/newton-collab#17 will be retired per its TODO once a release ships with this fix.
shi-eric commentedon Jul 30, 2026
Sounds good. This fix will be in Warp 1.17 releasing at the beginning of August.
Improve batched reduction accuracy [NVIDIAGH-1700]