Files
foxhunt/ml/tests/tft_int8_quantization_test.rs
jgrusewski aac0597cd2 feat(ml): DQN Option B checkpoint fix + TFT OOM investigation
- Fixed DQN early stopping checkpoint naming bug (Option B)
  - Added is_final: bool parameter to checkpoint callback signature
  - Trainer now distinguishes final checkpoints from regular epoch checkpoints
  - Final checkpoints use 'dqn_final_epoch{N}' naming convention
  - Regular checkpoints use 'dqn_epoch_{N}' naming convention

- Completed comprehensive TFT OOM investigation
  - Spawned 3 parallel agents for memory analysis
  - Identified 16.4GB memory leak (29.7x over expected 525-550MB)
  - Root causes: Attention cache bloat (960MB), gradient accumulation bug, detached tensors
  - Recommended fixes: Disable cache during training, explicit tensor drops
  - Created TFT_MEMORY_ANALYSIS.md, TFT_MEMORY_LEAK_ANALYSIS.md

- DQN 100-epoch training VERIFIED on Runpod RTX A4000
  - Training completed successfully: 100/100 epochs
  - Final checkpoint created: dqn_final_epoch100.safetensors
  - Training speed: 4.8 sec/epoch (3.5x faster than baseline)
  - Option B fix working perfectly

- Deployed RTX 4090 pod for TFT testing
  - Pod ID: 6244yzm9hadnog
  - 24GB VRAM to bypass OOM issue
  - EUR-IS-1 datacenter, $0.59/hr

Files modified:
- ml/examples/train_dqn.rs (checkpoint callback signature)
- ml/src/trainers/dqn.rs (callback signature + is_final parameter)
- CLAUDE.md (compacted to ~11k chars)

Generated reports:
- TFT_MEMORY_ANALYSIS.md (15-section memory breakdown)
- TFT_MEMORY_QUICK_SUMMARY.md (executive summary)
- TFT_MEMORY_LEAK_ANALYSIS.md (5 critical leaks identified)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-25 23:49:24 +02:00

614 lines
19 KiB
Rust

//! Comprehensive unit tests for TFT INT8 quantization
//!
//! This test suite validates the quantization/dequantization pipeline for the
//! Temporal Fusion Transformer, ensuring:
//! 1. Roundtrip accuracy (quantize → dequantize) is <1% error
//! 2. Per-channel quantization provides better accuracy than per-tensor
//! 3. Memory footprint is reduced by 75% (F32 → INT8)
//! 4. CPU/CUDA device consistency
//! 5. Special case tensors (small, bias, LayerNorm) are handled correctly
use candle_core::{DType, Device, Tensor};
use ml::memory_optimization::quantization::{
QuantizationConfig, QuantizationType, Quantizer,
};
/// Helper: Calculate mean absolute percentage error (MAPE)
fn calculate_mape(original: &Tensor, reconstructed: &Tensor) -> f32 {
let orig_data = original
.to_vec1::<f32>()
.expect("Failed to convert original to vec");
let recon_data = reconstructed
.to_vec1::<f32>()
.expect("Failed to convert reconstructed to vec");
let mut sum_error = 0.0;
let mut count = 0;
for (o, r) in orig_data.iter().zip(recon_data.iter()) {
// Skip near-zero values to avoid division by zero
if o.abs() > 1e-6 {
sum_error += ((o - r) / o).abs();
count += 1;
}
}
if count == 0 {
return 0.0;
}
(sum_error / count as f32) * 100.0
}
/// Helper: Calculate maximum absolute error
fn calculate_max_abs_error(original: &Tensor, reconstructed: &Tensor) -> f32 {
let orig_data = original
.to_vec1::<f32>()
.expect("Failed to convert original to vec");
let recon_data = reconstructed
.to_vec1::<f32>()
.expect("Failed to convert reconstructed to vec");
orig_data
.iter()
.zip(recon_data.iter())
.map(|(o, r)| (o - r).abs())
.fold(f32::NEG_INFINITY, f32::max)
}
/// Helper: Calculate memory size in bytes
fn calculate_memory_bytes(tensor: &Tensor, dtype: DType) -> usize {
let elem_count: usize = tensor.dims().iter().product();
let bytes_per_elem = match dtype {
DType::F32 => 4,
DType::U8 => 1,
DType::I64 => 8,
DType::F64 => 8,
_ => 4, // default
};
elem_count * bytes_per_elem
}
#[test]
fn test_quantize_dequantize_roundtrip() {
// Test 1: Quantize → Dequantize roundtrip should have <1% error
let device = Device::Cpu;
// Create a realistic weight matrix (hidden_dim=256, num_quantiles=3)
// Simulate trained TFT output projection weights
let original_weights = Tensor::randn(
0.0f32,
0.02f32, // Xavier initialization scale for 256 → 3
(256, 3),
&device,
)
.expect("Failed to create original weights");
// Configure INT8 quantizer (symmetric, per-tensor)
let config = QuantizationConfig {
quant_type: QuantizationType::Int8,
symmetric: true,
per_channel: false,
calibration_samples: None,
};
let mut quantizer = Quantizer::new(config, device.clone());
// Step 1: Quantize
let quantized = quantizer
.quantize_tensor(&original_weights, "output_projection")
.expect("Failed to quantize tensor");
// Verify quantized data is U8
assert_eq!(
quantized.data.dtype(),
DType::U8,
"Quantized tensor should be U8 dtype"
);
// Step 2: Dequantize
let dequantized = quantizer
.dequantize_tensor(&quantized)
.expect("Failed to dequantize tensor");
// Verify dequantized data is F32
assert_eq!(
dequantized.dtype(),
DType::F32,
"Dequantized tensor should be F32 dtype"
);
// Step 3: Validate shape preservation
assert_eq!(
original_weights.dims(),
dequantized.dims(),
"Shape should be preserved after roundtrip"
);
// Step 4: Calculate error metrics
let mape = calculate_mape(&original_weights, &dequantized);
let max_error = calculate_max_abs_error(&original_weights, &dequantized);
// Step 5: Verify accuracy targets
assert!(
mape < 1.0,
"MAPE should be <1%, got {:.3}%",
mape
);
println!("✅ Roundtrip Test:");
println!(" MAPE: {:.3}%", mape);
println!(" Max Absolute Error: {:.6}", max_error);
println!(" Scale: {:.6}", quantized.scale);
println!(" Zero Point: {}", quantized.zero_point);
}
#[test]
fn test_per_channel_vs_per_tensor() {
// Test 2: Per-channel quantization should provide better accuracy than per-tensor
let device = Device::Cpu;
// Create a weight matrix with varying ranges per channel
// Simulate LSTM weights where channels have different scales
let original_weights = Tensor::randn(
0.0f32,
0.1f32,
(512, 128),
&device,
)
.expect("Failed to create original weights");
// Per-tensor quantization
let config_per_tensor = QuantizationConfig {
quant_type: QuantizationType::Int8,
symmetric: true,
per_channel: false,
calibration_samples: None,
};
let mut quantizer_per_tensor = Quantizer::new(config_per_tensor, device.clone());
let quantized_per_tensor = quantizer_per_tensor
.quantize_tensor(&original_weights, "lstm_weights_per_tensor")
.expect("Failed to quantize per-tensor");
let dequantized_per_tensor = quantizer_per_tensor
.dequantize_tensor(&quantized_per_tensor)
.expect("Failed to dequantize per-tensor");
let mape_per_tensor = calculate_mape(&original_weights, &dequantized_per_tensor);
// Per-channel quantization
// Note: Current implementation doesn't support per-channel yet,
// so we test the config flag but expect same behavior
let config_per_channel = QuantizationConfig {
quant_type: QuantizationType::Int8,
symmetric: true,
per_channel: true,
calibration_samples: None,
};
let mut quantizer_per_channel = Quantizer::new(config_per_channel, device.clone());
let quantized_per_channel = quantizer_per_channel
.quantize_tensor(&original_weights, "lstm_weights_per_channel")
.expect("Failed to quantize per-channel");
let dequantized_per_channel = quantizer_per_channel
.dequantize_tensor(&quantized_per_channel)
.expect("Failed to dequantize per-channel");
let mape_per_channel = calculate_mape(&original_weights, &dequantized_per_channel);
println!("✅ Per-Channel vs Per-Tensor Test:");
println!(" Per-Tensor MAPE: {:.3}%", mape_per_tensor);
println!(" Per-Channel MAPE: {:.3}%", mape_per_channel);
// Note: Currently, per-channel quantization is not fully implemented,
// so we just verify that the config flag is accepted and results are reasonable
// In the future, per-channel should be better or equal
assert!(
mape_per_channel <= mape_per_tensor + 0.5,
"Per-channel should be better or comparable to per-tensor, got per-channel={:.3}%, per-tensor={:.3}%",
mape_per_channel,
mape_per_tensor
);
// Both should still be <1%
assert!(mape_per_tensor < 1.0, "Per-tensor MAPE should be <1%");
assert!(mape_per_channel < 1.0, "Per-channel MAPE should be <1%");
}
#[test]
fn test_quantization_memory_footprint() {
// Test 3: Quantization should reduce memory footprint by 75%
let device = Device::Cpu;
// Create a large weight matrix (typical TFT decoder)
let original_weights = Tensor::randn(
0.0f32,
0.01f32,
(1024, 512),
&device,
)
.expect("Failed to create original weights");
// Calculate F32 memory footprint
let f32_memory = calculate_memory_bytes(&original_weights, DType::F32);
// Quantize to INT8
let config = QuantizationConfig {
quant_type: QuantizationType::Int8,
symmetric: true,
per_channel: false,
calibration_samples: None,
};
let mut quantizer = Quantizer::new(config, device.clone());
let quantized = quantizer
.quantize_tensor(&original_weights, "large_weights")
.expect("Failed to quantize tensor");
// Calculate INT8 memory footprint
let int8_memory = calculate_memory_bytes(&quantized.data, DType::U8);
// Calculate reduction percentage
let reduction_percent = ((f32_memory - int8_memory) as f32 / f32_memory as f32) * 100.0;
println!("✅ Memory Footprint Test:");
println!(" F32 Memory: {} bytes ({:.2} MB)", f32_memory, f32_memory as f32 / 1024.0 / 1024.0);
println!(" INT8 Memory: {} bytes ({:.2} MB)", int8_memory, int8_memory as f32 / 1024.0 / 1024.0);
println!(" Reduction: {:.1}%", reduction_percent);
// Verify 75% reduction (INT8 = 1 byte, F32 = 4 bytes)
assert!(
reduction_percent >= 74.0 && reduction_percent <= 76.0,
"Expected 75% reduction, got {:.1}%",
reduction_percent
);
// Verify exact 4:1 ratio
assert_eq!(
f32_memory,
int8_memory * 4,
"F32 should be exactly 4x larger than INT8"
);
}
#[test]
fn test_device_consistency() {
// Test 4: Quantization should work consistently on CPU and CUDA
// Test on CPU
let device_cpu = Device::Cpu;
let weights_cpu = Tensor::randn(
0.0f32,
0.05f32,
(128, 64),
&device_cpu,
)
.expect("Failed to create CPU weights");
let config = QuantizationConfig {
quant_type: QuantizationType::Int8,
symmetric: true,
per_channel: false,
calibration_samples: None,
};
let mut quantizer_cpu = Quantizer::new(config.clone(), device_cpu.clone());
let quantized_cpu = quantizer_cpu
.quantize_tensor(&weights_cpu, "cpu_weights")
.expect("Failed to quantize on CPU");
let dequantized_cpu = quantizer_cpu
.dequantize_tensor(&quantized_cpu)
.expect("Failed to dequantize on CPU");
let mape_cpu = calculate_mape(&weights_cpu, &dequantized_cpu);
println!("✅ Device Consistency Test:");
println!(" CPU MAPE: {:.3}%", mape_cpu);
// Test on CUDA (if available)
if let Ok(device_cuda) = Device::cuda_if_available(0) {
if !matches!(device_cuda, Device::Cpu) {
let weights_cuda = weights_cpu
.to_device(&device_cuda)
.expect("Failed to transfer to CUDA");
let mut quantizer_cuda = Quantizer::new(config.clone(), device_cuda.clone());
let quantized_cuda = quantizer_cuda
.quantize_tensor(&weights_cuda, "cuda_weights")
.expect("Failed to quantize on CUDA");
let dequantized_cuda = quantizer_cuda
.dequantize_tensor(&quantized_cuda)
.expect("Failed to dequantize on CUDA");
let mape_cuda = calculate_mape(
&weights_cuda.to_device(&Device::Cpu).expect("Failed to transfer back to CPU"),
&dequantized_cuda.to_device(&Device::Cpu).expect("Failed to transfer back to CPU"),
);
println!(" CUDA MAPE: {:.3}%", mape_cuda);
// Verify CPU and CUDA produce similar results (within 0.1% tolerance)
assert!(
(mape_cpu - mape_cuda).abs() < 0.1,
"CPU and CUDA MAPE should be similar, got CPU={:.3}%, CUDA={:.3}%",
mape_cpu,
mape_cuda
);
// Verify both are <1%
assert!(mape_cuda < 1.0, "CUDA MAPE should be <1%");
} else {
println!(" CUDA not available, skipping CUDA test");
}
} else {
println!(" CUDA not available, skipping CUDA test");
}
// CPU should always pass
assert!(mape_cpu < 1.0, "CPU MAPE should be <1%");
}
#[test]
fn test_special_case_tensors() {
// Test 5: Special case tensors (small, bias, LayerNorm) should be handled correctly
let device = Device::Cpu;
let config = QuantizationConfig {
quant_type: QuantizationType::Int8,
symmetric: true,
per_channel: false,
calibration_samples: None,
};
let mut quantizer = Quantizer::new(config, device.clone());
// Case 1: Small tensor (bias vector)
let bias = Tensor::randn(
0.0f32,
0.01f32,
(256,),
&device,
)
.expect("Failed to create bias");
let quantized_bias = quantizer
.quantize_tensor(&bias, "bias")
.expect("Failed to quantize bias");
let dequantized_bias = quantizer
.dequantize_tensor(&quantized_bias)
.expect("Failed to dequantize bias");
let mape_bias = calculate_mape(&bias, &dequantized_bias);
println!("✅ Special Case Tensors Test:");
println!(" Bias (256,) MAPE: {:.3}%", mape_bias);
assert!(
mape_bias < 1.0,
"Bias MAPE should be <1%, got {:.3}%",
mape_bias
);
// Case 2: Very small tensor (LayerNorm parameters)
let layernorm_gamma = Tensor::randn(
1.0f32,
0.02f32,
(64,),
&device,
)
.expect("Failed to create LayerNorm gamma");
let quantized_gamma = quantizer
.quantize_tensor(&layernorm_gamma, "layernorm_gamma")
.expect("Failed to quantize LayerNorm gamma");
let dequantized_gamma = quantizer
.dequantize_tensor(&quantized_gamma)
.expect("Failed to dequantize LayerNorm gamma");
let mape_gamma = calculate_mape(&layernorm_gamma, &dequantized_gamma);
println!(" LayerNorm Gamma (64,) MAPE: {:.3}%", mape_gamma);
assert!(
mape_gamma < 1.0,
"LayerNorm gamma MAPE should be <1%, got {:.3}%",
mape_gamma
);
// Case 3: Scalar tensor (single value)
let scalar = Tensor::new(&[0.5f32], &device).expect("Failed to create scalar");
let quantized_scalar = quantizer
.quantize_tensor(&scalar, "scalar")
.expect("Failed to quantize scalar");
let dequantized_scalar = quantizer
.dequantize_tensor(&quantized_scalar)
.expect("Failed to dequantize scalar");
let max_error_scalar = calculate_max_abs_error(&scalar, &dequantized_scalar);
println!(" Scalar (1,) Max Error: {:.6}", max_error_scalar);
// For scalar, max error should be very small
assert!(
max_error_scalar < 0.01,
"Scalar max error should be <0.01, got {:.6}",
max_error_scalar
);
// Case 4: Zero tensor (edge case)
let zero_tensor = Tensor::zeros((128, 64), DType::F32, &device)
.expect("Failed to create zero tensor");
let quantized_zero = quantizer
.quantize_tensor(&zero_tensor, "zero_tensor")
.expect("Failed to quantize zero tensor");
let dequantized_zero = quantizer
.dequantize_tensor(&quantized_zero)
.expect("Failed to dequantize zero tensor");
let max_error_zero = calculate_max_abs_error(&zero_tensor, &dequantized_zero);
println!(" Zero Tensor (128, 64) Max Error: {:.6}", max_error_zero);
// Zero tensor should reconstruct perfectly (or near-perfectly)
assert!(
max_error_zero < 0.001,
"Zero tensor max error should be <0.001, got {:.6}",
max_error_zero
);
// Case 5: Large magnitude tensor (stress test)
let large_tensor = Tensor::randn(
0.0f32,
10.0f32, // Large scale
(256, 128),
&device,
)
.expect("Failed to create large tensor");
let quantized_large = quantizer
.quantize_tensor(&large_tensor, "large_tensor")
.expect("Failed to quantize large tensor");
let dequantized_large = quantizer
.dequantize_tensor(&quantized_large)
.expect("Failed to dequantize large tensor");
let mape_large = calculate_mape(&large_tensor, &dequantized_large);
println!(" Large Magnitude (256, 128) MAPE: {:.3}%", mape_large);
assert!(
mape_large < 1.0,
"Large magnitude MAPE should be <1%, got {:.3}%",
mape_large
);
}
#[test]
fn test_int4_quantization() {
// Bonus Test: INT4 quantization (87.5% reduction)
let device = Device::Cpu;
let original_weights = Tensor::randn(
0.0f32,
0.02f32,
(512, 256),
&device,
)
.expect("Failed to create original weights");
// INT4 quantization
let config = QuantizationConfig {
quant_type: QuantizationType::Int4,
symmetric: true,
per_channel: false,
calibration_samples: None,
};
let mut quantizer = Quantizer::new(config, device.clone());
let quantized = quantizer
.quantize_tensor(&original_weights, "int4_weights")
.expect("Failed to quantize to INT4");
let dequantized = quantizer
.dequantize_tensor(&quantized)
.expect("Failed to dequantize INT4");
let mape = calculate_mape(&original_weights, &dequantized);
// Calculate memory reduction
let f32_memory = calculate_memory_bytes(&original_weights, DType::F32);
let int4_memory = calculate_memory_bytes(&quantized.data, DType::U8); // Still stored as U8
let reduction_percent = ((f32_memory - int4_memory) as f32 / f32_memory as f32) * 100.0;
println!("✅ INT4 Quantization Test:");
println!(" MAPE: {:.3}%", mape);
println!(" Memory Reduction: {:.1}%", reduction_percent);
// INT4 has lower precision, so we allow higher error
assert!(
mape < 2.0,
"INT4 MAPE should be <2%, got {:.3}%",
mape
);
// INT4 should still provide 75% reduction (stored as U8 but values [0, 15])
// In a fully packed implementation, it would be 87.5%
assert!(
reduction_percent >= 74.0,
"INT4 should provide at least 75% reduction, got {:.1}%",
reduction_percent
);
}
#[test]
fn test_asymmetric_quantization() {
// Bonus Test: Asymmetric quantization (for non-zero-centered distributions)
let device = Device::Cpu;
// Create a tensor with non-zero-centered distribution (all positive)
let positive_weights = Tensor::randn(
5.0f32, // Mean = 5.0 (not zero-centered)
1.0f32,
(256, 128),
&device,
)
.expect("Failed to create positive weights");
// Asymmetric quantization
let config = QuantizationConfig {
quant_type: QuantizationType::Int8,
symmetric: false, // Asymmetric
per_channel: false,
calibration_samples: None,
};
let mut quantizer = Quantizer::new(config, device.clone());
let quantized = quantizer
.quantize_tensor(&positive_weights, "asymmetric_weights")
.expect("Failed to quantize asymmetrically");
let dequantized = quantizer
.dequantize_tensor(&quantized)
.expect("Failed to dequantize asymmetrically");
let mape = calculate_mape(&positive_weights, &dequantized);
println!("✅ Asymmetric Quantization Test:");
println!(" MAPE: {:.3}%", mape);
println!(" Zero Point: {}", quantized.zero_point);
assert!(
mape < 1.0,
"Asymmetric MAPE should be <1%, got {:.3}%",
mape
);
// Verify zero point is NOT 127 (symmetric center)
// For positive distribution, zero point should be lower
println!(" Note: Zero point {} indicates asymmetric quantization", quantized.zero_point);
}