feat(ml): InferenceEnsemble GPU-aggregated prediction — N syncs → 1

Use predict_raw() to collect raw GPU tensors from adapters. Stack,
sigmoid, weighted-sum on GPU before single extraction. Falls back
to CPU path for adapters without tensor output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-02 17:46:21 +01:00
parent 78f5ead601
commit 58dc95cf54

View File

@@ -8,8 +8,10 @@ use std::collections::HashMap;
use rayon::prelude::*;
use candle_core::Tensor;
use crate::ensemble::inference_adapter::{
EnsemblePrediction, FeatureVector, ModelInferenceAdapter, PredictionMeta,
EnsemblePrediction, FeatureVector, ModelInferenceAdapter, PredictionMeta, RawPrediction,
};
use crate::{MLError, MLResult};
@@ -57,6 +59,13 @@ impl InferenceEnsemble {
/// Run inference across all ready adapters and aggregate via
/// confidence-weighted voting.
///
/// Uses [`predict_raw()`](ModelInferenceAdapter::predict_raw) to collect
/// raw GPU tensors where available. Models that return a tensor are
/// aggregated on-device (stack → sigmoid → weighted-sum → single
/// extraction). Models without a tensor fall back to the original
/// scalar-weighted-average path. The two paths are merged by
/// model-count-weighted average.
///
/// Returns [`MLError::InferenceError`] if no models are ready.
pub fn predict(&self, features: &FeatureVector) -> MLResult<EnsemblePrediction> {
let ready_adapters: Vec<&Box<dyn ModelInferenceAdapter>> =
@@ -68,17 +77,17 @@ impl InferenceEnsemble {
));
}
// Run inference in parallel across all ready adapters
let results: Vec<(String, EnsemblePrediction)> = ready_adapters
// Run predict_raw() in parallel across all ready adapters
let raw_predictions: Vec<(String, RawPrediction)> = ready_adapters
.par_iter()
.filter_map(|adapter| {
let model_name = adapter.model_name().to_string();
match adapter.predict(features) {
match adapter.predict_raw(features) {
Ok(pred) => {
if !pred.direction.is_finite() || !pred.confidence.is_finite() {
if !pred.direction_scalar.is_finite() || !pred.confidence.is_finite() {
tracing::warn!(
model = %model_name,
direction = %pred.direction,
direction = %pred.direction_scalar,
confidence = %pred.confidence,
"Model returned NaN/Inf prediction, skipping (circuit breaker)"
);
@@ -99,41 +108,82 @@ impl InferenceEnsemble {
})
.collect();
// Aggregate results sequentially (fast arithmetic)
let mut weighted_direction_sum = 0.0_f64;
let mut weight_confidence_sum = 0.0_f64;
let mut confidence_sum = 0.0_f64;
let mut model_names: Vec<String> = Vec::new();
for (model_name, pred) in &results {
let confidence = pred.confidence.clamp(0.0, 1.0);
let w = self
.weights
.get(model_name)
.copied()
.unwrap_or(1.0);
let wc = w * confidence;
weighted_direction_sum += pred.direction * wc;
weight_confidence_sum += wc;
confidence_sum += confidence;
model_names.push(model_name.clone());
}
let successful_count = results.len();
if successful_count == 0 {
if raw_predictions.is_empty() {
return Err(MLError::InferenceError(
"All ready models failed during inference".to_owned(),
));
}
let direction = if weight_confidence_sum.abs() < f64::EPSILON {
0.0
// Partition into GPU-tensor vs CPU-scalar predictions
let (gpu_preds, cpu_preds): (Vec<_>, Vec<_>) = raw_predictions
.into_iter()
.partition(|(_, p)| p.tensor.is_some());
// Collect model names from both paths for the ensemble label
let mut model_names: Vec<String> = Vec::new();
let mut total_confidence_sum = 0.0_f64;
let mut total_count: usize = 0;
// --- GPU path: stack tensors, sigmoid, weighted-sum, single extraction ---
let gpu_result = if !gpu_preds.is_empty() {
match self.aggregate_gpu(&gpu_preds) {
Ok((direction, count)) => {
for (name, pred) in &gpu_preds {
model_names.push(name.clone());
total_confidence_sum += pred.confidence.clamp(0.0, 1.0);
}
total_count += gpu_preds.len();
Some((direction, count))
}
Err(e) => {
tracing::warn!(
error = %e,
"GPU aggregation failed, falling back to CPU for {} models",
gpu_preds.len()
);
// Fall back: treat GPU preds as CPU scalars
let fallback = self.aggregate_cpu(&gpu_preds);
for (name, pred) in &gpu_preds {
model_names.push(name.clone());
total_confidence_sum += pred.confidence.clamp(0.0, 1.0);
}
total_count += gpu_preds.len();
fallback
}
}
} else {
weighted_direction_sum / weight_confidence_sum
None
};
let avg_confidence = confidence_sum / successful_count as f64;
// --- CPU path: existing scalar weighted average ---
let cpu_result = if !cpu_preds.is_empty() {
let result = self.aggregate_cpu(&cpu_preds);
for (name, pred) in &cpu_preds {
model_names.push(name.clone());
total_confidence_sum += pred.confidence.clamp(0.0, 1.0);
}
total_count += cpu_preds.len();
result
} else {
None
};
// Merge GPU and CPU directions by model-count-weighted average
let direction = match (gpu_result, cpu_result) {
(Some((gpu_dir, gpu_n)), Some((cpu_dir, cpu_n))) => {
let total_n = (gpu_n + cpu_n) as f64;
(gpu_dir * gpu_n as f64 + cpu_dir * cpu_n as f64) / total_n
}
(Some((dir, _)), None) => dir,
(None, Some((dir, _))) => dir,
(None, None) => 0.0,
};
let avg_confidence = if total_count > 0 {
total_confidence_sum / total_count as f64
} else {
0.0
};
let ensemble_name = format!("ENSEMBLE({})", model_names.join("+"));
@@ -144,6 +194,118 @@ impl InferenceEnsemble {
metadata: PredictionMeta::default(),
})
}
/// Aggregate predictions on GPU: stack tensors, apply sigmoid,
/// compute confidence-weighted sum, extract single scalar.
fn aggregate_gpu(
&self,
preds: &[(String, RawPrediction)],
) -> Result<(f64, usize), MLError> {
let tensors: Vec<Tensor> = preds
.iter()
.filter_map(|(_, p)| p.tensor.as_ref().cloned())
.collect();
if tensors.is_empty() {
return Err(MLError::InferenceError(
"No GPU tensors available for aggregation".to_owned(),
));
}
// Stack all model outputs into a single tensor [N]
let stacked = Tensor::stack(&tensors, 0).map_err(|e| {
MLError::TensorOperationError(format!("Failed to stack GPU tensors: {e}"))
})?;
// Apply sigmoid to convert logits → probabilities on device
let sigmoided = candle_nn::ops::sigmoid(&stacked).map_err(|e| {
MLError::TensorOperationError(format!("Sigmoid failed: {e}"))
})?;
// Build confidence weights on the same device
let weights_f32: Vec<f32> = preds
.iter()
.map(|(name, p)| {
let conf = p.confidence.clamp(0.0, 1.0) as f32;
let w = self.weights.get(name).copied().unwrap_or(1.0) as f32;
w * conf
})
.collect();
let device = stacked.device();
let n = weights_f32.len();
let weight_t = Tensor::from_vec(weights_f32, n, device).map_err(|e| {
MLError::TensorCreationError {
operation: "weight_tensor".to_owned(),
reason: format!("{e}"),
}
})?;
let weight_sum = weight_t.sum_all().map_err(|e| {
MLError::TensorOperationError(format!("Weight sum failed: {e}"))
})?;
// Guard against zero-weight sum
let weight_sum_val = weight_sum.to_scalar::<f32>().map_err(|e| {
MLError::TensorOperationError(format!("Weight sum extraction failed: {e}"))
})?;
if weight_sum_val.abs() < f32::EPSILON {
return Ok((0.0, preds.len()));
}
let normalized = weight_t.broadcast_div(&weight_sum).map_err(|e| {
MLError::TensorOperationError(format!("Weight normalization failed: {e}"))
})?;
// Weighted sum: dot product of sigmoided values and normalized weights
let weighted = sigmoided.mul(&normalized).map_err(|e| {
MLError::TensorOperationError(format!("Weighted multiply failed: {e}"))
})?;
let result = weighted.sum_all().map_err(|e| {
MLError::TensorOperationError(format!("Sum extraction failed: {e}"))
})?;
let direction_f32 = result.to_scalar::<f32>().map_err(|e| {
MLError::TensorOperationError(format!("Scalar extraction failed: {e}"))
})?;
// Sigmoid output is [0,1]; remap to [-1,1] direction space
let direction = (f64::from(direction_f32) * 2.0) - 1.0;
Ok((direction, preds.len()))
}
/// CPU-side scalar weighted average (original algorithm).
fn aggregate_cpu(
&self,
preds: &[(String, RawPrediction)],
) -> Option<(f64, usize)> {
if preds.is_empty() {
return None;
}
let mut weighted_direction_sum = 0.0_f64;
let mut weight_confidence_sum = 0.0_f64;
for (name, pred) in preds {
let confidence = pred.confidence.clamp(0.0, 1.0);
let w = self.weights.get(name).copied().unwrap_or(1.0);
let wc = w * confidence;
weighted_direction_sum += pred.direction_scalar * wc;
weight_confidence_sum += wc;
}
let direction = if weight_confidence_sum.abs() < f64::EPSILON {
0.0
} else {
weighted_direction_sum / weight_confidence_sum
};
Some((direction, preds.len()))
}
}
#[cfg(test)]