Newton solver engineering note · 11 August 2026

FeatherPGS grasp stability

What stops a robot hand from holding an object in FeatherPGS, what to set to make it work, and what needs fixing in the solver. Every number comes from two scenes: a Franka arm that grasps a 40 mm cube and shakes it, and a two-pad coupon that strips the same problem down to one contact. Compared throughout against SolverMuJoCo on the same models.

FeatherPGS fpgs-main @ d95efe1d Newton 1.5.0.dev0 · Warp 1.16.0.dev20260716 RTX PRO 6000 Blackwell (sm_120)
Summary

The contact model is sound. Its defaults cannot express a real contact set, and one row family cannot express a squeeze.

Given enough constraint rows and enough iterations, FeatherPGS holds a shaken object as tightly as SolverMuJoCo and tolerates a far larger time step before the grasp itself fails — with no contact tuning at all. Getting there means changing defaults that silently discard most of the scene's contacts, and avoiding two options that make things worse.

201Constraint rows the arm scene needsDefault is 32; below 96 the cube is never lifted
0.31 mmCreep at 20 solver iterationsSolverMuJoCo on the same scene: 0.31 mm
16.7 msTime step the grasp alone survivesSolverMuJoCo loses the object beyond 2 ms
2.8×Cost per substep vs SolverMuJoCoOne environment; both are launch-latency bound

What needs attention

The contact-row budget defaults to 32 rows — ten contacts. The arm scene needs 201. Overflow is silent, and below 96 rows the cube is never picked up at all. The default solve mode also cannot hold that many rows cheaply, because its shared-memory footprint grows as the square of the budget.

One row family has no compliance term. Contacts between a free object and a fixed surface carry only the regularisation constant, so a squeeze between rigid surfaces has no defined force. That scene slides, ejects if squeezed harder, changes answer with the iteration count, and does not repeat run to run.

Two options make grasps worse. Extra velocity iterations turn every not-yet-touching contact into a wall, and the friction anchors trade a downward slide for an upward push.

What is already good

With the rows in place the cube stays within 0.82 mm through an eleven-second shake at twelve iterations, and 0.31 mm at twenty — matching SolverMuJoCo, which needs tuned contact settings to get there. Nothing was tuned: friction, compliance, position correction and regularisation were left at their defaults and the result is insensitive to all of them across wide sweeps.

Strip the arm away and the grasp alone survives a 16.7 ms time step, where SolverMuJoCo loses the object past 2 ms. The gap in the full scene is the arm and controller, not the contact model.

Scope. Two scenes, one simulated environment per run, on a single NVIDIA RTX PRO 6000 Blackwell (compute capability 12.0). Speed figures are therefore single-environment and say nothing about FeatherPGS at thousands of parallel worlds, which is what it is built for. Behaviour that depends on how the GPU schedules a kernel launch is flagged where it appears.
Guidance

Start here

This configuration produced every measurement on this page. Two of the three settings are structural — they decide whether the scene's contacts can be represented at all — and the third is required for CUDA graph capture.

solver setup
solver = newton.solvers.SolverFeatherPGS(
    model,
    pgs_mode="matrix_free",       # "split" cannot hold this scene's rows cheaply
    dense_max_constraints=256,    # scene peak is 201; the default 32 drops most of it
    mf_max_constraints=4096,
    pgs_iterations=12,            # 20 halves the creep at proportional cost
    double_buffer=False,          # required for CUDA graph capture
    # friction_mode="bisection_desaxce",   # 0.72 mm -> 0.40 mm, matrix-free only
    # pgs_beta, pgs_cfm, compliance and friction settings left at their defaults
)

Then supply the arm's gravity torque through control.joint_f, because the solver will not. And check what your scene actually needs:

find out whether contacts are being dropped
solver = newton.solvers.SolverFeatherPGS(model, row_watermark=True, ...)
# ... run the scene ...
print(solver.constraint_row_watermarks())
# {'dense_high_water': 201, 'mf_high_water': 12, 'contact_high_water': 71, ...}

One prerequisite before any of this is measurable

A position-controlled arm needs a gravity term, and no Newton solver computes one yet — it is a platform gap being closed across all of them rather than a FeatherPGS defect. It matters here only because without it the test arm never reaches its target pose, so nothing downstream can be measured at all. Every arm-scene number on this page supplies it as a feed-forward torque through control.joint_f, leaving every gain and solver setting untouched.

Worst arm-joint error after holding a fixed target for two seconds. Sag scales exactly as one over the stiffness, and vanishes with gravity switched off — a static load, not a solver problem. 4.65° puts the end effector centimetres off, and the test's phase gate wants 5 mm.
Do not compensate with stiffness. Scaling the gains lands on the finger joints too. At 30× the cube is dropped within a tenth of a second; at 100× the pads sink 3.86 mm into it against 0.045 mm for a correct grasp, the cube is squeezed out rather than held, and it falls 3.65 s into the shake — with no finger-to-cube contact at all in 265 of its 488 shake frames.

Why these and not others

With the arm in position and the rows in place, the solver options can be judged on their own. Only two move the number much.

Peak creep in the arm scene for each option, everything else held at defaults. More iterations and the two bisection friction formulations each roughly halve the creep. The shared friction anchor is neutral here. Velocity iterations make it worse.

The two parameters people usually reach for do nothing useful. The grasp survives every value of the position-correction factor from zero to 0.9 and every value of the regularisation term across four orders of magnitude — but the residual creep under the position-correction factor bounces between 0.33 mm and 4.50 mm with no pattern, so there is nothing to tune toward.

Wide sweeps of the two parameters most often tuned. The regularisation term is flat because it is added to the Gauss-Seidel denominator but not to the residual the sweep drives to zero: it changes how fast the sweep converges, not what it converges to.
Things that will waste your time. pgs_velocity_iterations makes the grasp worse, not better. pgs_cfm does nothing to contact softness. friction_smoothing does nothing at all. A grasp built from immovable pads and a fixed overlap has no defined grip force and will give you a different answer on every machine. Each is explained below.
Issue 1

The contact-row budget is 32. The arm scene needs 201, and going over is silent.

Every contact between the arm and anything else becomes one normal row plus two friction rows in a fixed-size per-world buffer. Drive rows and joint-limit rows are allocated from the same buffer first, so contacts are the first thing sacrificed when it fills.

The solver's own high-water telemetry gives the real requirement over a full grasp and shake: 201 dense rows at peak, from up to 71 simultaneous contacts. The constructor default is dense_max_constraints=32, which is ten contacts. The gap is not a matter of degree — below 96 rows the gripper cannot pick the cube up at all.

Peak creep against the row budget. At 64 rows and below the grasp does not happen: the gripper closes, the arm lifts and shakes, and the cube stays on the table. At 96 rows and above it is sub-millimetre. Nothing in the output indicates that anything was discarded.

What overflow does

Slots are claimed with an atomic counter. A contact that does not fit rolls the counter back and marks itself dead:

newton/_src/solvers/feather_pgs/kernels.py:2863
    else:
        # Dense path
        slot = wp.atomic_add(world_slot_counter, world, slots_needed)
        if slot + slots_needed > max_constraints:
            # Roll back the counter so finalize sees only filled slots
            wp.atomic_add(world_slot_counter, world, -slots_needed)
            contact_slot[c] = -1
            contact_path[c] = -1
            return

And the budget is expensive to raise on the default solve mode

The default pgs_kernel="tiled_row" stages the whole constraint matrix in static shared memory:

newton/_src/solvers/feather_pgs/solver_feather_pgs.py:8650
// Packed LOWER triangle of C in row-major (i*(i+1)/2 + j), j<=i
__shared__ float s_Ctri[TILE_TRI];      // TILE_TRI = M*(M+1)/2

__shared__ float s_lam[TILE_M];
__shared__ float s_rhs[TILE_M];
__shared__ float s_diag[TILE_M];
__shared__ int   s_rtype[TILE_M];
__shared__ int   s_parent[TILE_M];
__shared__ float s_mu[TILE_M];

That is 2M² + 26M bytes — quadratic in the row budget. CUDA's default per-block limit for a static shared-memory allocation is 48 KiB, and the opt-in above that applies only to dynamic shared memory, so on a device holding to the default the budget caps at 150 rows. Some devices allow more: on the RTX PRO 6000 Blackwell used for these measurements, 201 rows loads and 256 does not, putting its limit between 84 and 134 KiB.

dense_max_constraintsShared memory neededFits CUDA's default 48 KiB?Measured on the test GPU
32 (default)2.8 KiByes — but only 10 contactsloads
12835.2 KiByesloads
15047.8 KiByes — the largest that doesloads
201 (what the scene needs)84.0 KiBnoloads, at 0.11× realtime
256134.5 KiBnowill not load

Nothing checks any of this at construction. The solver already has a shared-memory estimator with a helpful error message, but it is wired into a different kernel family; the PGS kernel is built straight from dense_max_constraints with no device query, and the failure arrives as a CUDA module-load error inside the first step.

The escape hatch. pgs_mode="matrix_free" uses a generated kernel with an O(M) footprint and does build at 256 rows — which is why every measurement here uses it. That makes matrix-free mode effectively mandatory for any articulated scene with a real contact set, even though the constructor default is "split". Either the default should change, or the tiled path should move its staging to dynamic shared memory.

The arm scene on the constructor's own defaults

ConfigurationModeRowsResultSpeed

On its own defaults the solver never picks the cube up, entirely because of the 32-row budget, and it does so quietly — the arm runs the full scripted sequence over an object that never moves. Raising the split path's budget until it fits costs about three times the wall-clock time, because a 201-row tile is 84 KiB of shared memory driven by a single warp. Matrix-free mode is not an optimisation here; it is the only configuration that is both correct and fast.

Issue 2

A hard contact model cannot express a squeeze between two immovable surfaces.

Strip the scene down to two pads and one object and this becomes visible in isolation. It matters because it is the shape most people reach for when writing a minimal grasp test, and it has no answer.

Static pads

Pads welded to the world, the object placed between them with a fixed geometric overlap, then shaken by a body force. Nothing to set but the overlap. Contacts take the free-body row path.

Driven pads

The pads are the fingers of a four-joint carrier that closes on the object, lifts it off a support and shakes it, all position driven. Grip force comes from the finger drive. Contacts take the articulated path, which is what a real gripper uses.

Static pads: the object slides, and the rate is arbitrary

A 40 mm cube, 0.5 mm of overlap per side, gravity down, an oscillating force shaking it sideways for ten seconds. It does not fall — it never reaches a frame with zero contacts — but it does not stay put either:

How far the cube has slid below its starting height. Under FeatherPGS the primitive box creeps a fraction of a millimetre and settles; the identical cube as a triangle mesh keeps going. SolverMuJoCo holds both flat.
Object colliderContactsSlid downPushed upSolverMuJoCo, slid down

Why there is no answer to find

Push the object equally hard from both sides and it does not move, whether you push with one newton or a thousand. Nothing in its motion distinguishes them, so nothing in the solve pins the pair of forces down: both contacts only say "do not penetrate", and neither has a compliance that would let a penetration depth stand in for a force. A compliant contact model does not have this problem, which is why SolverMuJoCo is untroubled by the same scene.

What FeatherPGS produces instead comes from the position-correction term, which asks for the penetration to be removed within one step — from both sides at once, in a sweep that visits one row and then the other. Three measurements pin that down.

First, the answer moves with the iteration count, on fixed geometry:

Same coupon, same collider, only the iteration count changed. The box's excursion moves by a factor of seven and never quite settles; the mesh's does not even keep its direction — at four and thirty-two iterations the cube drifts upward, at twelve it slides down.

This is also why the collision representation matters so much here and so little once the pads are driven. The box presents eight contact points and the mesh eleven, so the two land on different values of an undetermined force, and the friction capacity that follows differs with them. Nothing about triangles is at fault; each representation simply converges to a different accident.

Second, squeezing harder makes it worse, and past a threshold the object is ejected:

Downward slip against overlap, with the same two-pad shape swept rather than fixed. Points at the bottom of the axis are at the 1 µm floor, meaning no measurable slip.
Overlap per sideFeatherPGS, boxFeatherPGS, meshSolverMuJoCo, boxSolverMuJoCo, mesh

Third, take the shake away, take gravity away, and simply place the object between the pads. Nothing in the scene is trying to move it. It moves anyway:

Overlap per sidePeak speed, FeatherPGSDrift, FeatherPGSDrift, SolverMuJoCo

Turning the position-correction factor down settles it completely:

pgs_betaPeak speedDrift over one second

At pgs_beta = 0 the object does not move at all — the drift is exactly zero, not small. Every millimetre it travels comes from the position-correction term, and it grows far faster than linearly in that term because the push feeds back into the next step's penetration. The two opposed rows would cancel if solved together; they are not, because the sweep visits one and then the other.

The usual remedy is a compliance term, which turns the penetration back into a force. FeatherPGS has one — dense_contact_compliance — but it does not reach here. Sweeping it from zero to 1e-4 over the ejecting coupon produces bit-identical results, because contacts between a free object and world-fixed pads land on the free-body row family, whose diagonal is seeded with pgs_cfm and nothing else. A gripper's contacts land on a different family that does have the term.

It does not repeat, either

ComparisonTrajectory difference

The box is bit-identical under every combination of double buffering and parallel streams, and bit-identical when simply run again. The mesh is not: two runs with nothing changed at all agree until frame 573 and then part company, ending 1.9 mm apart. Contact rows are claimed with an atomic counter, so the sweep order is whatever the GPU happened to schedule. On a well-posed problem that is a rounding-level detail. Here it decides the answer.

Driven pads: well posed, and the geometry difference collapses

Replace the welded pads with a real finger drive and grip force becomes a defined quantity: stiffness times how far past the surface the finger is commanded. Nothing about the contact model changes.

The same object, the same shake, held by position-driven fingers.
Commanded squeezeFeatherPGS, boxFeatherPGS, meshSolverMuJoCo, boxSolverMuJoCo, mesh

Each cell is downward slip / sideways lag in millimetres. Downward slip is the object sliding through the grasp; sideways lag is it trailing the shake, which is inertia rather than slipping.

From one millimetre of squeeze upward, FeatherPGS holds the object with essentially zero downward slip, and it does not care whether the object is a box or a triangle mesh. SolverMuJoCo on the same coupon needs four millimetres of squeeze to get close, and at one millimetre it lets the object slide 21 mm. That ordering is the right way round: holding an object firmly with a modest squeeze is what a hard contact model is for.

Solver iterations on the driven coupon. Eight is already enough here, where the arm scene is still improving past twenty — a minimal test converges faster than the thing it stands in for.
Friction coefficient on every surface. FeatherPGS shows no measurable slip anywhere in this range.
Physics time stepFeatherPGSSolverMuJoCo

On this grasp FeatherPGS still holds the object at a 16.7 ms step — one physics step per control frame — with 1.6 mm of slip, while SolverMuJoCo loses it beyond about 2 ms.

One caveat. The SolverMuJoCo side of these coupons runs the coupon's own defaults, not tuned contact settings. The arm-scene comparison further up is the fair one. What this shows is how much of MuJoCo's grasp quality comes from that tuning, and how little FeatherPGS needs.

Static pads, triangle mesh

The cube never loses contact; it slides steadily down through the pads.

Static pads, primitive box

The same scene with only the collider swapped. It settles within a fraction of a millimetre and stays.

Static pads, squeezed 2 mm

Nothing is moving the pads. The object is thrown out of the gap by the position-correction term alone.

Driven pads

Close, lift off the support, shake. The same contact model, with a grip force that exists.

Issue 3

Friction: a biased anchor, a mismatched combination rule, and a better formulation you have to ask for.

Friction anchors trade a downward slide for an upward push

The friction anchor options stop the static coupon's mesh from sliding, and replace the slide with travel in the other direction:

Worst movement out of the starting position in each direction. Two anchors take the mesh's downward creep from 10.9 mm to 0.07 mm and add 33 mm of upward travel instead. The shared anchor does the same to the box at a smaller scale: 0.69 mm down becomes 0.05 mm down and 1.8 mm up.

"It no longer falls" is not the same as "it is held". Both options convert a downward slide into an upward push, which is what you would expect from a friction row carrying a positional bias rather than only a velocity target. A grasp that slowly extrudes the object upward out of the fingers will pass a drop test and fail everything else.

Medium  Two solvers in Newton combine friction differently

FeatherPGS forms the pair coefficient as the arithmetic mean of the two shape values. Newton's own MuJoCo path takes the element-wise maximum. Same model, same materials, different friction cone.

feather_pgs/kernels.py:3096  vs  mujoco/kernels.py:165
# FeatherPGS
mu = 0.0
mat_count = 0
if shape_a >= 0:
    mu += shape_material_mu[shape_a]
    mat_count += 1
if shape_b >= 0:
    mu += shape_material_mu[shape_b]
    mat_count += 1
if mat_count > 0:
    mu /= float(mat_count)

# SolverMuJoCo
resolved_friction = wp.max(geom_friction[worldid, g1], geom_friction[worldid, g2])

Medium  The best friction formulation is not the default

Three alternatives to the default lagged cone exist. Two are clearly better for a grasp: bisection cuts peak creep from 0.72 mm to 0.44 mm, and bisection with the de Saxée correction to 0.40 mm — a 44% improvement for about 1.4× the solve time, with no other change. The bracketed Newton variant is worse than the default at 0.96 mm. None can be selected unless pgs_mode="matrix_free", and all are excluded from the propagation contact path entirely.

The arm scene with the cube's friction coefficient lowered from 1.2, pads left at 1.0. Dropping it to 0.2 quadruples FeatherPGS's creep and leaves SolverMuJoCo completely unmoved — it takes the maximum, so the pads dominate and the edit is invisible to it. Both hold the cube at every value tested, so this is about margin, not failure.
Third defect, on by default. The two friction impulses of a contact pair are equal and opposite but applied at each body's own witness point, and those are separated along the normal by the penetration depth. The pair leaves a residual torque proportional to grip force times penetration — exactly the geometry of a pinch grasp. contact_friction_shared_anchor=True puts both at the midpoint and removes it. Its measured effect in the arm scene is small (0.82 mm to 0.80 mm) only because that grasp barely penetrates.
Issue 4

Extra velocity iterations make the grasp worse, because speculative contacts become hard walls.

pgs_velocity_iterations solves positions with a position-correction bias, then cleans up velocities without one. It should improve a grasp. It does the opposite, and the reason is one hard-coded argument.

A contact whose surfaces are still apart has a positive gap. The correct velocity-level constraint for it is "you may close at up to gap over dt", which is what the position pass builds. The velocity pass rebuilds every right-hand side with the speculative scale forced to zero:

newton/_src/solvers/feather_pgs/solver_feather_pgs.py:4779
self._stage4_compute_rhs_world(
    dt,
    bias_scale=0.0,
    contact_speculative_scale=0.0,          # <-- not a parameter
    joint_limit_speculative_scale=1.0,
    output=self.rhs_unbiased,
)
if self._has_free_rigid_bodies:
    self._compute_mf_rhs_bias(dt, bias_scale=0.0, speculative_scale=0.0,
                              output=self.mf_rhs_unbiased)

Dropping the position bias is correct and is the point of the pass. Dropping the speculative term is not: the constraint becomes "you may not approach at all", applied to pairs that are millimetres apart. Every one turns into an invisible wall for the duration of the velocity iterations.

The arm scene's collision gap is 5 mm per shape, so during the shake there are typically 14 touching contacts and 32 speculative ones. Shrinking the gap by 50× so almost no speculative contacts exist removes the regression and lets the pass do what it is supposed to:

ConfigurationSpeculative contacts livePeak creep
The fix is one argument. The velocity pass should keep contact_speculative_scale=1.0, matching what the position pass already does and what the constraint means. As written, the option cannot be used on any scene with a non-trivial collision gap — which is the default configuration of every Newton scene.
Performance

How it compares to SolverMuJoCo: same accuracy for more compute, and far more headroom on the step.

What the grasp looks like when it works

The measurement throughout is the object's position in the gripper's own frame, referenced to the start of the shake. That removes the commanded motion and leaves only relative movement — slip, squeeze and rattle.

Total movement of the cube inside the gripper over the 11.5 s shake. Extending the corrected run to 2000 frames — just under thirty seconds of shaking — gives 1.16 mm, so the residual creep is not a slow leak that eventually loses the object.

SolverMuJoCo, tuned contacts

The reference, with the gravity term supplied by the scene. Peak creep 0.31 mm.

FeatherPGS, no gravity term

The arm sags below the IK target and the phase gate never opens, so the grasp is never attempted.

FeatherPGS, stiffness instead

Stiff enough to reach the pose, but the cube works its way out of the over-stiff pads and falls 3.65 s into the shake.

FeatherPGS, gravity term supplied

Original gains, contact settings at defaults. Peak creep 0.82 mm across the full shake, 0.31 mm at twenty iterations.

Contact points between the pads and the cube during the shake. The corrected run keeps eighteen to twenty-two throughout; the 100× run loses contact repeatedly and then permanently.

The control loop stays at 60 Hz; only the number of physics substeps changes. Speed is measured with the physics loop inside a captured CUDA graph so it reflects the solver rather than Python.

The two solvers converge as the step grows. Both fail the arm scene at 16.7 ms, but that limit belongs to the arm and the controller rather than the contact model — with the arm removed, FeatherPGS still holds the object at 16.7 ms while SolverMuJoCo loses it past 2 ms.
Speed for the same runs, one environment. Both are dominated by fixed per-substep cost: roughly 0.91 ms for FeatherPGS and 0.32 ms for SolverMuJoCo, nearly independent of substep count.
Time stepFeatherPGS creepSolverMuJoCo creepFeatherPGS per frameSolverMuJoCo per frame

Two things fall out. FeatherPGS starts 2.6× behind on creep at the smallest step and the two converge as the step grows — at 8.3 ms they are within 5% of each other. And the cost ratio barely moves: SolverMuJoCo is 2.7–2.8× cheaper per frame at every step, because a flat per-substep cost across a 16× change in substep count means both are latency-bound rather than compute-bound at one environment. A single 9-DOF arm produces tens of kernel launches per substep, almost all with grids of one to thirty-two threads. The Cholesky factorisation of the 9×9 mass matrix runs on one threadsmall_dof_threshold=12 selects the serial kernel below thirteen degrees of freedom — and the Gauss-Seidel solve is launched as one tile of thirty-two threads per world, in both solve modes. There is nothing wrong with that at scale; it is the wrong shape for one arm.

Three costs are avoidable at any scale:

newton/_src/solvers/feather_pgs/solver_feather_pgs.py:5046 — ungated, every step
self._stage3_compute_v_hat(state_in, state_aug, dt)
self._clamp_rigid_velocity_limits(self.v_hat)
wp.copy(self._debug_stage3_qd_work, self.qd_work)
wp.copy(self._debug_stage3_joint_qdd, state_aug.joint_qdd)
wp.copy(self._debug_stage3_v_hat, self.v_hat)
The trade, plainly. On this single-environment scene SolverMuJoCo wins on wall clock at every operating point — at an 8.3 ms step it holds the cube to 3.29 mm at 23× realtime against 3.43 mm at 8.5×. What FeatherPGS offers instead is a contact model that survives a far larger step before the grasp fails, and reaches those numbers without contact tuning. Both of those matter more once the arm is not the bottleneck and once many environments are running at once, which is the regime this measurement cannot speak to.

Where the time is best spent

There are three ways to spend more time on the same second of simulation: smaller substeps, more solver iterations per substep, or — on an unmerged branch — frozen inner substeps that reuse the mass-matrix factorisation and the contact Jacobians.

What each knob buys per millisecond. Down and to the left is better. Adding iterations and adding substeps land on roughly the same frontier; the frozen inner substeps sit above it, and the points marked with a cross produced no usable grasp.
Solver iterationsGraspPer frameSpeed

Iterations are the cleanest lever, and they are what closes the accuracy gap: twenty iterations reaches 0.31 mm, exactly SolverMuJoCo's number on this scene, and thirty-two beats it at 0.22 mm. The price is 20.9 ms per frame against SolverMuJoCo's 5.2 ms — four times the compute to match. Past thirty-two there is nothing left to buy. The wrinkle is that convergence is not monotone — eight iterations is worse than four and much worse than twelve. Under-converged Gauss-Seidel does not give a slightly softer grasp; it gives a different one. That is an argument for running enough iterations to be past the noisy region rather than for tuning the number.

The frozen inner substeps do not help here

Solver × inner substepsEffective time stepGraspPer frameSpeed

Two solver substeps with no inner loop gives 3.43 mm at 1.95 ms. Adding four frozen inner substeps — a four-times-smaller integration step — gives 3.51 mm at 5.82 ms. Refining the integration changed nothing, because the error is not in the integration; it is in the contact basis, which the frozen loop deliberately does not refresh. At the same effective step size, plain substepping gives 1.30 mm.

It does earn its keep rescuing a step too large to work at all: at one solver substep the plain solve drops the cube, and four frozen inner substeps turn that into a 7.47 mm hold at 5.9× realtime. But the behaviour is not monotone — eight loses the grasp and sixteen drops the cube again — which is what you would expect when positions are advanced far from where the Jacobians were built. As a speed lever for grasping it needs the contact basis refreshed on some cadence, not frozen for the whole step.

Issue 5

The default execution settings cannot be captured into a CUDA graph.

Every speed number here depends on capturing the 16-substep physics loop once and replaying it. With the constructor defaults, that capture fails.

use_parallel_streamsdouble_bufferCapture of a 16-substep frame

Only double_buffer is responsible, and the reason is structural. The double-buffer memset forks onto a private stream and never joins back within the same step:

newton/_src/solvers/feather_pgs/solver_feather_pgs.py:5530
if self._memset_stream is not None:
    with wp.ScopedTimer("DB_Memset", ...):
        with wp.ScopedStream(self._memset_stream):        # forks the capture
            for size in self.size_groups:
                if self._mass_update_global_flag:
                    self._H_bufs[self._buf_idx][size].zero_()
                self._J_bufs[self._buf_idx][size].zero_()
        self._memset_done_event[self._buf_idx] = self._memset_stream.record_event()
        self._buf_idx = 1 - self._buf_idx

wp.ScopedStream synchronises on entry but not on exit. The only join is the wait_event at the top of the next step, so the final step of any captured region leaves an unjoined fork and capture fails. On the first captured step the event being waited on was also recorded outside the capture, which is separately illegal.

A method exists that would fix the second half of this, seed_double_buffer_events(). It has zero callers anywhere in the repository, and its docstring says it must be called inside graph capture, which is the opposite of what would work. No test in the repository captures a FeatherPGS step(), which is why this survived.

Practical effect. Pass double_buffer=False. On a 9-DOF arm the buffers involved are about 1.5 KB, so the memset it hides costs nothing; the extra stream, the per-step event allocation and the extra dependency edges cost strictly more. A capture guard using the existing device.is_capturing would let the default stay on for the uncaptured case.
Remaining gaps

Smaller things that will each cost somebody an afternoon

Medium  A documented friction parameter does nothing

friction_smoothing is accepted by the constructor, documented as the Huber-norm delta for friction velocity normalisation, and stored on the instance. No kernel reads it, and norm_huber appears nowhere in the package. It is live in the sibling Featherstone and semi-implicit solvers, which is where it was copied from. Setting it is a silent no-op.

Medium  The regularisation term is not compliance

pgs_cfm is added to the Gauss-Seidel denominator but not to the residual the sweep drives to zero, so it changes how fast the sweep converges rather than what it converges to. Sweeping it across four orders of magnitude moves peak creep from 0.720 mm to 0.788 mm. If you were expecting it to soften the contact, it will not.

Medium  Joint limits are silently wrong with two of the four solve kernels

The docstring states that enable_joint_limits is incompatible with pgs_kernel="tiled_contact" and "streaming". Nothing enforces it. Those kernels never receive the row-type or row-parent arrays, so limit rows are projected as if they were contacts. This is wrong physics, not a performance downgrade, and it happens without any message.

Low  Model data the solver ignores

None of joint_damping, joint_friction, or joint_target_mode appears anywhere in the package — zero matches across all three files. SolverMuJoCo reads all three. Passive joint damping vanishes, joint dry friction vanishes, and every DOF with a non-zero gain gets both a position and a velocity term regardless of the drive mode the model declares.

Low  Depenetration is unbounded on the path a gripper uses

The matrix-free and propagation paths clamp the position-correction push with rigid_body_max_depenetration_velocity. The dense path — where articulated contacts land under the default articulated_contact_response="immediate" — does not take that array as an argument at all. A bad initial grasp pose gets an unbounded correction.

Low  Current control targets are not supported

FeatherPGS reads the deprecated DOF-layout aliases control.joint_target_pos and joint_target_vel. Under Newton's coordinate-layout targets those properties raise, so step() fails outright; under the older layout they emit a deprecation warning on every step. Any scene using the newer layout has to turn it off.

What to fix

In order of how much grasp stability each one buys

Gravity compensation is deliberately not on this list. A position-controlled arm needs one, and FeatherPGS has none — but neither does any other Newton solver, so it belongs to the platform rather than to this solver. Supply it however you like; everything below is what remains once you have.

Raise the default row budget and make overflow loud

Thirty-two rows is ten contacts; a two-finger grasp over a table already needs seventy. Size the default from the model's contact capacity, and warn once when a contact is dropped. Silent contact deletion is the failure mode most likely to be mistaken for a friction problem.

Give hard contacts a way to express a squeeze

Two opposed contacts on a rigid object have no force balance to converge to, so the position-correction term becomes the only thing setting the answer — and it throws the object out in proportion to the overlap. dense_contact_compliance already provides a compliance term for the row family a gripper's contacts land on; the free-body family has none, and its diagonal carries pgs_cfm and nothing else. Until that is filled in, a hard-contact grasp has to be driven, never wedged.

Make the better friction formulation reachable, and reconcile the combination rule

The de Saxée bisection cone halves the creep of the default for about 1.4× the solve time, but only exists on one solve mode and not on the propagation path. Separately, FeatherPGS averages the two friction coefficients while Newton's own MuJoCo path takes their maximum — two solvers in one library disagreeing about what a material means. Default the shared friction anchor on while you are there; the torque couple it removes is specific to pinch grasps.

Keep the speculative term in the velocity pass

One hard-coded contact_speculative_scale=0.0 turns every not-yet-touching contact into a wall and makes pgs_velocity_iterations a net negative on any scene with a normal collision gap.

Move the tiled solve's staging to dynamic shared memory

A quadratic static allocation puts the row budget at the mercy of the device: 150 rows under CUDA's default 48 KiB static limit, roughly 220 on a device that allows 99 KiB. Until it moves, validate dense_max_constraints against the device's actual limit at construction and say so, rather than failing as a module-load error inside the first step. The solver already has an estimator that does exactly this for a different kernel.

Guard the double-buffer fork against graph capture

Use the existing device.is_capturing to fall back to an inline memset, or default double_buffer to off. Add a test that captures step() — there is currently none, which is why this has gone unnoticed.

Cut the fixed per-substep cost for small models

At one environment a 9-DOF arm costs 0.91 ms per substep against SolverMuJoCo's 0.32 ms, and the ratio holds across a 16× change in substep count — it is launch latency, not arithmetic. A 9×9 Cholesky on one thread and the whole solve on one warp are the right shapes at scale and the wrong ones here. Fusing the small-model path would make FeatherPGS competitive on single-scene work as well as on batched work.

Remove the ungated per-step debug copies and the dead parameters

Three wp.copy calls run on every step for buffers nothing reads. friction_smoothing is documented and inert. Both cost trust more than they cost time.

Port to the current control-target layout

Reading the deprecated DOF-layout aliases makes FeatherPGS unusable with Newton's current default and noisy with the old one.

Reproduction

Pinned versions and the harness

Environment

  • Linux, NVIDIA RTX PRO 6000 Blackwell Server Edition, one MIG 1g.24gb slice, compute capability 12.0
  • Python 3.12, Newton 1.5.0.dev0, Warp 1.16.0.dev20260716
  • FeatherPGS: dylanturpin/newton-collab branch fpgs-main at d95efe1d — 111 commits ahead of and 119 behind newton-physics/newton@main. PR #2608 has not moved since 8 July 2026 and is blocked, so there is no upstream FeatherPGS that runs these scenes.
  • Arm scene: StoneT2000/newton-tests at 32c1544b, franka_cube_shake.py, unmodified. A fixed FR3, analytic IK, a scripted approach→descend→grasp→lift→shake sequence at 60 Hz with 16 substeps. Its SolverMuJoCo settings come from the grasp-shake-drift investigation and are left as they are; the only thing changed here is the solver.

Harness

setup
git clone --branch fpgs-main https://github.com/dylanturpin/newton-collab.git
cd newton-collab && git checkout d95efe1d1bb07497a4e51bf9563f36bc8821ab73
uv venv .venv --python 3.12 && uv sync --extra examples

git clone https://github.com/StoneT2000/newton-tests.git
cd newton-tests && git checkout 32c1544bac713bb722e9f805c26dc99413f0e359
the one change that matters
from harness import make_env
from gravcomp import GravityCompensator

env  = make_env("fpgs")                 # original gains, nothing else changed
comp = GravityCompensator(env)

for frame in range(900):
    comp.apply()                        # writes tau_g into control.joint_f
    env.step()