Skip to content

fix(springbone): segment per-substep host writes into per-substep slots (#396) - #398

Merged
arkavo-com merged 4 commits into
mainfrom
issue/396-substep-buffer-segmentation
Aug 7, 2026
Merged

fix(springbone): segment per-substep host writes into per-substep slots (#396)#398
arkavo-com merged 4 commits into
mainfrom
issue/396-substep-buffer-segmentation

Conversation

@arkavo-com

@arkavo-com arkavo-com commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Closes #396.

The substep loop rewrites globalParams, bindDirections and the collider buffers from the host on every substep, but only animatedRootPositions was segmented by substep index (the #278 fix). The other three were single regions every dispatch read at offset 0, which failed differently on each command-buffer path:

warmupPhysics already documents this exact race and pays a full GPU drain per step to dodge it; the runtime loop cannot afford that, so it segments instead.

Each buffer now carries maxSubstepsPerFrame slots at 256-byte aligned strides (the kernels bind them in the constant address space), written and bound at substepIndex * stride. Writers that are not per-substep — the load-time uploads and the once-per-frame foreign collider tail — broadcast to every slot, so a substep never reads a slot the current frame did not populate.

Verification

The #394 determinism test is green 3/3 over full parallel runs that failed 2/3 on the same machine before.

Both new tests were confirmed red-then-green by reverting the fix underneath them. Worth noting the first version of the content test was vacuous — it passed against a single-region build because a slot left holding a stale broadcast also merely differs from its neighbour. It now pins the actual interpolant per slot.

Baseline drift scales with substeps per frame, which is the signature this change should have and is independent evidence it is not perturbing something unrelated:

tier substeps at dt=1/60 max drift
ultra (120 Hz) 2 3.85 mm
high (90 Hz) ~1.5 2.13 mm
medium (60 Hz) ~1 0.94 mm
low (30 Hz) <= 1 unchanged

Only ultra and high exceeded the 1 mm gate. Regenerated in b9d1746. Sanity render of AvatarSample_U is unchanged.

Behaviour change

Per-substep collider and bind-direction interpolation now actually reaches the GPU, so early substeps see geometry partway through the frame instead of fully advanced.

SpringBoneRotationTests read bindDirections at offset 0 and now index the last substep's slot, which is where the frame's final direction lives. Those failures were a stale helper, not a regression — (0.707, -0.707, 0) is exactly the t=0.5 interpolant for a 90 degrees rotation with 2 substeps.

Arm-swing guard re-baselined (#397)

testU_armSwing_velocityCorrection_reducesReentryFrames moved from a 13/180 floor to 19/180, so its bound goes 16 -> 19 in this PR. Investigation on #397 established that this is a real shift in that configuration rather than a slipped guard:

Both configurations are also now bit-deterministic run to run. The jitter that guard's docs attribute to "Metal compute reductions / fast-math" was substantially the #394 race.

Full parallel suite is green 3/3 at --num-workers 14.

Scope note

globalParams is segmented for uniformity but was not contributing to the divergence: every substep writes byte-identical params today, because var params = globalParams copies a stale let and neither windPhase nor settlingFrames is written back to it. That staleness is a separate latent bug (wind phase never advances) and is left alone here.

#380 rewrites much of this area and does not fix this — a grep across its full diff for the interpolation and wait symbols returns zero touched lines — so expect a conflict if it lands first.

🤖 Generated with Claude Code

Comment thread Sources/VRMMetalKit/SpringBoneComputeSystem.swift
Closes #396.

The substep loop rewrites globalParams, bindDirections and the collider
buffers from the host on every substep, but only animatedRootPositions
was segmented by substep index (the #278 fix). The other three were
single regions every dispatch read at offset 0, which failed differently
on each command-buffer path.

On the self-committed path (synchronousSpringBone) each substep is
committed immediately and nothing waits, so substep i+1's host writes
landed while substep i was still reading them — the run-to-run
divergence tracked as #394. On the shared-buffer path every substep is
encoded before the caller commits, so no dispatch had executed by the
time the last write landed and every substep read the final substep's
values: deterministic, silent, and a different simulation. warmupPhysics
already documents this exact race and pays a full GPU drain per step to
dodge it; the runtime loop cannot afford that, so it segments instead.

Each of the three buffers now carries maxSubstepsPerFrame slots at
256-byte aligned strides (the kernels bind them in the constant address
space), written and bound at substepIndex * stride. Writers that are not
per-substep — the load-time uploads and the once-per-frame foreign
collider tail — broadcast to every slot so a substep never reads a slot
the current frame did not populate.

globalParams is segmented for uniformity but was not contributing to the
divergence: every substep writes byte-identical params today, because
`var params = globalParams` copies a stale `let` and neither windPhase
nor settlingFrames is written back to it. That staleness is a separate
latent bug, left alone here.

Behaviour change: per-substep collider and bind-direction interpolation
now actually reaches the GPU, so early substeps see the collider partway
through the frame instead of fully advanced. SpringBoneRotationTests read
bindDirections at offset 0 and now index the last substep's slot, which
is where the frame's final direction lives.

Verified: the #394 determinism test is green 3/3 over full parallel runs
that failed 2/3 before.
The #396 fix makes per-substep collider and bind-direction interpolation
actually reach the GPU, so early substeps now see geometry partway
through the frame instead of fully advanced. The frozen trajectories
shift accordingly.

Drift scales with substeps per frame, which is the signature this change
should have:

  ultra  (120Hz, 2 substeps)   3.85 mm  J_Sec_Hair4_04_end z
  high   (90Hz)                2.13 mm  J_Sec_Hair4_07_end z
  medium (60Hz, ~1 substep)    0.94 mm  J_Sec_Hair4_04_end z
  low    (30Hz, <=1 substep)   unchanged

Only ultra and high exceeded the 1 mm gate; medium is regenerated because
it did move, and low produced no diff at all. Largest change is under
4 mm on a hair tip.

Regenerated with VRM162_REGENERATE_BASELINE=1.
@arkavo-com
arkavo-com force-pushed the issue/396-substep-buffer-segmentation branch from b9d1746 to be399d0 Compare August 7, 2026 16:30
The #396 segmentation moves this metric, so its calibration lands with
it rather than leaving the PR red for a follow-up.

Before segmentation the floor was 13/180; it is now 19/180. That is a
real shift in this configuration, not a slipped guard:

- The velocity correction still engages. Stubbing applyVelocityCorrection
  to an early return on post-#396 code gives 23/180 against 19/180 with
  it intact — a 4-frame benefit, versus 5 frames before.
- This test runs augment:false, so no synthetic colliders and no swept
  CCD. It is the coarse-collider repro configuration, not the one
  #309/#313 shipped. On the augmented path the same change took re-entry
  from 24-27 frames to a flat 9, peak unchanged.
- The pre-#396 collapse had every substep read the collider at its
  end-of-frame position, which during a fast swing put it ahead of its
  true mid-frame location and caught cloth early — an accidental, crude
  CCD. Correct per-substep interpolation removes that, exposing the
  ordinary discrete tunneling the synthetic colliders exist to fix.

The doc block also carried three claims the measurements contradict: the
13/180 floor, the "flickers 13<->18" jitter, and "floor >=17 means the
bleed stopped engaging". Segmenting removed the #394 race and the metric
is now bit-identical run to run, so those are corrected and the evidence
recorded in place. The min-over-3 is kept — it costs little and the
determinism claim is measured serially.

Verified: full parallel suite green 3/3 at --num-workers 14.
Review follow-up on #398 (gitar-bot).

writeForeignTail broadcast the foreign collider tail into all 10 substep
slots every frame, but the substep loop only ever dispatches
[0, frameSubstepCount). Since the tail is rewritten every frame, slots
above that are never read before being overwritten, so the extra writes
were pure waste — up to 10x the per-frame tail memcpy in crowd and
external-collider scenes.

Bounded to max(1, frameSubstepCount). This adds no new assumption: the
substep loop's own `t = (currentSubstepIndex + 1) / frameSubstepCount`
already depends on the dispatched index staying below frameSubstepCount.
Verified rather than assumed — a precondition asserting
`currentSubstepIdx < frameSubstepCount` never tripped across a full
parallel suite run.

The load-time updateBindDirections / updateSphereColliders /
updateCapsuleColliders broadcasts still fill every slot; those run once,
not per frame.

Verified: full parallel suite green 2/2 at --num-workers 14.
@gitar-bot

gitar-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown
Code Review ✅ Approved 1 resolved / 1 findings

Segments springbone per-substep host writes into dedicated slots to eliminate race conditions, addressing the foreign tail broadcast inefficiency. No issues found.

✅ 1 resolved
Performance: Foreign tail broadcast to all 10 slots every frame is wasteful

📄 Sources/VRMMetalKit/SpringBoneComputeSystem.swift:427-441 📄 Sources/VRMMetalKit/SpringBoneComputeSystem.swift:629
writeForeignTail runs once per frame and now loops over all substepSlots (10) writing the foreign collider tail into every slot, but only slots [0, frameSubstepCount) are ever dispatched this frame — and since it is re-run each frame, unread higher slots never matter. In crowd/external-collider scenes this multiplies the per-frame tail memcpy by up to 10x versus the single write it replaced. frameSubstepCount is already computed (line 535) before this call (line 629), so the loop can be bounded to the slots that will actually be read. Load-time updateBindDirections/updateSphereColliders/updateCapsuleColliders broadcasts are one-time and fine.

Was this helpful? React with 👍 / 👎 | Gitar

@arkavo-com
arkavo-com merged commit 8d87fd5 into main Aug 7, 2026
12 checks passed
@arkavo-com
arkavo-com deleted the issue/396-substep-buffer-segmentation branch August 7, 2026 22:52
arkavo-com added a commit that referenced this pull request Aug 23, 2026
The baseline was captured on 1.1.0-beta.1, before #398 (per-substep host
write segmentation) and #420 moved spring-bone trajectories on main. 1322
of 3059 samples differ, all of it main's motion, none of it this branch's.

Verified rather than assumed: captured the same 90-frame armsCrossed
sequence from a worktree at origin/main with the flag plumbing removed,
and this branch's flag-off capture matches it bit for bit, 3059/3059. The
invariant the oracle exists to protect — opt-in off changes nothing — now
holds against 1.1.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pj1CapHF8WkgztWwcB6Ls
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.

SpringBone: per-substep host writes to unsegmented shared buffers race in-flight GPU reads (root cause of #394)

1 participant