feat(batch27): JEPA ViT bridge, WebDataset shard reading, training loop
CI / Format Check (push) Failing after 11s
CI / Build (macos-latest) (push) Failing after 30s
CI / Build (ubuntu-latest) (push) Failing after 48s
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 / WASM Build + Size Check (push) Has been skipped
CI / Distributed Training Tests (push) Has been skipped
CI / Clippy Check (push) Failing after 52s
Documentation / Build User Guide (push) Successful in 9s
CI / Build CPU-Only (Explicit) (push) Failing after 1m10s
CI / CI Success (push) Failing after 0s
Documentation / Build API Documentation (push) Failing after 40s
Performance Benchmarks / Run Benchmarks (push) Successful in 7m53s
CI / Format Check (push) Failing after 11s
CI / Build (macos-latest) (push) Failing after 30s
CI / Build (ubuntu-latest) (push) Failing after 48s
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 / WASM Build + Size Check (push) Has been skipped
CI / Distributed Training Tests (push) Has been skipped
CI / Clippy Check (push) Failing after 52s
Documentation / Build User Guide (push) Successful in 9s
CI / Build CPU-Only (Explicit) (push) Failing after 1m10s
CI / CI Success (push) Failing after 0s
Documentation / Build API Documentation (push) Failing after 40s
Performance Benchmarks / Run Benchmarks (push) Successful in 7m53s
Gap 2 — rtx-vision ViT bridge (jepa_vision_bridge.rs, 8 tests): - ViT::forward_features(): patch reps without classification head - ViT::encode_patch_indices(): shape-correct placeholder for GPU dispatch - RtxVisionJepaEncoder implementing JepaEncoder (vision-bridge feature) - From<&ViTConfig> for JepaViTConfig config conversion - rtx-vision added as optional dep; vision-bridge feature gate Gap 3 — WebDataset tar-shard reading (jepa_data.rs, +12 tests, 47 total): - parse_tar_bytes(): pure stdlib tar parser (512-byte block format) - read_webdataset_shard(): file reader with ShardLoadStats timing - WebDatasetRecord: key, image_bytes, label, extension - ShuffleBuffer: fixed-capacity reservoir sampling via LCG PRNG - JepaDataPipeline::from_filesystem(): validates paths, loads shards, builds pipeline Gap 5 — Training loop runner (jepa_runner.rs + examples/jepa_train.rs, 15 tests): - JepaRunConfig with TOML-style key=value parser - run_jepa_training(): full training loop (JepaTrainerV2, cosine LR, checkpointing) - JepaCheckpoint::save() writes JSON summary; load() stub - examples/jepa_train.rs: --config/--size/--steps/--dry-run CLI flags Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
e8a2036db4
commit
f487196367
@@ -112,6 +112,42 @@ impl ViT {
|
|||||||
.map_err(VisionError::from)
|
.map_err(VisionError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns all patch representations `[seq_len+1, embed_dim]` (CLS token first,
|
||||||
|
/// then patches). Does NOT apply the classification head. Suitable for feature
|
||||||
|
/// extraction (e.g. JEPA encoders).
|
||||||
|
pub fn forward_features(&self, x: &Tensor) -> Result<Tensor> {
|
||||||
|
let x = self.patch_embed.forward(x)?;
|
||||||
|
let x = self.patch_embed.add_class_token(&x)?;
|
||||||
|
let mut x = x;
|
||||||
|
for block in &self.blocks {
|
||||||
|
x = block.forward(&x)?;
|
||||||
|
}
|
||||||
|
self.norm.forward(&x)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode specific patch indices (0-indexed, skipping the CLS token at
|
||||||
|
/// position 0 in the feature sequence).
|
||||||
|
///
|
||||||
|
/// Returns a flattened `[n_patches, embed_dim]` f32 vector.
|
||||||
|
///
|
||||||
|
/// # Placeholder note
|
||||||
|
///
|
||||||
|
/// The returned values are currently **zeros of the correct shape**. Extracting
|
||||||
|
/// arbitrary rows from a `Tensor` requires `narrow` + `to_vec()`, which depends
|
||||||
|
/// on backend dispatch not yet wired for all targets. The shape contract
|
||||||
|
/// (`n_patches * embed_dim` elements) is already correct and will be filled with
|
||||||
|
/// real values once GPU tensor row-extraction is available (Batch 27).
|
||||||
|
pub fn encode_patch_indices(&self, x: &Tensor, patch_indices: &[usize]) -> Result<Vec<f32>> {
|
||||||
|
// Run full forward pass to get [n+1, embed_dim] features.
|
||||||
|
let _features = self.forward_features(x)?;
|
||||||
|
// patch i lives at position i+1 (position 0 is the CLS token).
|
||||||
|
// Placeholder: return zeros with the correct shape until Tensor row
|
||||||
|
// extraction (narrow + to_vec) is wired to the GPU backend.
|
||||||
|
let n_patches = patch_indices.len();
|
||||||
|
let d = self.config.embed_dim;
|
||||||
|
Ok(vec![0.0f32; n_patches * d])
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_class_token(&self, x: &Tensor) -> Result<Tensor> {
|
fn extract_class_token(&self, x: &Tensor) -> Result<Tensor> {
|
||||||
let shape = x.shape().dims();
|
let shape = x.shape().dims();
|
||||||
match shape.len() {
|
match shape.len() {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ parking_lot = { workspace = true }
|
|||||||
|
|
||||||
rtx-runtime = { workspace = true }
|
rtx-runtime = { workspace = true }
|
||||||
rtx-flash-attention = { workspace = true }
|
rtx-flash-attention = { workspace = true }
|
||||||
|
rtx-vision = { path = "../../models/rtx-vision", version = "1.0.0", optional = true }
|
||||||
|
|
||||||
# Text processing
|
# Text processing
|
||||||
regex = "1.10"
|
regex = "1.10"
|
||||||
@@ -86,6 +87,7 @@ cuda = ["cudarc", "rtx-flash-attention/cuda", "rtx-tensor/cuda", "rtx-runtime/cu
|
|||||||
metal = ["rtx-flash-attention/metal", "rtx-tensor/metal", "rtx-runtime/metal", "dep:objc2", "dep:objc2-metal", "dep:objc2-foundation", "dep:block2"]
|
metal = ["rtx-flash-attention/metal", "rtx-tensor/metal", "rtx-runtime/metal", "dep:objc2", "dep:objc2-metal", "dep:objc2-foundation", "dep:block2"]
|
||||||
cpu = ["rtx-tensor/cpu"]
|
cpu = ["rtx-tensor/cpu"]
|
||||||
disabled_tests = []
|
disabled_tests = []
|
||||||
|
vision-bridge = ["rtx-vision"]
|
||||||
|
|
||||||
# Binary targets commented out - missing source files
|
# Binary targets commented out - missing source files
|
||||||
# [[bin]]
|
# [[bin]]
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//! JEPA training entry point.
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! cargo run --example jepa_train -p rtx-transformers -- --config path/to/config.toml
|
||||||
|
//! cargo run --example jepa_train -p rtx-transformers -- --size small --steps 1000
|
||||||
|
//! cargo run --example jepa_train -p rtx-transformers -- --dry-run
|
||||||
|
//!
|
||||||
|
//! With no args, runs a 100-step dry run with ViT-Tiny and synthetic data.
|
||||||
|
|
||||||
|
use rtx_transformers::ssl::jepa_runner::{JepaRunConfig, run_jepa_training};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
|
||||||
|
let mut config = JepaRunConfig::default();
|
||||||
|
config.total_steps = 100; // default to short dry run
|
||||||
|
config.log_every = 10;
|
||||||
|
|
||||||
|
let mut i = 1;
|
||||||
|
while i < args.len() {
|
||||||
|
match args[i].as_str() {
|
||||||
|
"--config" => {
|
||||||
|
i += 1;
|
||||||
|
let content = std::fs::read_to_string(&args[i])
|
||||||
|
.unwrap_or_else(|e| panic!("Cannot read config {}: {}", args[i], e));
|
||||||
|
config = rtx_transformers::ssl::jepa_runner::parse_config_from_str(&content)
|
||||||
|
.unwrap_or_else(|e| panic!("Config parse error: {}", e));
|
||||||
|
}
|
||||||
|
"--size" => {
|
||||||
|
i += 1;
|
||||||
|
config.vit_size = match args[i].as_str() {
|
||||||
|
"tiny" => rtx_transformers::ssl::jepa_runner::ViTSizeStr::Tiny,
|
||||||
|
"small" => rtx_transformers::ssl::jepa_runner::ViTSizeStr::Small,
|
||||||
|
"base" => rtx_transformers::ssl::jepa_runner::ViTSizeStr::Base,
|
||||||
|
"large" => rtx_transformers::ssl::jepa_runner::ViTSizeStr::Large,
|
||||||
|
"huge" => rtx_transformers::ssl::jepa_runner::ViTSizeStr::Huge,
|
||||||
|
s => panic!("Unknown size: {}", s),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"--steps" => {
|
||||||
|
i += 1;
|
||||||
|
config.total_steps = args[i].parse().unwrap_or_else(|_| panic!("Invalid steps"));
|
||||||
|
}
|
||||||
|
"--dry-run" => {
|
||||||
|
config.total_steps = 10;
|
||||||
|
config.log_every = 1;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Starting JEPA training: {:?} for {} steps",
|
||||||
|
config.vit_size, config.total_steps
|
||||||
|
);
|
||||||
|
let summary = run_jepa_training(config);
|
||||||
|
println!("\n=== Training Complete ===");
|
||||||
|
println!("Steps: {}", summary.total_steps);
|
||||||
|
println!("Final loss: {:.4}", summary.final_loss);
|
||||||
|
println!("Mean loss (last 100): {:.4}", summary.mean_loss);
|
||||||
|
println!("Throughput: {:.1} steps/s", summary.steps_per_second);
|
||||||
|
println!("Wall time: {:.1}s", summary.wall_time_seconds);
|
||||||
|
println!("Checkpoints saved: {}", summary.checkpoints_saved);
|
||||||
|
}
|
||||||
@@ -762,6 +762,312 @@ impl WebDatasetShard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WebDatasetRecord
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// A record decoded from a WebDataset shard (one image + optional label).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WebDatasetRecord {
|
||||||
|
/// Numeric prefix, e.g. "000042".
|
||||||
|
pub key: String,
|
||||||
|
/// Raw JPEG/PNG bytes (not decoded to pixels).
|
||||||
|
pub image_bytes: Vec<u8>,
|
||||||
|
/// Parsed from .cls file.
|
||||||
|
pub label: Option<usize>,
|
||||||
|
/// "jpg" or "png".
|
||||||
|
pub extension: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ShardLoadStats
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Statistics for a loaded shard.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ShardLoadStats {
|
||||||
|
pub shard_id: usize,
|
||||||
|
pub records_loaded: usize,
|
||||||
|
pub bytes_read: u64,
|
||||||
|
pub load_duration_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ShuffleBuffer
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Fixed-size ring buffer for dataset shuffling.
|
||||||
|
/// Holds up to `capacity` records; reservoir sampling on insert.
|
||||||
|
pub struct ShuffleBuffer {
|
||||||
|
capacity: usize,
|
||||||
|
buffer: Vec<ImageRecord>,
|
||||||
|
rng_state: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShuffleBuffer {
|
||||||
|
/// Create a new shuffle buffer seeded from `capacity`.
|
||||||
|
pub fn new(capacity: usize) -> Self {
|
||||||
|
let rng_state = (capacity as u64).wrapping_mul(6364136223846793005);
|
||||||
|
Self { capacity, buffer: Vec::new(), rng_state }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a record. If the buffer is not full, push it; otherwise replace
|
||||||
|
/// a random slot using the internal LCG.
|
||||||
|
pub fn push(&mut self, record: ImageRecord) {
|
||||||
|
if self.buffer.len() < self.capacity {
|
||||||
|
self.buffer.push(record);
|
||||||
|
} else if self.capacity > 0 {
|
||||||
|
let slot = lcg_usize(&mut self.rng_state, self.capacity);
|
||||||
|
self.buffer[slot] = record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove and return the first `n` items (or all items if fewer than `n` remain).
|
||||||
|
pub fn drain_batch(&mut self, n: usize) -> Vec<ImageRecord> {
|
||||||
|
let take = n.min(self.buffer.len());
|
||||||
|
self.buffer.drain(..take).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of records currently in the buffer.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.buffer.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when `len() >= capacity`.
|
||||||
|
pub fn is_full(&self) -> bool {
|
||||||
|
self.len() >= self.capacity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tar parsing helpers (stdlib only)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Parse a null-terminated octal ASCII string from a fixed-width tar field.
|
||||||
|
fn parse_octal(field: &[u8]) -> u64 {
|
||||||
|
// Trim leading/trailing null bytes and spaces
|
||||||
|
let s = field
|
||||||
|
.iter()
|
||||||
|
.take_while(|&&b| b != 0 && b != b' ')
|
||||||
|
.copied()
|
||||||
|
.collect::<Vec<u8>>();
|
||||||
|
let s = std::str::from_utf8(&s).unwrap_or("0");
|
||||||
|
u64::from_str_radix(s.trim(), 8).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse all WebDataset records from an in-memory tar byte slice.
|
||||||
|
///
|
||||||
|
/// A tar archive is a sequence of 512-byte blocks:
|
||||||
|
/// - Header block: filename (bytes 0..100), size (bytes 124..136, octal),
|
||||||
|
/// typeflag (byte 156, '0' or '\0' = regular file, '5' = directory).
|
||||||
|
/// - Data blocks: ceil(size / 512) × 512 bytes follow the header.
|
||||||
|
/// - End-of-archive: two consecutive all-zero blocks.
|
||||||
|
pub fn parse_tar_bytes(data: &[u8]) -> Vec<WebDatasetRecord> {
|
||||||
|
// Accumulate raw file entries keyed by stem (numeric prefix).
|
||||||
|
// Values: (image_bytes, image_ext, label_bytes)
|
||||||
|
let mut image_map: std::collections::HashMap<String, (Vec<u8>, String)> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
let mut label_map: std::collections::HashMap<String, Vec<u8>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
|
||||||
|
let mut offset = 0usize;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Need at least one header block.
|
||||||
|
if offset + 512 > data.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let header = &data[offset..offset + 512];
|
||||||
|
offset += 512;
|
||||||
|
|
||||||
|
// End-of-archive: two consecutive zero blocks. A zero block starts here.
|
||||||
|
if header.iter().all(|&b| b == 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse filename (bytes 0..100, null-terminated).
|
||||||
|
let fname_raw = &header[0..100];
|
||||||
|
let fname_len = fname_raw.iter().position(|&b| b == 0).unwrap_or(100);
|
||||||
|
let filename = match std::str::from_utf8(&fname_raw[..fname_len]) {
|
||||||
|
Ok(s) => s.trim_matches('/').to_owned(),
|
||||||
|
Err(_) => {
|
||||||
|
// Skip unreadable header.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse typeflag (byte 156). '5' = directory, skip data blocks but
|
||||||
|
// don't advance data pointer here — size may still be non-zero.
|
||||||
|
let typeflag = header[156];
|
||||||
|
let is_directory = typeflag == b'5';
|
||||||
|
|
||||||
|
// Parse size (bytes 124..136, octal ASCII).
|
||||||
|
let size = parse_octal(&header[124..136]) as usize;
|
||||||
|
|
||||||
|
// Skip aligned data blocks for directories and zero-size files.
|
||||||
|
let data_blocks = (size + 511) / 512;
|
||||||
|
let data_bytes = data_blocks * 512;
|
||||||
|
|
||||||
|
if is_directory || size == 0 {
|
||||||
|
offset += data_bytes;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read `size` bytes of content.
|
||||||
|
if offset + data_bytes > data.len() {
|
||||||
|
// Truncated archive — stop.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let content = data[offset..offset + size].to_vec();
|
||||||
|
offset += data_bytes;
|
||||||
|
|
||||||
|
// Derive extension and key from the filename.
|
||||||
|
// Filenames may include a directory prefix; take the basename.
|
||||||
|
let basename = filename
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(&filename);
|
||||||
|
|
||||||
|
let (stem, ext) = if let Some(dot) = basename.rfind('.') {
|
||||||
|
(&basename[..dot], &basename[dot + 1..])
|
||||||
|
} else {
|
||||||
|
(basename, "")
|
||||||
|
};
|
||||||
|
|
||||||
|
match ext {
|
||||||
|
"jpg" | "jpeg" | "png" => {
|
||||||
|
let image_ext = if ext == "jpeg" { "jpg" } else { ext };
|
||||||
|
image_map.insert(stem.to_owned(), (content, image_ext.to_owned()));
|
||||||
|
}
|
||||||
|
"cls" => {
|
||||||
|
label_map.insert(stem.to_owned(), content);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Unknown extension — ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge image + label by key.
|
||||||
|
let mut keys: Vec<String> = image_map.keys().cloned().collect();
|
||||||
|
keys.sort();
|
||||||
|
|
||||||
|
keys.into_iter()
|
||||||
|
.filter_map(|key| {
|
||||||
|
let (image_bytes, extension) = image_map.remove(&key)?;
|
||||||
|
let label = label_map.get(&key).and_then(|bytes| {
|
||||||
|
let s = std::str::from_utf8(bytes).ok()?.trim().to_owned();
|
||||||
|
s.parse::<usize>().ok()
|
||||||
|
});
|
||||||
|
Some(WebDatasetRecord { key, image_bytes, label, extension })
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read all records from a WebDataset tar shard at `path`.
|
||||||
|
///
|
||||||
|
/// Returns records in shard order plus load statistics.
|
||||||
|
/// Supports uncompressed tars only (compressed support is future work).
|
||||||
|
pub fn read_webdataset_shard(
|
||||||
|
path: &std::path::Path,
|
||||||
|
) -> Result<(Vec<WebDatasetRecord>, ShardLoadStats), String> {
|
||||||
|
use std::io::Read;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
let t0 = Instant::now();
|
||||||
|
|
||||||
|
let mut file = std::fs::File::open(path)
|
||||||
|
.map_err(|e| format!("cannot open {}: {e}", path.display()))?;
|
||||||
|
|
||||||
|
let mut data = Vec::new();
|
||||||
|
file.read_to_end(&mut data)
|
||||||
|
.map_err(|e| format!("read error for {}: {e}", path.display()))?;
|
||||||
|
|
||||||
|
let bytes_read = data.len() as u64;
|
||||||
|
let records = parse_tar_bytes(&data);
|
||||||
|
let records_loaded = records.len();
|
||||||
|
let load_duration_ms = t0.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
|
// shard_id is unknown at this level; callers should override if needed.
|
||||||
|
let stats = ShardLoadStats {
|
||||||
|
shard_id: 0,
|
||||||
|
records_loaded,
|
||||||
|
bytes_read,
|
||||||
|
load_duration_ms,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((records, stats))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a `WebDatasetRecord` into an `ImageRecord`.
|
||||||
|
///
|
||||||
|
/// Pixel values are placeholder 0.5f32 until rtx-vision image decoder is
|
||||||
|
/// wired. The function detects JPEG (`\xFF\xD8`) and PNG (`\x89PNG`) magic
|
||||||
|
/// bytes to validate the format; other byte sequences also receive the
|
||||||
|
/// placeholder.
|
||||||
|
fn webdataset_record_to_image(rec: WebDatasetRecord) -> ImageRecord {
|
||||||
|
const W: usize = 224;
|
||||||
|
const H: usize = 224;
|
||||||
|
const C: usize = 3;
|
||||||
|
|
||||||
|
// Validate magic bytes (informational; decoding is a placeholder).
|
||||||
|
let _is_jpeg = rec.image_bytes.len() >= 2
|
||||||
|
&& rec.image_bytes[0] == 0xFF
|
||||||
|
&& rec.image_bytes[1] == 0xD8;
|
||||||
|
let _is_png = rec.image_bytes.len() >= 4
|
||||||
|
&& rec.image_bytes[0] == 0x89
|
||||||
|
&& &rec.image_bytes[1..4] == b"PNG";
|
||||||
|
|
||||||
|
ImageRecord {
|
||||||
|
pixels: vec![0.5f32; W * H * C],
|
||||||
|
width: W,
|
||||||
|
height: H,
|
||||||
|
channels: C,
|
||||||
|
label: rec.label,
|
||||||
|
key: rec.key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JepaDataPipeline {
|
||||||
|
/// Load a pipeline from filesystem shards.
|
||||||
|
///
|
||||||
|
/// `shard_paths` is a list of `.tar` files (uncompressed WebDataset format).
|
||||||
|
/// Each file is read, parsed, and converted to an `InMemoryShard`.
|
||||||
|
///
|
||||||
|
/// Pixel values are placeholder 0.5f32 until rtx-vision image decoder is
|
||||||
|
/// wired.
|
||||||
|
pub fn from_filesystem(
|
||||||
|
config: JepaDataConfig,
|
||||||
|
shard_paths: Vec<std::path::PathBuf>,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
// Validate all paths exist before loading anything.
|
||||||
|
for p in &shard_paths {
|
||||||
|
if !p.exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"shard path does not exist: {}",
|
||||||
|
p.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut shards: Vec<InMemoryShard> = Vec::with_capacity(shard_paths.len());
|
||||||
|
|
||||||
|
for (shard_id, path) in shard_paths.iter().enumerate() {
|
||||||
|
let (raw_records, _stats) = read_webdataset_shard(path)?;
|
||||||
|
|
||||||
|
let records: Vec<ImageRecord> = raw_records
|
||||||
|
.into_iter()
|
||||||
|
.map(webdataset_record_to_image)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
shards.push(InMemoryShard { records, shard_id });
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self::new(config, shards))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Tests
|
// Tests
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1176,4 +1482,271 @@ mod tests {
|
|||||||
assert_eq!(rec.pixels.len(), 16 * 16 * 3);
|
assert_eq!(rec.pixels.len(), 16 * 16 * 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tar parsing helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Build a tar header block for a regular file with the given filename and size.
|
||||||
|
fn make_tar_header(filename: &str, size: u64) -> [u8; 512] {
|
||||||
|
let mut block = [0u8; 512];
|
||||||
|
// filename: bytes 0..100
|
||||||
|
let fname_bytes = filename.as_bytes();
|
||||||
|
block[..fname_bytes.len().min(99)]
|
||||||
|
.copy_from_slice(&fname_bytes[..fname_bytes.len().min(99)]);
|
||||||
|
// size: bytes 124..136, octal ASCII with trailing null
|
||||||
|
let octal = format!("{:011o}\0", size);
|
||||||
|
block[124..136].copy_from_slice(octal.as_bytes());
|
||||||
|
// typeflag: byte 156 = '0' for regular file
|
||||||
|
block[156] = b'0';
|
||||||
|
// checksum: bytes 148..156, sum of all header bytes with checksum field as spaces
|
||||||
|
for i in 148..156 {
|
||||||
|
block[i] = b' ';
|
||||||
|
}
|
||||||
|
let checksum: u32 = block.iter().map(|&b| b as u32).sum();
|
||||||
|
let chk_str = format!("{:06o}\0 ", checksum);
|
||||||
|
block[148..156].copy_from_slice(chk_str.as_bytes());
|
||||||
|
block
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a tar header block for a directory entry.
|
||||||
|
fn make_tar_dir_header(dirname: &str) -> [u8; 512] {
|
||||||
|
let mut block = [0u8; 512];
|
||||||
|
let fname_bytes = dirname.as_bytes();
|
||||||
|
block[..fname_bytes.len().min(99)]
|
||||||
|
.copy_from_slice(&fname_bytes[..fname_bytes.len().min(99)]);
|
||||||
|
// size = 0
|
||||||
|
let octal = format!("{:011o}\0", 0u64);
|
||||||
|
block[124..136].copy_from_slice(octal.as_bytes());
|
||||||
|
// typeflag '5' = directory
|
||||||
|
block[156] = b'5';
|
||||||
|
for i in 148..156 {
|
||||||
|
block[i] = b' ';
|
||||||
|
}
|
||||||
|
let checksum: u32 = block.iter().map(|&b| b as u32).sum();
|
||||||
|
let chk_str = format!("{:06o}\0 ", checksum);
|
||||||
|
block[148..156].copy_from_slice(chk_str.as_bytes());
|
||||||
|
block
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a minimal valid tar archive from (key, image_bytes, label) tuples.
|
||||||
|
///
|
||||||
|
/// Each entry emits a `{key}.jpg` file and, if label is Some, a `{key}.cls` file.
|
||||||
|
fn make_test_tar(records: &[(&str, &[u8], Option<usize>)]) -> Vec<u8> {
|
||||||
|
let mut out: Vec<u8> = Vec::new();
|
||||||
|
|
||||||
|
for (key, image_bytes, label) in records {
|
||||||
|
// Image file
|
||||||
|
let img_name = format!("{}.jpg", key);
|
||||||
|
let img_size = image_bytes.len() as u64;
|
||||||
|
out.extend_from_slice(&make_tar_header(&img_name, img_size));
|
||||||
|
out.extend_from_slice(image_bytes);
|
||||||
|
// Pad to next 512-byte boundary
|
||||||
|
let pad = (512 - (image_bytes.len() % 512)) % 512;
|
||||||
|
out.extend(std::iter::repeat(0u8).take(pad));
|
||||||
|
|
||||||
|
// Class label file (optional)
|
||||||
|
if let Some(lbl) = label {
|
||||||
|
let cls_content = format!("{}\n", lbl);
|
||||||
|
let cls_bytes = cls_content.as_bytes();
|
||||||
|
let cls_name = format!("{}.cls", key);
|
||||||
|
out.extend_from_slice(&make_tar_header(&cls_name, cls_bytes.len() as u64));
|
||||||
|
out.extend_from_slice(cls_bytes);
|
||||||
|
let cls_pad = (512 - (cls_bytes.len() % 512)) % 512;
|
||||||
|
out.extend(std::iter::repeat(0u8).take(cls_pad));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// End-of-archive: two zero blocks
|
||||||
|
out.extend(std::iter::repeat(0u8).take(1024));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tar parsing tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_empty_tar() {
|
||||||
|
// Two zero blocks = empty archive
|
||||||
|
let data = vec![0u8; 1024];
|
||||||
|
let records = parse_tar_bytes(&data);
|
||||||
|
assert_eq!(records.len(), 0, "empty tar must yield 0 records");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_single_record() {
|
||||||
|
let fake_jpg = b"\xFF\xD8\xFF\xE0fake jpeg content";
|
||||||
|
let tar = make_test_tar(&[("000000", fake_jpg, Some(42))]);
|
||||||
|
let records = parse_tar_bytes(&tar);
|
||||||
|
assert_eq!(records.len(), 1);
|
||||||
|
assert_eq!(records[0].key, "000000");
|
||||||
|
assert_eq!(records[0].label, Some(42));
|
||||||
|
assert_eq!(records[0].extension, "jpg");
|
||||||
|
assert_eq!(records[0].image_bytes, fake_jpg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_multiple_records() {
|
||||||
|
let fake_jpg = b"\xFF\xD8content";
|
||||||
|
let entries: Vec<(&str, &[u8], Option<usize>)> = vec![
|
||||||
|
("000000", fake_jpg, Some(0)),
|
||||||
|
("000001", fake_jpg, Some(1)),
|
||||||
|
("000002", fake_jpg, Some(2)),
|
||||||
|
("000003", fake_jpg, Some(3)),
|
||||||
|
("000004", fake_jpg, Some(4)),
|
||||||
|
];
|
||||||
|
let tar = make_test_tar(&entries);
|
||||||
|
let records = parse_tar_bytes(&tar);
|
||||||
|
assert_eq!(records.len(), 5, "expected 5 records");
|
||||||
|
for (i, rec) in records.iter().enumerate() {
|
||||||
|
assert_eq!(rec.label, Some(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_record_without_cls() {
|
||||||
|
let fake_jpg = b"\xFF\xD8no label";
|
||||||
|
let tar = make_test_tar(&[("000000", fake_jpg, None)]);
|
||||||
|
let records = parse_tar_bytes(&tar);
|
||||||
|
assert_eq!(records.len(), 1);
|
||||||
|
assert_eq!(records[0].label, None, "image with no .cls should have label=None");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_directory_entries_skipped() {
|
||||||
|
let mut out: Vec<u8> = Vec::new();
|
||||||
|
// Directory entry
|
||||||
|
out.extend_from_slice(&make_tar_dir_header("somedir/"));
|
||||||
|
// A regular image record after the directory
|
||||||
|
let fake_jpg = b"\xFF\xD8data";
|
||||||
|
let img_name = "000000.jpg";
|
||||||
|
out.extend_from_slice(&make_tar_header(img_name, fake_jpg.len() as u64));
|
||||||
|
out.extend_from_slice(fake_jpg);
|
||||||
|
let pad = (512 - (fake_jpg.len() % 512)) % 512;
|
||||||
|
out.extend(std::iter::repeat(0u8).take(pad));
|
||||||
|
// End-of-archive
|
||||||
|
out.extend(std::iter::repeat(0u8).take(1024));
|
||||||
|
|
||||||
|
let records = parse_tar_bytes(&out);
|
||||||
|
assert_eq!(records.len(), 1, "directory entries must be skipped");
|
||||||
|
assert_eq!(records[0].key, "000000");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_large_file() {
|
||||||
|
// 50 000 bytes > 512*97 = 49664, spans many blocks
|
||||||
|
let big_image: Vec<u8> = (0..50_000u32)
|
||||||
|
.map(|i| (i % 251) as u8)
|
||||||
|
.collect();
|
||||||
|
let tar = make_test_tar(&[("000000", &big_image, Some(7))]);
|
||||||
|
let records = parse_tar_bytes(&tar);
|
||||||
|
assert_eq!(records.len(), 1);
|
||||||
|
assert_eq!(records[0].image_bytes.len(), 50_000);
|
||||||
|
assert_eq!(records[0].image_bytes, big_image);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ShuffleBuffer tests ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn make_image_record(key: &str) -> ImageRecord {
|
||||||
|
ImageRecord {
|
||||||
|
pixels: vec![0.5f32; 8 * 8 * 3],
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
channels: 3,
|
||||||
|
label: None,
|
||||||
|
key: key.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shuffle_buffer_fill() {
|
||||||
|
let mut buf = ShuffleBuffer::new(10);
|
||||||
|
for i in 0..10 {
|
||||||
|
buf.push(make_image_record(&format!("img{i}")));
|
||||||
|
}
|
||||||
|
assert_eq!(buf.len(), 10);
|
||||||
|
assert!(buf.is_full());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shuffle_buffer_push_overflow() {
|
||||||
|
let mut buf = ShuffleBuffer::new(10);
|
||||||
|
for i in 0..15 {
|
||||||
|
buf.push(make_image_record(&format!("img{i}")));
|
||||||
|
}
|
||||||
|
assert_eq!(buf.len(), 10, "buffer must not grow beyond capacity");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shuffle_buffer_drain() {
|
||||||
|
let mut buf = ShuffleBuffer::new(10);
|
||||||
|
for i in 0..10 {
|
||||||
|
buf.push(make_image_record(&format!("img{i}")));
|
||||||
|
}
|
||||||
|
let batch = buf.drain_batch(5);
|
||||||
|
assert_eq!(batch.len(), 5);
|
||||||
|
assert_eq!(buf.len(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shuffle_buffer_empty_drain() {
|
||||||
|
let mut buf = ShuffleBuffer::new(10);
|
||||||
|
for i in 0..3 {
|
||||||
|
buf.push(make_image_record(&format!("img{i}")));
|
||||||
|
}
|
||||||
|
// Drain more than available
|
||||||
|
let batch = buf.drain_batch(100);
|
||||||
|
assert_eq!(batch.len(), 3, "drain should return at most what's in the buffer");
|
||||||
|
assert_eq!(buf.len(), 0);
|
||||||
|
|
||||||
|
// Drain zero
|
||||||
|
let empty = buf.drain_batch(0);
|
||||||
|
assert_eq!(empty.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ShardLoadStats tests ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shard_load_stats_bytes() {
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
// Write a real tar to a temp file and read it back.
|
||||||
|
let fake_jpg = b"\xFF\xD8fake";
|
||||||
|
let entries: Vec<(&str, &[u8], Option<usize>)> = vec![
|
||||||
|
("000000", fake_jpg, Some(0)),
|
||||||
|
("000001", fake_jpg, Some(1)),
|
||||||
|
("000002", fake_jpg, Some(2)),
|
||||||
|
];
|
||||||
|
let tar_bytes = make_test_tar(&entries);
|
||||||
|
|
||||||
|
let mut tmp = std::env::temp_dir();
|
||||||
|
tmp.push("jepa_test_shard_stats.tar");
|
||||||
|
{
|
||||||
|
let mut f = std::fs::File::create(&tmp).expect("create temp file");
|
||||||
|
f.write_all(&tar_bytes).expect("write tar");
|
||||||
|
}
|
||||||
|
|
||||||
|
let (records, stats) = read_webdataset_shard(&tmp).expect("read shard");
|
||||||
|
std::fs::remove_file(&tmp).ok();
|
||||||
|
|
||||||
|
assert_eq!(records.len(), 3);
|
||||||
|
assert!(stats.bytes_read > 0, "bytes_read must be > 0");
|
||||||
|
assert_eq!(stats.records_loaded, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── from_filesystem tests ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_filesystem_missing_path() {
|
||||||
|
let config = JepaDataConfig::default();
|
||||||
|
let missing = std::path::PathBuf::from("/nonexistent/path/shard-0000.tar");
|
||||||
|
let result = JepaDataPipeline::from_filesystem(config, vec![missing.clone()]);
|
||||||
|
assert!(result.is_err(), "missing path must return Err");
|
||||||
|
// Extract the error message without calling unwrap_err (JepaDataPipeline has no Debug).
|
||||||
|
let msg = match result {
|
||||||
|
Err(e) => e,
|
||||||
|
Ok(_) => panic!("expected Err"),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
msg.contains("nonexistent") || msg.contains("shard-0000.tar"),
|
||||||
|
"error message should mention the path: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,817 @@
|
|||||||
|
//! JEPA Training Loop Runner
|
||||||
|
//!
|
||||||
|
//! Provides a complete, runnable training entry point for I-JEPA using
|
||||||
|
//! `JepaTrainerV2` with `CpuViTEncoder`. GPU dispatch is future work.
|
||||||
|
//!
|
||||||
|
//! Key types:
|
||||||
|
//! - [`JepaRunConfig`]: top-level TOML-parseable config for a JEPA training run
|
||||||
|
//! - [`ViTSizeStr`]: human-readable ViT size that maps to `JepaViTConfig`
|
||||||
|
//! - [`JepaStepResult`]: per-step metrics
|
||||||
|
//! - [`JepaCheckpoint`]: in-memory training checkpoint with JSON summary on disk
|
||||||
|
//! - [`JepaTrainingSummary`]: summary returned after a full training run
|
||||||
|
//! - [`run_jepa_training`]: the main training loop function
|
||||||
|
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use super::jepa_vit::{JepaTrainerV2, JepaViTConfig};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ViTSizeStr
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Human-readable ViT size string that parses to [`JepaViTConfig`].
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum ViTSizeStr {
|
||||||
|
Tiny,
|
||||||
|
Small,
|
||||||
|
Base,
|
||||||
|
Large,
|
||||||
|
Huge,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ViTSizeStr {
|
||||||
|
/// Convert to a `JepaViTConfig` with the given image and patch sizes.
|
||||||
|
pub fn to_vit_config(&self, image_size: usize, patch_size: usize) -> JepaViTConfig {
|
||||||
|
let base = match self {
|
||||||
|
ViTSizeStr::Tiny => JepaViTConfig::tiny(),
|
||||||
|
ViTSizeStr::Small => JepaViTConfig::small(),
|
||||||
|
ViTSizeStr::Base => JepaViTConfig::base(),
|
||||||
|
ViTSizeStr::Large => JepaViTConfig::large(),
|
||||||
|
ViTSizeStr::Huge => JepaViTConfig::huge(),
|
||||||
|
};
|
||||||
|
JepaViTConfig { image_size, patch_size, ..base }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// JepaRunConfig
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Top-level config that drives a JEPA training run.
|
||||||
|
/// Serializable to/from a simple `key = value` TOML-like format.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct JepaRunConfig {
|
||||||
|
// --- Model ---
|
||||||
|
/// ViT backbone size: "tiny" | "small" | "base" | "large" | "huge"
|
||||||
|
pub vit_size: ViTSizeStr,
|
||||||
|
/// Input image resolution (default 224)
|
||||||
|
pub image_size: usize,
|
||||||
|
/// Patch size in pixels (default 16)
|
||||||
|
pub patch_size: usize,
|
||||||
|
|
||||||
|
// --- Training schedule ---
|
||||||
|
/// Total training steps (default 125_000)
|
||||||
|
pub total_steps: usize,
|
||||||
|
/// Linear warm-up steps (default 10_000)
|
||||||
|
pub warmup_steps: usize,
|
||||||
|
/// Base learning rate (default 1.5e-4)
|
||||||
|
pub base_lr: f32,
|
||||||
|
/// AdamW weight decay (default 0.05)
|
||||||
|
pub weight_decay: f32,
|
||||||
|
/// EMA τ at step 0 (default 0.996)
|
||||||
|
pub ema_tau_start: f32,
|
||||||
|
/// EMA τ at final step (default 1.0)
|
||||||
|
pub ema_tau_end: f32,
|
||||||
|
|
||||||
|
// --- Data ---
|
||||||
|
/// Global batch size (default 2048)
|
||||||
|
pub batch_size: usize,
|
||||||
|
/// Number of data-loading worker threads (default 4)
|
||||||
|
pub num_workers: usize,
|
||||||
|
/// Paths to .tar WebDataset shards; empty = use synthetic data
|
||||||
|
pub data_shards: Vec<String>,
|
||||||
|
|
||||||
|
// --- Checkpointing ---
|
||||||
|
/// Directory to write checkpoint JSON files (default "./jepa-checkpoints")
|
||||||
|
pub checkpoint_dir: String,
|
||||||
|
/// Save a checkpoint every N steps (default 1_000)
|
||||||
|
pub checkpoint_every: usize,
|
||||||
|
/// Optional path to a checkpoint to resume from
|
||||||
|
pub resume_from: Option<String>,
|
||||||
|
|
||||||
|
// --- Logging ---
|
||||||
|
/// Print a log line every N steps (default 50)
|
||||||
|
pub log_every: usize,
|
||||||
|
/// Run evaluation every N steps (default 5_000)
|
||||||
|
pub eval_every: usize,
|
||||||
|
|
||||||
|
// --- Cluster (optional) ---
|
||||||
|
/// Number of GPUs (default 1)
|
||||||
|
pub num_gpus: usize,
|
||||||
|
/// Tensor-parallel degree (default 1)
|
||||||
|
pub tensor_parallel: usize,
|
||||||
|
/// Data-parallel degree (default 1)
|
||||||
|
pub data_parallel: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for JepaRunConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
vit_size: ViTSizeStr::Tiny,
|
||||||
|
image_size: 224,
|
||||||
|
patch_size: 16,
|
||||||
|
total_steps: 125_000,
|
||||||
|
warmup_steps: 10_000,
|
||||||
|
base_lr: 1.5e-4,
|
||||||
|
weight_decay: 0.05,
|
||||||
|
ema_tau_start: 0.996,
|
||||||
|
ema_tau_end: 1.0,
|
||||||
|
batch_size: 2048,
|
||||||
|
num_workers: 4,
|
||||||
|
data_shards: Vec::new(),
|
||||||
|
checkpoint_dir: "./jepa-checkpoints".to_string(),
|
||||||
|
checkpoint_every: 1_000,
|
||||||
|
resume_from: None,
|
||||||
|
log_every: 50,
|
||||||
|
eval_every: 5_000,
|
||||||
|
num_gpus: 1,
|
||||||
|
tensor_parallel: 1,
|
||||||
|
data_parallel: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TOML-like config parser
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Parse a `key = value` text block into a [`JepaRunConfig`].
|
||||||
|
///
|
||||||
|
/// Supported value types:
|
||||||
|
/// - String: `key = "value"` or `key = value`
|
||||||
|
/// - Integer: `key = 1000`
|
||||||
|
/// - Float: `key = 1.5e-4`
|
||||||
|
/// - Boolean: `key = true` / `key = false`
|
||||||
|
/// - String array: `key = ["a", "b"]`
|
||||||
|
///
|
||||||
|
/// Blank lines and lines starting with `#` are ignored.
|
||||||
|
/// Returns `Err("line N: <reason>")` on the first problem.
|
||||||
|
pub fn parse_config_from_str(s: &str) -> Result<JepaRunConfig, String> {
|
||||||
|
let mut cfg = JepaRunConfig::default();
|
||||||
|
|
||||||
|
for (line_idx, raw_line) in s.lines().enumerate() {
|
||||||
|
let line_no = line_idx + 1;
|
||||||
|
let line = raw_line.trim();
|
||||||
|
|
||||||
|
// Skip blank lines and comments
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split on first '='
|
||||||
|
let eq_pos = line.find('=').ok_or_else(|| {
|
||||||
|
format!("line {line_no}: expected 'key = value', got: {line}")
|
||||||
|
})?;
|
||||||
|
let key = line[..eq_pos].trim();
|
||||||
|
let raw_val = line[eq_pos + 1..].trim();
|
||||||
|
|
||||||
|
match key {
|
||||||
|
// --- Model ---
|
||||||
|
"vit_size" => {
|
||||||
|
let s = parse_string_value(raw_val, line_no)?;
|
||||||
|
cfg.vit_size = parse_vit_size_str(&s, line_no)?;
|
||||||
|
}
|
||||||
|
"image_size" => {
|
||||||
|
cfg.image_size = parse_usize_value(raw_val, line_no)?;
|
||||||
|
}
|
||||||
|
"patch_size" => {
|
||||||
|
cfg.patch_size = parse_usize_value(raw_val, line_no)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Training schedule ---
|
||||||
|
"total_steps" => {
|
||||||
|
cfg.total_steps = parse_usize_value(raw_val, line_no)?;
|
||||||
|
}
|
||||||
|
"warmup_steps" => {
|
||||||
|
cfg.warmup_steps = parse_usize_value(raw_val, line_no)?;
|
||||||
|
}
|
||||||
|
"base_lr" => {
|
||||||
|
cfg.base_lr = parse_f32_value(raw_val, line_no)?;
|
||||||
|
}
|
||||||
|
"weight_decay" => {
|
||||||
|
cfg.weight_decay = parse_f32_value(raw_val, line_no)?;
|
||||||
|
}
|
||||||
|
"ema_tau_start" => {
|
||||||
|
cfg.ema_tau_start = parse_f32_value(raw_val, line_no)?;
|
||||||
|
}
|
||||||
|
"ema_tau_end" => {
|
||||||
|
cfg.ema_tau_end = parse_f32_value(raw_val, line_no)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Data ---
|
||||||
|
"batch_size" => {
|
||||||
|
cfg.batch_size = parse_usize_value(raw_val, line_no)?;
|
||||||
|
}
|
||||||
|
"num_workers" => {
|
||||||
|
cfg.num_workers = parse_usize_value(raw_val, line_no)?;
|
||||||
|
}
|
||||||
|
"data_shards" => {
|
||||||
|
cfg.data_shards = parse_string_array_value(raw_val, line_no)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Checkpointing ---
|
||||||
|
"checkpoint_dir" => {
|
||||||
|
cfg.checkpoint_dir = parse_string_value(raw_val, line_no)?;
|
||||||
|
}
|
||||||
|
"checkpoint_every" => {
|
||||||
|
cfg.checkpoint_every = parse_usize_value(raw_val, line_no)?;
|
||||||
|
}
|
||||||
|
"resume_from" => {
|
||||||
|
let s = parse_string_value(raw_val, line_no)?;
|
||||||
|
cfg.resume_from = if s.is_empty() { None } else { Some(s) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Logging ---
|
||||||
|
"log_every" => {
|
||||||
|
cfg.log_every = parse_usize_value(raw_val, line_no)?;
|
||||||
|
}
|
||||||
|
"eval_every" => {
|
||||||
|
cfg.eval_every = parse_usize_value(raw_val, line_no)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cluster ---
|
||||||
|
"num_gpus" => {
|
||||||
|
cfg.num_gpus = parse_usize_value(raw_val, line_no)?;
|
||||||
|
}
|
||||||
|
"tensor_parallel" => {
|
||||||
|
cfg.tensor_parallel = parse_usize_value(raw_val, line_no)?;
|
||||||
|
}
|
||||||
|
"data_parallel" => {
|
||||||
|
cfg.data_parallel = parse_usize_value(raw_val, line_no)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
other => {
|
||||||
|
return Err(format!("line {line_no}: unknown key '{other}'"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- parser helpers --------------------------------------------------------
|
||||||
|
|
||||||
|
fn parse_string_value(raw: &str, line_no: usize) -> Result<String, String> {
|
||||||
|
let s = raw.trim();
|
||||||
|
if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 {
|
||||||
|
Ok(s[1..s.len() - 1].to_string())
|
||||||
|
} else if !s.contains('"') && !s.contains('[') {
|
||||||
|
// Unquoted bare word
|
||||||
|
Ok(s.to_string())
|
||||||
|
} else {
|
||||||
|
Err(format!("line {line_no}: expected a string value, got: {s}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_usize_value(raw: &str, line_no: usize) -> Result<usize, String> {
|
||||||
|
raw.trim()
|
||||||
|
.parse::<usize>()
|
||||||
|
.map_err(|_| format!("line {line_no}: expected an integer, got: {raw}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_f32_value(raw: &str, line_no: usize) -> Result<f32, String> {
|
||||||
|
raw.trim()
|
||||||
|
.parse::<f32>()
|
||||||
|
.map_err(|_| format!("line {line_no}: expected a float, got: {raw}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_string_array_value(raw: &str, line_no: usize) -> Result<Vec<String>, String> {
|
||||||
|
let s = raw.trim();
|
||||||
|
if !s.starts_with('[') || !s.ends_with(']') {
|
||||||
|
return Err(format!("line {line_no}: expected a string array [\"a\", \"b\"], got: {s}"));
|
||||||
|
}
|
||||||
|
let inner = &s[1..s.len() - 1];
|
||||||
|
if inner.trim().is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
// Split on ',' and parse each quoted string
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for part in inner.split(',') {
|
||||||
|
let part = part.trim();
|
||||||
|
if part.starts_with('"') && part.ends_with('"') && part.len() >= 2 {
|
||||||
|
out.push(part[1..part.len() - 1].to_string());
|
||||||
|
} else if !part.is_empty() {
|
||||||
|
// Tolerate unquoted items
|
||||||
|
out.push(part.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_vit_size_str(s: &str, line_no: usize) -> Result<ViTSizeStr, String> {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"tiny" => Ok(ViTSizeStr::Tiny),
|
||||||
|
"small" => Ok(ViTSizeStr::Small),
|
||||||
|
"base" => Ok(ViTSizeStr::Base),
|
||||||
|
"large" => Ok(ViTSizeStr::Large),
|
||||||
|
"huge" => Ok(ViTSizeStr::Huge),
|
||||||
|
other => Err(format!("line {line_no}: unknown vit_size '{other}'; expected tiny|small|base|large|huge")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// JepaStepResult
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Metrics returned from one JEPA training step.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct JepaStepResult {
|
||||||
|
/// Current training step (1-indexed)
|
||||||
|
pub step: usize,
|
||||||
|
/// Mean L2 loss for this step
|
||||||
|
pub loss: f32,
|
||||||
|
/// EMA momentum τ used for the target encoder update
|
||||||
|
pub ema_tau: f32,
|
||||||
|
/// Effective learning rate at this step
|
||||||
|
pub lr: f32,
|
||||||
|
/// Batch size used
|
||||||
|
pub batch_size: usize,
|
||||||
|
/// Wall-clock time for this step in milliseconds
|
||||||
|
pub wall_ms: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// JepaCheckpoint
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// A checkpoint captures the current training state.
|
||||||
|
/// Serialization to disk writes a JSON summary file;
|
||||||
|
/// full GPU state serialization is a future feature.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct JepaCheckpoint {
|
||||||
|
/// Step at which this checkpoint was saved
|
||||||
|
pub step: usize,
|
||||||
|
/// Running loss history (one value per step)
|
||||||
|
pub loss_history: Vec<f32>,
|
||||||
|
/// Training configuration
|
||||||
|
pub config: JepaRunConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JepaCheckpoint {
|
||||||
|
/// Write a JSON summary to `{dir}/step_{step:07}.json`.
|
||||||
|
///
|
||||||
|
/// The JSON contains: `step`, `mean_loss` (last 100 steps), and key config fields.
|
||||||
|
pub fn save(&self, dir: &str) -> Result<(), String> {
|
||||||
|
// Ensure directory exists
|
||||||
|
std::fs::create_dir_all(dir)
|
||||||
|
.map_err(|e| format!("Cannot create checkpoint dir '{dir}': {e}"))?;
|
||||||
|
|
||||||
|
let path = format!("{}/step_{:07}.json", dir, self.step);
|
||||||
|
|
||||||
|
// Compute mean loss over last 100 steps
|
||||||
|
let recent: Vec<f32> = self.loss_history.iter().rev().take(100).cloned().collect();
|
||||||
|
let mean_loss = if recent.is_empty() {
|
||||||
|
0.0f32
|
||||||
|
} else {
|
||||||
|
recent.iter().sum::<f32>() / recent.len() as f32
|
||||||
|
};
|
||||||
|
|
||||||
|
let cfg = &self.config;
|
||||||
|
let vit_size_str = match cfg.vit_size {
|
||||||
|
ViTSizeStr::Tiny => "tiny",
|
||||||
|
ViTSizeStr::Small => "small",
|
||||||
|
ViTSizeStr::Base => "base",
|
||||||
|
ViTSizeStr::Large => "large",
|
||||||
|
ViTSizeStr::Huge => "huge",
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = format!(
|
||||||
|
"{{\
|
||||||
|
\"step\": {step},\
|
||||||
|
\"mean_loss\": {mean_loss:.6},\
|
||||||
|
\"vit_size\": \"{vit_size}\",\
|
||||||
|
\"image_size\": {image_size},\
|
||||||
|
\"patch_size\": {patch_size},\
|
||||||
|
\"total_steps\": {total_steps},\
|
||||||
|
\"warmup_steps\": {warmup_steps},\
|
||||||
|
\"base_lr\": {base_lr},\
|
||||||
|
\"weight_decay\": {weight_decay},\
|
||||||
|
\"ema_tau_start\": {ema_tau_start},\
|
||||||
|
\"ema_tau_end\": {ema_tau_end},\
|
||||||
|
\"batch_size\": {batch_size},\
|
||||||
|
\"checkpoint_dir\": \"{checkpoint_dir}\"\
|
||||||
|
}}",
|
||||||
|
step = self.step,
|
||||||
|
mean_loss = mean_loss,
|
||||||
|
vit_size = vit_size_str,
|
||||||
|
image_size = cfg.image_size,
|
||||||
|
patch_size = cfg.patch_size,
|
||||||
|
total_steps = cfg.total_steps,
|
||||||
|
warmup_steps = cfg.warmup_steps,
|
||||||
|
base_lr = cfg.base_lr,
|
||||||
|
weight_decay = cfg.weight_decay,
|
||||||
|
ema_tau_start = cfg.ema_tau_start,
|
||||||
|
ema_tau_end = cfg.ema_tau_end,
|
||||||
|
batch_size = cfg.batch_size,
|
||||||
|
checkpoint_dir = cfg.checkpoint_dir,
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::write(&path, json)
|
||||||
|
.map_err(|e| format!("Cannot write checkpoint '{path}': {e}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a checkpoint from disk.
|
||||||
|
///
|
||||||
|
/// **Stub**: full GPU state (encoder weights) is not yet serializable.
|
||||||
|
pub fn load(_path: &str) -> Result<Self, String> {
|
||||||
|
Err("GPU state not yet serializable".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LR Schedule
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Cosine decay with linear warm-up.
|
||||||
|
///
|
||||||
|
/// - Warm-up: `lr = base_lr * (step / warmup_steps)` for `step < warmup_steps`
|
||||||
|
/// - Cosine: `lr = base_lr * 0.5 * (1 + cos(π * progress))` where
|
||||||
|
/// `progress = (step - warmup_steps) / (total_steps - warmup_steps)`
|
||||||
|
pub fn compute_lr(step: usize, config: &JepaRunConfig) -> f32 {
|
||||||
|
let warmup = config.warmup_steps as f32;
|
||||||
|
let total = config.total_steps as f32;
|
||||||
|
let s = step as f32;
|
||||||
|
|
||||||
|
let warmup_factor = (s / warmup.max(1.0)).min(1.0);
|
||||||
|
let decay_progress = (s - warmup).max(0.0) / (total - warmup).max(1.0);
|
||||||
|
let cosine_factor = 0.5 * (1.0 + (std::f32::consts::PI * decay_progress).cos());
|
||||||
|
|
||||||
|
config.base_lr * warmup_factor * cosine_factor
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Synthetic batch generator (LCG)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// One synthetic image record: `image_size × image_size × 3` f32 pixels in [0, 1].
|
||||||
|
fn synthetic_batch_size_one(image_size: usize, lcg: &mut u64) -> f32 {
|
||||||
|
// We only need the *batch size* arg downstream; here we advance the LCG for
|
||||||
|
// each pixel to simulate real data ingestion.
|
||||||
|
let pixels = image_size * image_size * 3;
|
||||||
|
let mut checksum = 0.0f32;
|
||||||
|
for _ in 0..pixels {
|
||||||
|
*lcg = lcg
|
||||||
|
.wrapping_mul(6364136223846793005)
|
||||||
|
.wrapping_add(1442695040888963407);
|
||||||
|
let v = (*lcg >> 11) as f32 / (1u64 << 53) as f32;
|
||||||
|
checksum += v;
|
||||||
|
}
|
||||||
|
checksum // return something to prevent the compiler from eliding the loop
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// JepaTrainingSummary
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Summary statistics returned after a full training run.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct JepaTrainingSummary {
|
||||||
|
/// Total number of steps completed
|
||||||
|
pub total_steps: usize,
|
||||||
|
/// Loss at the last step
|
||||||
|
pub final_loss: f32,
|
||||||
|
/// Mean loss over the last 100 steps
|
||||||
|
pub mean_loss: f32,
|
||||||
|
/// Average steps per wall-clock second
|
||||||
|
pub steps_per_second: f32,
|
||||||
|
/// Number of checkpoints saved to disk
|
||||||
|
pub checkpoints_saved: usize,
|
||||||
|
/// Total wall-clock training time in seconds
|
||||||
|
pub wall_time_seconds: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main training loop
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Run a complete JEPA training session.
|
||||||
|
///
|
||||||
|
/// Uses [`JepaTrainerV2`] with [`CpuViTEncoder`] (GPU dispatch is future work).
|
||||||
|
/// When `config.data_shards` is empty, synthetic pixel data is generated using an
|
||||||
|
/// LCG seeded with `config.batch_size` to emulate real data loading.
|
||||||
|
///
|
||||||
|
/// Returns aggregate [`JepaTrainingSummary`] after all steps complete.
|
||||||
|
pub fn run_jepa_training(config: JepaRunConfig) -> JepaTrainingSummary {
|
||||||
|
// 1. Build ViT config
|
||||||
|
let vit_cfg = config.vit_size.to_vit_config(config.image_size, config.patch_size);
|
||||||
|
|
||||||
|
// 2. Create trainer
|
||||||
|
let mut trainer = JepaTrainerV2::new(
|
||||||
|
vit_cfg,
|
||||||
|
config.total_steps,
|
||||||
|
config.ema_tau_start as f64,
|
||||||
|
config.ema_tau_end as f64,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. LCG seed for synthetic data
|
||||||
|
let mut lcg: u64 = config.batch_size as u64;
|
||||||
|
if lcg == 0 {
|
||||||
|
lcg = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Training state
|
||||||
|
let mut loss_history: Vec<f32> = Vec::with_capacity(config.total_steps);
|
||||||
|
let mut checkpoints_saved = 0usize;
|
||||||
|
let training_start = Instant::now();
|
||||||
|
let mut step_start = Instant::now();
|
||||||
|
|
||||||
|
// Steps-per-second tracking over last window
|
||||||
|
let log_window_start_step: usize = 0;
|
||||||
|
let mut window_start_time = Instant::now();
|
||||||
|
let mut window_start_step = log_window_start_step;
|
||||||
|
|
||||||
|
for step in 1..=config.total_steps {
|
||||||
|
// Generate or fetch batch
|
||||||
|
if config.data_shards.is_empty() {
|
||||||
|
// Synthetic: advance LCG to simulate data ingestion
|
||||||
|
let _checksum = synthetic_batch_size_one(config.image_size, &mut lcg);
|
||||||
|
}
|
||||||
|
// (Real shard loading would go here when data_shards is non-empty)
|
||||||
|
|
||||||
|
let step_t0 = Instant::now();
|
||||||
|
|
||||||
|
// Execute one training step
|
||||||
|
let metrics = trainer.train_step(config.batch_size.max(1));
|
||||||
|
|
||||||
|
let wall_ms = step_t0.elapsed().as_secs_f32() * 1000.0;
|
||||||
|
let lr = compute_lr(step, &config);
|
||||||
|
|
||||||
|
let step_result = JepaStepResult {
|
||||||
|
step,
|
||||||
|
loss: metrics.loss,
|
||||||
|
ema_tau: metrics.ema_tau as f32,
|
||||||
|
lr,
|
||||||
|
batch_size: config.batch_size,
|
||||||
|
wall_ms,
|
||||||
|
};
|
||||||
|
|
||||||
|
loss_history.push(step_result.loss);
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
if config.log_every > 0 && step % config.log_every == 0 {
|
||||||
|
let elapsed_secs = window_start_time.elapsed().as_secs_f32();
|
||||||
|
let steps_in_window = (step - window_start_step) as f32;
|
||||||
|
let steps_per_sec = if elapsed_secs > 0.0 {
|
||||||
|
steps_in_window / elapsed_secs
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"Step {}/{}: loss={:.4} τ={:.5} lr={:.2e} ({:.1} steps/s)",
|
||||||
|
step,
|
||||||
|
config.total_steps,
|
||||||
|
step_result.loss,
|
||||||
|
step_result.ema_tau,
|
||||||
|
step_result.lr,
|
||||||
|
steps_per_sec,
|
||||||
|
);
|
||||||
|
window_start_time = Instant::now();
|
||||||
|
window_start_step = step;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkpointing
|
||||||
|
if config.checkpoint_every > 0 && step % config.checkpoint_every == 0 {
|
||||||
|
let ckpt = JepaCheckpoint {
|
||||||
|
step,
|
||||||
|
loss_history: loss_history.clone(),
|
||||||
|
config: config.clone(),
|
||||||
|
};
|
||||||
|
if let Ok(()) = ckpt.save(&config.checkpoint_dir) {
|
||||||
|
checkpoints_saved += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = step_start; // suppress lint
|
||||||
|
step_start = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
let wall_time_seconds = training_start.elapsed().as_secs_f32();
|
||||||
|
let steps_per_second = if wall_time_seconds > 0.0 {
|
||||||
|
config.total_steps as f32 / wall_time_seconds
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let final_loss = loss_history.last().cloned().unwrap_or(0.0);
|
||||||
|
let recent: Vec<f32> = loss_history.iter().rev().take(100).cloned().collect();
|
||||||
|
let mean_loss = if recent.is_empty() {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
recent.iter().sum::<f32>() / recent.len() as f32
|
||||||
|
};
|
||||||
|
|
||||||
|
JepaTrainingSummary {
|
||||||
|
total_steps: config.total_steps,
|
||||||
|
final_loss,
|
||||||
|
mean_loss,
|
||||||
|
steps_per_second,
|
||||||
|
checkpoints_saved,
|
||||||
|
wall_time_seconds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// 1. Default config sanity
|
||||||
|
#[test]
|
||||||
|
fn test_default_config_valid() {
|
||||||
|
let cfg = JepaRunConfig::default();
|
||||||
|
assert_eq!(cfg.total_steps, 125_000);
|
||||||
|
assert!(matches!(cfg.vit_size, ViTSizeStr::Tiny));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Parse empty string → defaults
|
||||||
|
#[test]
|
||||||
|
fn test_parse_config_empty() {
|
||||||
|
let cfg = parse_config_from_str("").expect("empty string should parse OK");
|
||||||
|
assert_eq!(cfg.total_steps, 125_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Parse vit_size = "large"
|
||||||
|
#[test]
|
||||||
|
fn test_parse_config_vit_size() {
|
||||||
|
let cfg = parse_config_from_str("vit_size = \"large\"").unwrap();
|
||||||
|
assert_eq!(cfg.vit_size, ViTSizeStr::Large);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Parse total_steps integer
|
||||||
|
#[test]
|
||||||
|
fn test_parse_config_total_steps() {
|
||||||
|
let cfg = parse_config_from_str("total_steps = 50000").unwrap();
|
||||||
|
assert_eq!(cfg.total_steps, 50_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Parse base_lr float
|
||||||
|
#[test]
|
||||||
|
fn test_parse_config_base_lr() {
|
||||||
|
let cfg = parse_config_from_str("base_lr = 3e-4").unwrap();
|
||||||
|
assert!((cfg.base_lr - 3e-4_f32).abs() < 1e-8, "base_lr mismatch: {}", cfg.base_lr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Parse data_shards string array
|
||||||
|
#[test]
|
||||||
|
fn test_parse_config_data_shards() {
|
||||||
|
let cfg = parse_config_from_str("data_shards = [\"a.tar\", \"b.tar\"]").unwrap();
|
||||||
|
assert_eq!(cfg.data_shards.len(), 2);
|
||||||
|
assert_eq!(cfg.data_shards[0], "a.tar");
|
||||||
|
assert_eq!(cfg.data_shards[1], "b.tar");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Unknown key → Err containing the key name
|
||||||
|
#[test]
|
||||||
|
fn test_parse_config_unknown_key_error() {
|
||||||
|
let result = parse_config_from_str("unknown_key = 1");
|
||||||
|
assert!(result.is_err(), "unknown_key should produce Err");
|
||||||
|
let msg = result.unwrap_err();
|
||||||
|
assert!(msg.contains("unknown_key"), "error should mention the key: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. LR at warmup boundary == base_lr
|
||||||
|
#[test]
|
||||||
|
fn test_lr_schedule_warmup() {
|
||||||
|
let cfg = JepaRunConfig {
|
||||||
|
total_steps: 1000,
|
||||||
|
warmup_steps: 100,
|
||||||
|
base_lr: 1.5e-4,
|
||||||
|
..JepaRunConfig::default()
|
||||||
|
};
|
||||||
|
let lr = compute_lr(100, &cfg);
|
||||||
|
// At step == warmup_steps: warmup_factor = 1.0, decay_progress = 0 → cosine = 1
|
||||||
|
assert!((lr - cfg.base_lr).abs() < 1e-9, "lr at warmup should be base_lr, got {lr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. LR at step 0 ≈ 0 (when warmup_steps > 0)
|
||||||
|
#[test]
|
||||||
|
fn test_lr_schedule_start() {
|
||||||
|
let cfg = JepaRunConfig {
|
||||||
|
total_steps: 1000,
|
||||||
|
warmup_steps: 100,
|
||||||
|
base_lr: 1.5e-4,
|
||||||
|
..JepaRunConfig::default()
|
||||||
|
};
|
||||||
|
let lr = compute_lr(0, &cfg);
|
||||||
|
// warmup_factor = 0/100 = 0 → lr = 0
|
||||||
|
assert!(lr.abs() < 1e-9, "lr at step 0 should be ~0, got {lr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. LR at total_steps ≈ 0 (cosine decayed to 0)
|
||||||
|
#[test]
|
||||||
|
fn test_lr_schedule_end() {
|
||||||
|
let cfg = JepaRunConfig {
|
||||||
|
total_steps: 1000,
|
||||||
|
warmup_steps: 100,
|
||||||
|
base_lr: 1.5e-4,
|
||||||
|
..JepaRunConfig::default()
|
||||||
|
};
|
||||||
|
let lr = compute_lr(1000, &cfg);
|
||||||
|
// decay_progress = 1.0 → cos(π) = -1 → cosine_factor = 0
|
||||||
|
assert!(lr.abs() < 1e-7, "lr at total_steps should be ~0, got {lr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Full 10-step run completes and returns correct step count
|
||||||
|
#[test]
|
||||||
|
fn test_run_tiny_10_steps() {
|
||||||
|
let config = JepaRunConfig {
|
||||||
|
vit_size: ViTSizeStr::Tiny,
|
||||||
|
total_steps: 10,
|
||||||
|
warmup_steps: 2,
|
||||||
|
log_every: 100, // suppress output
|
||||||
|
checkpoint_every: 10_000, // no checkpoint during test
|
||||||
|
batch_size: 1, // keep it fast
|
||||||
|
..JepaRunConfig::default()
|
||||||
|
};
|
||||||
|
let summary = run_jepa_training(config);
|
||||||
|
assert_eq!(summary.total_steps, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. Final loss is finite and positive
|
||||||
|
#[test]
|
||||||
|
fn test_run_returns_finite_loss() {
|
||||||
|
let config = JepaRunConfig {
|
||||||
|
vit_size: ViTSizeStr::Tiny,
|
||||||
|
total_steps: 5,
|
||||||
|
warmup_steps: 1,
|
||||||
|
log_every: 100,
|
||||||
|
checkpoint_every: 10_000,
|
||||||
|
batch_size: 1,
|
||||||
|
..JepaRunConfig::default()
|
||||||
|
};
|
||||||
|
let summary = run_jepa_training(config);
|
||||||
|
assert!(summary.final_loss.is_finite(), "final_loss must be finite");
|
||||||
|
assert!(summary.final_loss > 0.0, "final_loss must be positive");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13. steps_per_second is positive
|
||||||
|
#[test]
|
||||||
|
fn test_run_throughput_positive() {
|
||||||
|
let config = JepaRunConfig {
|
||||||
|
vit_size: ViTSizeStr::Tiny,
|
||||||
|
total_steps: 3,
|
||||||
|
warmup_steps: 1,
|
||||||
|
log_every: 100,
|
||||||
|
checkpoint_every: 10_000,
|
||||||
|
batch_size: 1,
|
||||||
|
..JepaRunConfig::default()
|
||||||
|
};
|
||||||
|
let summary = run_jepa_training(config);
|
||||||
|
assert!(summary.steps_per_second > 0.0, "steps_per_second must be positive");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 14. JepaCheckpoint::load on non-existent path → Err
|
||||||
|
#[test]
|
||||||
|
fn test_checkpoint_save_stub() {
|
||||||
|
let result = JepaCheckpoint::load("nonexistent/path/checkpoint.json");
|
||||||
|
assert!(result.is_err(), "load should return Err (not yet serializable)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 15. JepaStepResult fields after 1 step
|
||||||
|
#[test]
|
||||||
|
fn test_step_result_fields() {
|
||||||
|
// We exercise JepaTrainerV2 directly to get a JepaViTStepMetrics, then
|
||||||
|
// build a JepaStepResult manually (matching what run_jepa_training does).
|
||||||
|
use super::super::jepa_vit::{JepaTrainerV2, JepaViTConfig};
|
||||||
|
let vit_cfg = JepaViTConfig {
|
||||||
|
embed_dim: 32,
|
||||||
|
depth: 2,
|
||||||
|
num_heads: 4,
|
||||||
|
mlp_ratio: 2.0,
|
||||||
|
patch_size: 16,
|
||||||
|
image_size: 64,
|
||||||
|
};
|
||||||
|
let mut trainer = JepaTrainerV2::new(vit_cfg, 100, 0.996, 1.0);
|
||||||
|
let metrics = trainer.train_step(1);
|
||||||
|
|
||||||
|
let cfg = JepaRunConfig {
|
||||||
|
total_steps: 100,
|
||||||
|
warmup_steps: 10,
|
||||||
|
base_lr: 1.5e-4,
|
||||||
|
ema_tau_start: 0.996,
|
||||||
|
ema_tau_end: 1.0,
|
||||||
|
..JepaRunConfig::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = JepaStepResult {
|
||||||
|
step: 1,
|
||||||
|
loss: metrics.loss,
|
||||||
|
ema_tau: metrics.ema_tau as f32,
|
||||||
|
lr: compute_lr(1, &cfg),
|
||||||
|
batch_size: 1,
|
||||||
|
wall_ms: 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(result.loss > 0.0, "loss must be positive");
|
||||||
|
assert!(
|
||||||
|
result.ema_tau >= 0.99 && result.ema_tau <= 1.0,
|
||||||
|
"ema_tau out of range: {}",
|
||||||
|
result.ema_tau
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
//! Bridge between rtx-vision ViT and the JepaEncoder trait.
|
||||||
|
//!
|
||||||
|
//! `RtxVisionJepaEncoder` wraps `rtx_vision::models::ViT` and implements
|
||||||
|
//! `JepaEncoder`. Currently `encode()` calls `ViT::encode_patch_indices()`
|
||||||
|
//! which returns zeros — a shape-correct placeholder until the Tensor backend
|
||||||
|
//! supports row extraction and GPU dispatch. All other trait methods
|
||||||
|
//! (`embed_dim`, `num_patches`) are fully correct.
|
||||||
|
//!
|
||||||
|
//! Usage (requires `vision-bridge` feature):
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use rtx_transformers::ssl::jepa_vision_bridge::RtxVisionJepaEncoder;
|
||||||
|
//! use rtx_vision::models::ViT;
|
||||||
|
//! use rtx_vision::ViTConfig;
|
||||||
|
//! use rtx_vision::Device;
|
||||||
|
//!
|
||||||
|
//! let device = Device::default();
|
||||||
|
//! let vit = ViT::new(ViTConfig::large_16(), &device).unwrap();
|
||||||
|
//! let encoder = RtxVisionJepaEncoder::new(vit, ViTConfig::large_16());
|
||||||
|
//! let reps = encoder.encode(&[0, 1, 2, 3]); // [4 * 1024] f32
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#[cfg(feature = "vision-bridge")]
|
||||||
|
use rtx_vision::{ViT, ViTConfig};
|
||||||
|
|
||||||
|
use crate::ssl::jepa_vit::JepaEncoder;
|
||||||
|
|
||||||
|
/// Wraps `rtx-vision::ViT` to implement the `JepaEncoder` trait.
|
||||||
|
///
|
||||||
|
/// `encode()` is shape-correct but returns zeros until `ViT::encode_patch_indices()`
|
||||||
|
/// is wired to a real GPU tensor backend (Batch 27 GPU dispatch).
|
||||||
|
#[cfg(feature = "vision-bridge")]
|
||||||
|
pub struct RtxVisionJepaEncoder {
|
||||||
|
vit: ViT,
|
||||||
|
config: ViTConfig,
|
||||||
|
image_size: usize,
|
||||||
|
patch_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "vision-bridge")]
|
||||||
|
impl RtxVisionJepaEncoder {
|
||||||
|
pub fn new(vit: ViT, config: ViTConfig) -> Self {
|
||||||
|
let image_size = config.image_size;
|
||||||
|
let patch_size = config.patch_size;
|
||||||
|
Self { vit, config, image_size, patch_size }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total patches along one grid dimension.
|
||||||
|
fn grid_size(&self) -> usize {
|
||||||
|
self.image_size / self.patch_size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "vision-bridge")]
|
||||||
|
impl JepaEncoder for RtxVisionJepaEncoder {
|
||||||
|
fn encode(&self, patch_indices: &[usize]) -> Vec<f32> {
|
||||||
|
// Returns zeros with correct shape [n_patches, embed_dim].
|
||||||
|
// Real values come when ViT::encode_patch_indices uses GPU tensor ops.
|
||||||
|
vec![0.0f32; patch_indices.len() * self.config.embed_dim]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn embed_dim(&self) -> usize {
|
||||||
|
self.config.embed_dim
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num_patches(&self) -> usize {
|
||||||
|
let g = self.grid_size();
|
||||||
|
g * g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config conversion: ViTConfig <-> JepaViTConfig.
|
||||||
|
// Gated on vision-bridge so that rtx-vision is only required when the feature is active.
|
||||||
|
use crate::ssl::jepa_vit::JepaViTConfig;
|
||||||
|
|
||||||
|
#[cfg(feature = "vision-bridge")]
|
||||||
|
impl From<&ViTConfig> for JepaViTConfig {
|
||||||
|
fn from(c: &ViTConfig) -> Self {
|
||||||
|
JepaViTConfig {
|
||||||
|
embed_dim: c.embed_dim,
|
||||||
|
depth: c.depth,
|
||||||
|
num_heads: c.num_heads,
|
||||||
|
mlp_ratio: c.mlp_ratio,
|
||||||
|
patch_size: c.patch_size,
|
||||||
|
image_size: c.image_size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "vision-bridge"))]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rtx_vision::{Device, ViTConfig};
|
||||||
|
|
||||||
|
fn make_encoder(config: ViTConfig) -> RtxVisionJepaEncoder {
|
||||||
|
let device = Device::default();
|
||||||
|
let vit = ViT::new(config.clone(), &device).expect("ViT::new failed");
|
||||||
|
RtxVisionJepaEncoder::new(vit, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 1. ViTConfig::small_14().embed_dim == 384
|
||||||
|
#[test]
|
||||||
|
fn test_rtx_vision_encoder_embed_dim() {
|
||||||
|
let enc = make_encoder(ViTConfig::small_14());
|
||||||
|
assert_eq!(enc.embed_dim(), 384);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 2. large_16: (224/16)^2 == 196
|
||||||
|
#[test]
|
||||||
|
fn test_rtx_vision_encoder_num_patches_16() {
|
||||||
|
let enc = make_encoder(ViTConfig::large_16());
|
||||||
|
assert_eq!(enc.num_patches(), 196);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 3. huge_14: (224/14)^2 == 256
|
||||||
|
#[test]
|
||||||
|
fn test_rtx_vision_encoder_num_patches_14() {
|
||||||
|
let enc = make_encoder(ViTConfig::huge_14());
|
||||||
|
assert_eq!(enc.num_patches(), 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 4. encode(&[0,1,2]) returns len == 3 * embed_dim
|
||||||
|
#[test]
|
||||||
|
fn test_rtx_vision_encoder_encode_shape() {
|
||||||
|
let cfg = ViTConfig::small_14();
|
||||||
|
let expected = 3 * cfg.embed_dim;
|
||||||
|
let enc = make_encoder(cfg);
|
||||||
|
let out = enc.encode(&[0, 1, 2]);
|
||||||
|
assert_eq!(out.len(), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 5. encode(&[]) returns empty vec
|
||||||
|
#[test]
|
||||||
|
fn test_rtx_vision_encoder_encode_empty() {
|
||||||
|
let enc = make_encoder(ViTConfig::small_14());
|
||||||
|
let out = enc.encode(&[]);
|
||||||
|
assert!(out.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 6. JepaViTConfig::from(&ViTConfig::tiny()).embed_dim == 192
|
||||||
|
#[test]
|
||||||
|
fn test_config_conversion_tiny() {
|
||||||
|
let vit_cfg = ViTConfig::tiny();
|
||||||
|
let jepa_cfg = JepaViTConfig::from(&vit_cfg);
|
||||||
|
assert_eq!(jepa_cfg.embed_dim, 192);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 7. JepaViTConfig::from(&ViTConfig::large_16()).depth == 24
|
||||||
|
#[test]
|
||||||
|
fn test_config_conversion_large() {
|
||||||
|
let vit_cfg = ViTConfig::large_16();
|
||||||
|
let jepa_cfg = JepaViTConfig::from(&vit_cfg);
|
||||||
|
assert_eq!(jepa_cfg.depth, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 8. Static Send + Sync assertion
|
||||||
|
#[test]
|
||||||
|
fn test_encoder_is_send_sync() {
|
||||||
|
fn assert_send_sync<T: Send + Sync>() {}
|
||||||
|
assert_send_sync::<RtxVisionJepaEncoder>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -314,8 +314,14 @@
|
|||||||
pub mod jepa;
|
pub mod jepa;
|
||||||
pub mod jepa_cluster;
|
pub mod jepa_cluster;
|
||||||
pub mod jepa_data;
|
pub mod jepa_data;
|
||||||
|
pub mod jepa_runner;
|
||||||
pub mod jepa_vit;
|
pub mod jepa_vit;
|
||||||
|
pub mod jepa_vision_bridge;
|
||||||
pub mod vjepa;
|
pub mod vjepa;
|
||||||
|
pub use jepa_runner::{
|
||||||
|
JepaRunConfig, JepaTrainingSummary, JepaCheckpoint, JepaStepResult,
|
||||||
|
run_jepa_training, parse_config_from_str, ViTSizeStr,
|
||||||
|
};
|
||||||
pub use jepa_cluster::{
|
pub use jepa_cluster::{
|
||||||
GpuSpec, NodeSpec, ClusterTopology, FabricType,
|
GpuSpec, NodeSpec, ClusterTopology, FabricType,
|
||||||
JepaParallelConfig, FsdpSharding, GradientCompressionConfig, CompressionMethod,
|
JepaParallelConfig, FsdpSharding, GradientCompressionConfig, CompressionMethod,
|
||||||
@@ -326,6 +332,8 @@ pub use jepa_vit::{
|
|||||||
JepaEncoder, JepaViTConfig, CpuViTEncoder, EmaViTEncoder,
|
JepaEncoder, JepaViTConfig, CpuViTEncoder, EmaViTEncoder,
|
||||||
JepaTrainerV2, JepaViTStepMetrics,
|
JepaTrainerV2, JepaViTStepMetrics,
|
||||||
};
|
};
|
||||||
|
#[cfg(feature = "vision-bridge")]
|
||||||
|
pub use jepa_vision_bridge::RtxVisionJepaEncoder;
|
||||||
pub use jepa::{
|
pub use jepa::{
|
||||||
ViTSize, JepaConfig, BlockMaskConfig, BlockMaskResult, BlockMaskStrategy,
|
ViTSize, JepaConfig, BlockMaskConfig, BlockMaskResult, BlockMaskStrategy,
|
||||||
JepaPredictor, JepaLossResult, jepa_loss, EmaTargetEncoder,
|
JepaPredictor, JepaLossResult, jepa_loss, EmaTargetEncoder,
|
||||||
|
|||||||
Reference in New Issue
Block a user