feat(jepa): GPU weight re-upload on checkpoint resume
Documentation / Build API Documentation (push) Failing after 5s
CI / Build CPU-Only (Explicit) (push) Failing after 6s
CI / Build (macos-latest) (push) Failing after 9s
Documentation / Build User Guide (push) Successful in 7s
CI / Format Check (push) Failing after 14s
CI / Clippy Check (push) Failing after 23s
CI / Build (ubuntu-latest) (push) Failing after 42s
CI / Test (macos-latest) (push) Has been skipped
CI / Test (ubuntu-latest) (push) Has been skipped
CI / Python Bindings (maturin) (macos-latest) (push) Has been skipped
CI / Python Bindings (maturin) (ubuntu-latest) (push) Has been skipped
CI / CI Success (push) Failing after 0s
Performance Benchmarks / Run Benchmarks (push) Successful in 1m9s
CI / WASM Build + Size Check (push) Has been skipped
CI / Distributed Training Tests (push) Has been skipped

GpuViTEncoder gains upload_weights (extracted from construction),
cpu_weights_mut, and reupload_weights; JepaTrainerV2 exposes
context_encoder_as_any_mut for backend-specific downcasts. The runner
resume path now restores checkpoint fields into the GPU encoder's host
copy and pushes them back to the device buffers — previously GPU
resume restored only the step counter with a warning. If re-upload
fails after host restore, the run aborts rather than training on stale
device weights.

Verified live on the RTX 5060 Ti: train 20 steps -> resume from the
.jepa binary with total_steps=30 -> "Resumed from step 20", exactly 10
further steps, eval runs, no warnings. New tests: GPU output changes
after host mutation + re-upload; CPU-target re-upload is a no-op Ok.
972 CPU tests / 37 GPU jepa_gpu tests pass.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
osobh
2026-07-10 03:59:36 -07:00
co-authored by Claude Fable 5
parent 74d3db7ee7
commit a755627269
3 changed files with 127 additions and 7 deletions
@@ -461,6 +461,78 @@ impl GpuViTEncoder {
} }
} }
/// Upload all encoder weights from `cpu` to the device via `stream`.
/// Returns `None` (with a warning) if any transfer fails.
#[cfg(feature = "cuda")]
fn upload_weights(
stream: &std::sync::Arc<cudarc::driver::CudaStream>,
cpu: &CpuViTEncoder,
) -> Option<GpuWeightBuffers> {
let patch_embed = stream.clone_htod(&cpu.patch_embed).ok()?;
let proj_w = stream.clone_htod(&cpu.proj_w).ok()?;
let proj_b = stream.clone_htod(&cpu.proj_b).ok()?;
let mut qkv_bufs = Vec::new();
let mut qkv_b_bufs = Vec::new();
let mut out_bufs = Vec::new();
let mut out_b_bufs = Vec::new();
let mut ffn1_bufs = Vec::new();
let mut ffn1_b_bufs = Vec::new();
let mut ffn2_bufs = Vec::new();
let mut ffn2_b_bufs = Vec::new();
for block in &cpu.blocks {
qkv_bufs.push(stream.clone_htod(&block.qkv_w).ok()?);
qkv_b_bufs.push(stream.clone_htod(&block.qkv_b).ok()?);
out_bufs.push(stream.clone_htod(&block.out_w).ok()?);
out_b_bufs.push(stream.clone_htod(&block.out_b).ok()?);
ffn1_bufs.push(stream.clone_htod(&block.ffn1_w).ok()?);
ffn1_b_bufs.push(stream.clone_htod(&block.ffn1_b).ok()?);
ffn2_bufs.push(stream.clone_htod(&block.ffn2_w).ok()?);
ffn2_b_bufs.push(stream.clone_htod(&block.ffn2_b).ok()?);
}
Some(GpuWeightBuffers {
patch_embed,
proj_w,
proj_b,
block_qkv_w: qkv_bufs,
block_qkv_b: qkv_b_bufs,
block_out_w: out_bufs,
block_out_b: out_b_bufs,
block_ffn1_w: ffn1_bufs,
block_ffn1_b: ffn1_b_bufs,
block_ffn2_w: ffn2_bufs,
block_ffn2_b: ffn2_b_bufs,
})
}
/// Mutable host-side weights. After mutating (e.g. checkpoint restore),
/// call [`GpuViTEncoder::reupload_weights`] so the device buffers match.
pub fn cpu_weights_mut(&mut self) -> &mut CpuViTEncoder {
&mut self.cpu_encoder
}
/// Re-upload the host-side weights to the GPU, replacing the existing
/// device buffers. No-op success on CPU-target encoders.
pub fn reupload_weights(&mut self) -> Result<(), String> {
#[cfg(feature = "cuda")]
{
if let Some(ctx) = &self.ctx {
let stream = ctx.default_stream();
match Self::upload_weights(&stream, &self.cpu_encoder) {
Some(bufs) => {
self.gpu_weights = Some(bufs);
return Ok(());
}
None => return Err("GPU weight re-upload failed".to_string()),
}
}
}
Ok(())
}
/// Create targeting CUDA device `device_id`. /// Create targeting CUDA device `device_id`.
/// ///
/// Under `cuda` feature: /// Under `cuda` feature:
@@ -1780,6 +1852,38 @@ mod tests {
check(tiny_cfg(), &[0, 5, 10, 15]); check(tiny_cfg(), &[0, 5, 10, 15]);
} }
// ── Weight re-upload: mutated host weights take effect on the GPU ────────
#[cfg(feature = "cuda")]
#[test]
fn test_reupload_weights_changes_gpu_output() {
let cfg = mini_cfg();
let mut gpu_enc = GpuViTEncoder::cuda(cfg.clone(), 0);
assert!(gpu_enc.has_gpu_weights());
let patch_indices = [0usize, 3];
let before = gpu_enc.encode(&patch_indices);
// Mutate host weights (scale the patch projection), then re-upload.
for w in &mut gpu_enc.cpu_weights_mut().proj_w {
*w *= 2.0;
}
gpu_enc.reupload_weights().expect("re-upload must succeed");
let after = gpu_enc.encode(&patch_indices);
assert_eq!(before.len(), after.len());
assert!(
before.iter().zip(after.iter()).any(|(a, b)| (a - b).abs() > 1e-6),
"GPU output must change after host weight mutation + re-upload"
);
}
// Re-upload is a no-op success on a CPU-target encoder (no device).
#[test]
fn test_reupload_weights_cpu_target_is_ok() {
let mut enc = GpuViTEncoder::cpu(mini_cfg());
assert!(enc.reupload_weights().is_ok());
}
// ── 35. Full-depth numerical parity (depth=12, embed_dim=192) ──────────── // ── 35. Full-depth numerical parity (depth=12, embed_dim=192) ────────────
// //
// `GpuViTEncoder::try_encode_gpu` now runs *every* configured block // `GpuViTEncoder::try_encode_gpu` now runs *every* configured block
@@ -929,17 +929,27 @@ pub fn run_jepa_training(config: JepaRunConfig) -> JepaTrainingSummary {
match load_checkpoint(resume_path) { match load_checkpoint(resume_path) {
Ok(ckpt) => { Ok(ckpt) => {
start_step = ckpt.step + 1; start_step = ckpt.step + 1;
// NOTE: weight restore currently only applies to the CPU
// encoder. Restoring into a GpuViTEncoder would require
// re-uploading the device buffers after mutation (not yet
// implemented) — mutating only its host copy would silently
// train on stale GPU weights, so we warn instead.
if let Some(cpu_enc) = trainer.context_encoder_as_cpu_mut() { if let Some(cpu_enc) = trainer.context_encoder_as_cpu_mut() {
let _ = apply_fields_to_encoder(cpu_enc, &ckpt.fields); let _ = apply_fields_to_encoder(cpu_enc, &ckpt.fields);
} else if let Some(gpu_enc) = trainer
.context_encoder_as_any_mut()
.downcast_mut::<super::jepa_gpu::GpuViTEncoder>()
{
// Restore into the GPU encoder's host copy, then push the
// updated weights back to the device buffers.
let _ = apply_fields_to_encoder(gpu_enc.cpu_weights_mut(), &ckpt.fields);
if let Err(e) = gpu_enc.reupload_weights() {
eprintln!(
"Warning: checkpoint restored on host but GPU re-upload \
failed ({e}); aborting resume to avoid training on stale \
device weights"
);
std::process::exit(1);
}
} else { } else {
eprintln!( eprintln!(
"Warning: resume with a GPU encoder restores the step counter \ "Warning: resume restores the step counter but not model \
but not model weights (GPU weight re-upload not implemented)" weights (unknown encoder backend)"
); );
} }
eprintln!("Resumed from step {}", ckpt.step); eprintln!("Resumed from step {}", ckpt.step);
@@ -791,6 +791,12 @@ impl JepaTrainerV2 {
self.context_encoder.as_any_mut().downcast_mut::<CpuViTEncoder>() self.context_encoder.as_any_mut().downcast_mut::<CpuViTEncoder>()
} }
/// Mutable `Any` access to the context encoder, for backend-specific
/// downcasts (e.g. `GpuViTEncoder` weight restore + re-upload).
pub fn context_encoder_as_any_mut(&mut self) -> &mut dyn std::any::Any {
self.context_encoder.as_any_mut()
}
/// Host-side view of the context encoder weights regardless of backend: /// Host-side view of the context encoder weights regardless of backend:
/// the `CpuViTEncoder` itself, or the host copy inside a `GpuViTEncoder` /// the `CpuViTEncoder` itself, or the host copy inside a `GpuViTEncoder`
/// (whose device buffers are uploaded from it). Used for checkpointing. /// (whose device buffers are uploaded from it). Used for checkpointing.