Commit Graph

3108 Commits

Author SHA1 Message Date
jgrusewski
0c4a9a70bc perf: migrate Q-stats readback to consolidated pinned buffer
Extend readback_pinned from 3 to 16 floats and write Q-stats DtoH
into slots [3..8] instead of the non-pinned q_stats_pending field.
On H100 CUDA 13 driver, non-pinned host memory causes cuMemcpyDtoHAsync
to degrade to a synchronous copy, creating a latent hang risk.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:31:14 +02:00
jgrusewski
a40a374efa cleanup: remove dead GpuTrainResult, cast helpers, stale annotations
GpuTrainResult was replaced by FusedStepResult (CPU-only f32 scalars).
F32ToU32Caster, get_cast_kernels_f32_to_u32, and f32_slice_to_gpu_tensor_gpu
were only used by the old pre-fused training path. Also removes
#[allow(dead_code)] from gather_f32 which is actively used.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:23:39 +02:00
jgrusewski
f419d941c2 fix: route PER updates through seg_tree_update kernel
Replace the orphaned per_update_kernel call site in fused_training.rs
with agent.update_priorities_from_td() which routes through
GpuReplayBuffer::update_priorities_gpu_raw -> seg_tree_update.
This kernel correctly writes priorities AND propagates the segment tree
from leaf to root, fixing stale PER sampling.

Add update_priorities_from_td() on ReplayBufferType and DqnAgentConfig.
Remove priorities_f32_ptr() which exposed raw pointers for the deleted
kernel. Remove batch_max epoch-boundary flush since seg_tree_update
propagates max_priority internally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:13:18 +02:00
jgrusewski
07bcf4834d refactor: delete orphaned per_update_kernel and batch_max_buf
The per_update_priorities_kernel scatter-wrote to the priorities[] buffer
but never updated the PER segment tree, leaving sampling priorities stale.
Remove the orphaned CUDA kernel, its cubin include, struct field,
compilation, and the update_priorities_cuda_raw / batch_max_readback_and_reset
methods from GpuDqnTrainer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:13:02 +02:00
jgrusewski
05a9c597ee fix: eliminate HtoD sync-stall hang in fused training result
GpuTrainResult::from_fused_scalars() allocated GPU memory and performed
two synchronous cuMemcpyHtoD copies after every training step. On H100
CUDA 13 driver, synchronous HtoD blocks until ALL pending async GPU work
completes — same class of bug as the DtoH pinned memory fix.

The uploaded scalars were never used: the training guard already reads
loss/grad_norm directly from GPU-resident buffers via loss_gpu_buf() /
grad_norm_gpu_buf(). Replace GpuTrainResult with lightweight
FusedStepResult (CPU-only f32 scalars, zero GPU ops).

Also adds H100_HANG4 diagnostic eprintln between PER update,
bookkeeping, varmap sync, and graph_aux capture to isolate if any
secondary hang exists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 01:41:43 +02:00
jgrusewski
d98ec8d343 fix: pinned host memory for async DtoH readback
cuMemcpyDtoHAsync with non-pinned host pointers blocks the CPU on
CUDA 13 / H100 driver 580+ until ALL pending GPU work completes.
With graph_forward + aux_ops + adam queued, this caused multi-minute
hangs between training steps.

Fix: allocate 12 bytes of pinned (page-locked) host memory via
cuMemHostAlloc(DEVICEMAP) for the 3 scalar readbacks (loss, mse_loss,
grad_norm). Pinned memory enables true async DtoH — CPU returns
immediately, GPU copies when it reaches that point in the stream.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 01:16:12 +02:00
jgrusewski
f1009517bd fix: sync DtoH readback — non-pinned cuMemcpyDtoHAsync blocks on H100
cuMemcpyDtoHAsync_v2 with non-pinned host pointer degrades to
synchronous copy on CUDA 13 / H100 driver 580+, blocking until
ALL pending GPU work completes. With graph_forward + aux_ops + adam
queued, this caused a multi-minute hang.

Fix: explicit cuStreamSynchronize + synchronous cuMemcpyDtoH.
2026-04-06 01:09:28 +02:00
jgrusewski
f3c110dfbd diag: all adam_readback logs via eprintln 2026-04-06 01:00:51 +02:00
jgrusewski
1ba5ce93e7 diag: all H100_STEP logs via eprintln (unbuffered) 2026-04-06 00:52:19 +02:00
jgrusewski
735194c25d diag: guard + loop completion eprintln 2026-04-06 00:42:57 +02:00
jgrusewski
06172a8919 diag: eprintln per-step in training loop 2026-04-06 00:34:17 +02:00
jgrusewski
dde8adbd9c diag: per-line logging inside replay_adam_and_readback 2026-04-06 00:16:52 +02:00
jgrusewski
e2da8255e1 diag: H100_STEP tracing in run_full_step phases 2026-04-06 00:00:41 +02:00
jgrusewski
cedff67331 diag: replay_forward + PER insert progress logging for H100 hang 2026-04-05 23:47:02 +02:00
jgrusewski
c90ccc100e chore: gitignore hyperopt_results + remove from tracking 2026-04-05 23:33:11 +02:00
jgrusewski
ce665b2e7a diag: experience collection progress logging at 0/50/100%
Stream sync + log at timestep 0, 50%, and 100% to identify which
phase of experience collection hangs on H100 with 4096 episodes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:33:01 +02:00
jgrusewski
e08d304aa8 chore: remove dead code — raw_f32_ptr, ManuallyDrop, assert_finite_f32, load_smoke_data
Zero dead functions, zero unused imports, zero CPU fallback paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:15:38 +02:00
jgrusewski
5f16a86a4a chore: remove dead build_next_states_dtod + dtod_clone_bf16
Both were unused (bf16 path replaced by f32 path in #30).
build_next_states_dtod had the same CPU loop anti-pattern (795K memcpy
calls at 4096 episodes) that caused the H100 hang.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:10:33 +02:00
jgrusewski
e00203b9a0 fix: GPU kernel replaces 795K CPU memcpy calls in build_next_states
Root cause of H100 training hang: build_next_states_f32() used a CPU
for-loop launching individual memcpy_dtod per episode per fill timestep.
At gpu_n_episodes=4096 × 2 counterfactual × 97 fill steps = 795K CUDA
API calls that saturated the driver command queue.

Replaced with single build_next_states_kernel launch — all episodes
processed in parallel, zero CPU loops. One kernel, done.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:07:42 +02:00
jgrusewski
5ea1db7e37 fix: suppress unsafe_code warnings on GPU regime kernel launches
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:39:49 +02:00
jgrusewski
77bc8bda95 fix: GPU regime stratification active in walk-forward training
Moved init_from_fxcache BEFORE fold generation so GPU features buffer
exists when generate_folds_stratified_gpu is called. Features uploaded
as temporary f32 for regime classification (freed after fold gen).

Removes TODO — GPU prefix sum path is now the default when CUDA available.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:31:49 +02:00
jgrusewski
69aca5ad75 feat: GPU-accelerated regime-stratified fold generation
generate_folds_stratified_gpu uses GPU prefix sums for O(1) range
queries during boundary adjustment. 1.1M bars: ~3ms vs ~1000ms CPU.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:19:27 +02:00
jgrusewski
f5021f38c3 feat: GPU regime classification + prefix sum kernels
regime_classify_kernel: parallel classification of 1.1M bars
regime_prefix_sum_kernel: cumulative counts for O(1) range queries
classify_regimes_gpu(): Rust wrapper that runs both and returns host prefix sums

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:10:20 +02:00
jgrusewski
c1e1cd47f7 fix: dual-stream CUDA graph capture eliminates H100 hang
Split graph_forward into graph_forward_main (Pass 1+2+loss+backward
on main stream) and graph_forward_ddqn (Pass 3 on double_dqn_stream).

Event synchronization happens in ungraphed replay_forward() code,
not inside captured functions. Each graph uses its own CublasForward
handle — no set_stream() during capture.

Fixes: H100 training hang with gpu_n_episodes=4096.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:04:29 +02:00
jgrusewski
f59c615687 refactor: split forward ops into per-stream functions
submit_forward_ops_main: Pass 1 + 2 + loss + grad + backward (main stream)
submit_forward_ops_ddqn: Pass 3 Double DQN (double_dqn_stream)

No set_stream() or event ops inside either function.
Graph capture will record each on its own stream.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 21:58:31 +02:00
jgrusewski
d5e323caf9 feat: separate CublasForward for double_dqn_stream
Each CUDA graph needs its own cuBLAS handle bound to its stream.
Eliminates set_stream() calls during graph capture that caused
the H100 training hang.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 21:54:11 +02:00
jgrusewski
f2b777a6ff fix: train_walk_forward uses regime-stratified fold generation
Was calling generate_folds() (non-stratified) instead of
generate_folds_stratified(). Walk-forward folds are now balanced
across trending/ranging/volatile regimes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 21:51:13 +02:00
jgrusewski
4956de7c74 docs: implementation plan for GPU regime + H100 hang fix
7 tasks: fold bug fix, dual cuBLAS, split forward ops, dual-graph
capture, GPU regime kernels, GPU stratification, H100 validation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 21:45:51 +02:00
jgrusewski
42c8d85bba docs: spec for GPU regime + H100 training hang fix
Three issues in one spec:
1. H100 hang: cuBLAS set_stream() inside CUDA graph capture → deadlock
2. Bug: train_walk_forward calls non-stratified generate_folds()
3. GPU regime: classification kernel + prefix sum for O(1) range queries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 21:25:05 +02:00
jgrusewski
6e4778fad9 fix: update test expectations for production batch_size + OFI state_dim
- Profile tests: batch_size 1024→8192, mbp10_data_dir /mnt→/data
- Trainer tests: state_dim adapts to OFI config (45 without, 53 with)
- Add batch_size>0 validation in trainer constructor
- 891 unit tests pass, 0 failures

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:14:03 +02:00
jgrusewski
82983f356f config: explicit batch_size=8192 + max_bars=0 in production TOML
All training parameters now explicit in the TOML — no implicit defaults.
- batch_size=8192: maximizes H100 tensor core utilization
- max_bars=0: unlimited (train on full dataset)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:38:49 +02:00
jgrusewski
45e9855817 fix: production TOML data paths match Argo PVC mounts
- mbp10_data_dir: /mnt/training-data → /data/futures-baseline-mbp10
- Add missing trades_data_dir: /data/futures-baseline-trades
- Paths now match Argo workflow PVC mount at /data (CLI overrides still work)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:34:21 +02:00
jgrusewski
6e03a5346e fix: hyperopt preload respects max_bars + deduplicate profile loading
- Preloaded fxcache truncated to max_bars from training profile
  (1.1M → 5000 bars for smoketest, ~40x faster per trial)
- Consolidated 3 redundant DqnTrainingProfile::load() calls into 2
  (VRAM gate reuses base_hp in train_with_params)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:19:15 +02:00
jgrusewski
1abdbb6388 test: hyperopt backtest metrics output in smoketest
Sharpe=-1.27, 269 trades, 22% win rate — expected for 3 random epochs.
Full PSO → train → GPU backtest → Sharpe objective pipeline validated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:13:17 +02:00
jgrusewski
95db8ef13d fix: VRAM gate uses training profile + backtest metrics working
- Load TOML profile BEFORE VRAM budget gate so hidden_dim/batch_size
  reflect the actual profile (smoketest=64 vs production=256)
- Fixes backtest_metrics=None: VRAM gate was pruning all trials using
  conservative() defaults (hidden=256, batch=1024) on RTX 3050
- Add test_hyperopt_trial_produces_backtest_metrics regression test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:04:51 +02:00
jgrusewski
77e08e6624 feat: configurable training profile for hyperopt + hyperopt smoketest
- Add training_profile field to DQNTrainer (default: "dqn-production")
- Smoketest uses "dqn-smoketest" profile for RTX 3050 compatible sizes
- DQNTrainer fields (data_source, mbp10_data_dir, trades_data_dir) are
  source of truth — TOML profile provides training params only
- Fix relative path resolution in preload_data for mbp10/trades dirs
- Add preload_data() call in hyperopt test (fxcache shared via Arc)
- Buffer size floor of 1024 in hyperopt param application

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:27:40 +02:00
jgrusewski
5f5ebd32bc fix: fxcache key match with MBP-10 + trades data paths
- Add mbp10_data_dir and trades_data_dir to smoketest TOML profile
- Fix data_loading path resolution: resolve relative paths against
  workspace root via CARGO_MANIFEST_DIR (CWD is crates/ml during tests)
- Add buffer_size floor of 1024 in hyperopt adapter (GPU PER minimum)
- Regenerated fxcache with full OFI features (MBP-10 + trades)

All tests now consistently hit fxcache with OFI=true.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:35:26 +02:00
jgrusewski
b7ea7c0760 chore: remove runtime NaN tracing stream syncs
Removes run_nan_tracing (3 stream syncs per step on first 20 steps)
and unused grad_buf_ptr/params_buf_ptr accessors. Root causes are
fixed — runtime detection no longer needed. isfinite guards in CUDA
kernels remain as silent defense-in-depth.

17/17 smoke tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:33:36 +02:00
jgrusewski
a5eeeaa477 fix: smoketest max_bars 60K→5K + counterfactual test threshold
- Reduce max_bars from 60000 to 5000 (smoketests ran 16min, now ~1min)
- Fix test_counterfactual_experiences_in_buffer: compute threshold from
  actual config instead of hardcoded 4000, enlarge buffer to avoid wrap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:51:21 +02:00
jgrusewski
24bae7d101 fix: test_data_dir returns base dir for fxcache key match
test_data_dir() was returning the symbol-specific path
(test_data/futures-baseline/ES.FUT) causing fxcache key mismatch —
the cache was created with the base dir. Now returns the parent
directory so trainer.train(data_dir, "ES.FUT", ...) can discover
the fxcache via matching cache key.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:49:01 +02:00
jgrusewski
c9191e7c58 chore: remove unused NaN diagnostic accessor methods
Removes cql_grad_scratch_ptr, iqn_trunk_m_ptr, save_h_s1_ptr,
stream_cu — added during NaN investigation, no longer called.

Retains run_nan_tracing (lightweight GPU-side checks on first 20 steps)
and all isfinite guards as production safety nets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:40:12 +02:00
jgrusewski
7b6e676952 fix: HER f32/bf16 type mismatch + stride bug — final NaN root cause
Root cause #8: HER in-place relabel kernel read f32 PER states as
`const __nv_bfloat16*`, reinterpreting raw f32 bytes as garbage bf16
values (up to ~1e38). These were written into padded states_buf at HER
rows, corrupting the data read by the IQN trunk backward GEMM.

Root cause #9: HER dst_stride used unpadded state_dim (48) instead of
pad128(state_dim) (128), scattering goal columns into padding region.

Both fixes verified: 200/200 runs with zero NaN (was 4-15% before).
Removed diagnostic tracing code from investigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:31:12 +02:00
jgrusewski
fc3f6f9df7 fix: 7 NaN root causes in GPU training pipeline + regression test
Root causes found and fixed:
- f32/bf16 type mismatch in pad_states_kernel (PER stores f32, kernel read bf16)
- States buffer stride mismatch in backward_full (lda=48 on pad128=128 buffer)
- States buffer stride mismatch in apply_iqn_trunk_gradient (same bug)
- IQN trunk forward stride mismatch in custom matvec kernel
- IQN grad_norm sqrt-per-block bug (sqrtf of partials → wrong norm + NaN)
- Uninitialized state vector slots in experience_state_gather (stale GPU memory)
- CUDA graph warmup launches producing extreme gradients from Xavier weights

Defense-in-depth guards:
- f32_to_bf16_kernel: NaN→0, clamp ±500
- Adam isfinite guards in DQN, IQN, attention, curiosity kernels
- IQN backward isfinite on d_h_s2 atomicAdd
- clipped_saxpy isfinite on auxiliary gradient input

Adds test_no_nan_after_graph_capture regression test (2s, fxcache-based).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:27:31 +02:00
jgrusewski
57844e1cc5 diag: GPU-side NaN detection kernels + capture warmup investigation
Added dqn_nan_check_f32/bf16 kernels for zero-overhead GPU-side NaN
detection between graph stages. Key findings:

- NaN appears in f32 params at step 0 (before first training forward)
- States (input data) are always clean
- The graph capture warmup launches corrupt params via:
  C51 forward → NaN gradients → Adam warmup → NaN weights
- Removed Adam warmup launch, but NaN persists — the forward
  graph warmup itself may corrupt buffers through bf16 overflow
  in cuBLAS backward path
- Step 14/22 NaN in later runs is from accumulated corruption

Next: investigate whether cuBLAS backward with random Xavier weights
produces extreme gradients that overflow bf16 d_logits staging buffers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:39:34 +02:00
jgrusewski
b316a25262 fix: IQN native f32 arithmetic + gradient budget alpha-blend
Two fixes for training stability:

1. IQN kernel rewrite: all-bf16 → native f32 arithmetic.
   BF16 exp() overflows at logit > 11.1, and bf16 accumulation
   of 64 multiply-adds loses catastrophic precision. The IQN
   head does NOT use cuBLAS tensor cores, so bf16 provided zero
   performance benefit — only precision loss and NaN risk.

   Pattern matches C51/MSE kernels: bf16 I/O at DQN trunk
   boundary, f32 for all internal computation.

   - Rust: all CudaSlice<half::bf16> → CudaSlice<f32>
   - CUDA: all __nv_bfloat16 registers → float
   - Adam, grad_norm, EMA: native f32
   - IQN→PER boundary: f32→bf16 cast via cast_f32_to_td_errors()

2. Gradient budget alpha-blend: during MSE warmup (c51_alpha=0),
   the budget was clipping MSE gradients to 60% of max_grad_norm
   as if they were C51. Now blends: full budget at alpha=0,
   C51 fraction at alpha=1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:47:35 +02:00
jgrusewski
95e96aa976 docs: spec for Q-value explosion / NaN investigation
Documented all findings from the training step performance session:
- Shared memory race was real but not the NaN source (racecheck: 0 hazards)
- NaN is non-deterministic numerical instability, NOT a concurrency bug
- Suspected math bug in gradient budget allocation during MSE warmup
- Investigation plan: GPU NaN detection kernels, gradient audit, git bisect

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:00:16 +02:00
jgrusewski
657d7b49ec perf: dual graph forward (MSE-only) + NaN-safe scale kernel
Two optimizations:

1. Dual CUDA graph: graph_forward_mse skips C51 loss/grad/SAXPY blend
   during MSE warmup (c51_alpha=0). Eliminates 7 kernel invocations
   from the graph replay. replay_forward() routes by alpha automatically.

2. NaN-safe dqn_scale_f32_kernel: writes 0.0 directly when alpha==0
   instead of multiplying (IEEE 754: 0*NaN=NaN). Defense-in-depth for
   the C51 gradient buffer which can contain NaN from random logits.

Fold 3 epoch: 5.08s → 4.92s (under 5s target).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:58:54 +02:00
jgrusewski
137480c887 fix: shared memory race in block_reduce (shmem_reduce read/write hazard)
compute-sanitizer --tool racecheck found 4000+ hazards per kernel invocation
in both c51_loss_batched and mse_loss_batched. The race is between consecutive
block_reduce calls sharing the same shmem_reduce buffer:

1. reduce_max returns shmem_reduce[0] → next reduce_sum writes shmem_reduce[warp]
2. reduce_sum returns shmem_reduce[0] → next reduce_max writes shmem_reduce[warp]

Fixed by adding __syncthreads() after reading the return value in both
block_reduce_max_f and block_reduce_sum_f (read-then-sync pattern). Also
added explicit barriers between spectral decoupling reduces and subsequent
softmax/log_softmax calls.

This race caused non-deterministic NaN in loss values. The effect was masked
by GPU printf statements (which acted as implicit memory barriers) and by
CUDA Graph replay timing differences.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:44:42 +02:00
jgrusewski
ca7201a1ec docs: spec for training step performance + Q-value explosion investigation
Two remaining issues: (1) ~400ms/step instead of ~1-7ms on RTX 3050,
(2) Q-value explosion when C51 kicks in at epoch 2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:30:18 +02:00
jgrusewski
287b37c937 fix: remove cuStreamSynchronize from training hot path (ensemble diversity readback)
The per-step GPU sync for 4-byte diversity_loss monitoring readback
was blocking the CPU on every training step, destroying the async
pipeline. Each step took ~350ms instead of ~1ms.

Fix: accumulate diversity_loss on GPU, defer readback to epoch boundary
via readback_diversity_loss(). The sync now happens ONCE per epoch
instead of per step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:19:12 +02:00