Skip to main content

furnace/
model.rs

1//! Models as the server runs them.
2//!
3//! [`TensorModel`] is the one trait every model implements, whatever engine runs it (Burn
4//! through furnace-burn, or your own). [`Model`] wraps one with its
5//! metadata and statistics and runs requests through a single pipeline: check each request's
6//! tensors against the model's inputs, join the valid ones along the batch dimension, run them
7//! (in chunks of a fixed batch size if the graph has one), check the outputs and split them
8//! back. The flat `Vec<f32>` methods (`predict*`) are thin adapters over that pipeline.
9
10use serde::{Deserialize, Serialize};
11use std::sync::Mutex;
12use std::time::{Instant, SystemTime};
13
14use crate::error::{FurnaceError, ModelError, Result};
15use crate::tensor::{DataType, HostTensor, IoSpec, TensorInfo, TensorSpec, Values};
16
17#[cfg(any(test, feature = "test-models"))]
18mod testing;
19
20#[cfg(any(test, feature = "test-models"))]
21pub use testing::{DummyModel, DummyTensorsModel};
22
23/// Hard upper bound on the number of items in one inference batch
24pub const MAX_BATCH_SIZE: usize = 1000;
25
26/// What `GET /model/info` reports about a model
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ModelInfo {
29    /// The name clients use: `/v2/models/{name}`, `/models/{name}/predict`
30    pub name: String,
31    pub version: String,
32    /// The first input. For a model with several, see `inputs`
33    pub input_spec: TensorSpec,
34    /// The first output. For a model with several, see `outputs`
35    pub output_spec: TensorSpec,
36    /// Every input, with the names `/v2/models/{model}/infer` takes
37    pub inputs: Vec<TensorInfo>,
38    /// Every output, in the order the model returns them
39    pub outputs: Vec<TensorInfo>,
40    /// How the model was defined: `custom`, `burn`, `onnx`, `mlp`, ...
41    pub model_type: String,
42    /// The backend it runs on, e.g. `cpu`
43    pub backend: String,
44    /// When it was loaded, RFC 3339
45    pub created_at: String,
46    /// Size of its weight file; 0 when there is none
47    pub model_size_bytes: u64,
48}
49
50/// A model's request counters, as `/metrics` reports them.
51///
52/// Counts are per request; timings are per forward pass, so requests batched together each
53/// record the time of the pass they shared.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct ModelStats {
56    /// Requests answered, successfully or not
57    pub inference_count: u64,
58    /// Sum of the forward-pass times recorded for successful requests
59    pub total_inference_time_ms: f64,
60    /// When the last successful request finished; RFC 3339 in JSON
61    #[serde(with = "crate::time::serde_utc")]
62    pub last_inference_time: Option<SystemTime>,
63    /// `total_inference_time_ms / success_count`
64    pub average_inference_time_ms: f64,
65    /// Requests that failed, whether their inputs were invalid or the model failed
66    pub error_count: u64,
67    /// Requests that got their outputs
68    pub success_count: u64,
69    /// Fastest forward pass a successful request was part of
70    pub min_inference_time_ms: Option<f64>,
71    /// Slowest forward pass a successful request was part of
72    pub max_inference_time_ms: Option<f64>,
73    /// Forward passes run. Below `success_count` when requests were batched together
74    pub batch_count: u64,
75    /// Items processed by those forward passes
76    pub batched_items: u64,
77}
78
79impl ModelStats {
80    fn new() -> Self {
81        Self {
82            inference_count: 0,
83            total_inference_time_ms: 0.0,
84            last_inference_time: None,
85            average_inference_time_ms: 0.0,
86            error_count: 0,
87            success_count: 0,
88            min_inference_time_ms: None,
89            max_inference_time_ms: None,
90            batch_count: 0,
91            batched_items: 0,
92        }
93    }
94
95    /// `now` is read before the lock is taken, so the clock is not read while holding it
96    fn record_success(&mut self, elapsed_ms: f64, now: SystemTime) {
97        self.inference_count += 1;
98        self.success_count += 1;
99        self.total_inference_time_ms += elapsed_ms;
100        self.average_inference_time_ms = self.total_inference_time_ms / self.success_count as f64;
101        self.min_inference_time_ms = Some(
102            self.min_inference_time_ms
103                .map_or(elapsed_ms, |m| m.min(elapsed_ms)),
104        );
105        self.max_inference_time_ms = Some(
106            self.max_inference_time_ms
107                .map_or(elapsed_ms, |m| m.max(elapsed_ms)),
108        );
109        self.last_inference_time = Some(now);
110    }
111
112    fn record_error(&mut self) {
113        self.inference_count += 1;
114        self.error_count += 1;
115    }
116
117    fn record_batch(&mut self, items: usize) {
118        self.batch_count += 1;
119        self.batched_items += items as u64;
120    }
121}
122
123/// One request's input for [`Model::predict_many`]: `batch_size` items back to back
124#[derive(Debug)]
125pub struct FlatInput {
126    /// The items' values, row-major, `batch_size * input size` of them
127    pub data: Vec<f32>,
128    /// Items in `data`
129    pub batch_size: usize,
130}
131
132/// A model the server can run: what it takes, what it returns, and how to run it.
133///
134/// The one trait between the server and the engine that runs a model. furnace-burn implements
135/// it for Burn models (`furnace_burn::custom`, `furnace_burn::from_fn`); implement it yourself
136/// to serve a model from any other engine, with every feature of the server (named tensors over
137/// the v2 protocol, batching, metrics, reloads, ...), through [`Model::custom`].
138///
139/// ```
140/// use furnace::{DataType, HostTensor, IoSpec, Model, TensorModel, Values};
141///
142/// /// Doubles every value
143/// #[derive(Debug)]
144/// struct Doubler;
145///
146/// impl TensorModel for Doubler {
147///     fn name(&self) -> &str { "doubler" }
148///     fn backend(&self) -> &str { "cpu" }
149///     fn inputs(&self) -> Vec<IoSpec> { vec![IoSpec::fp32("input", &[4])] }
150///     fn outputs(&self) -> Vec<IoSpec> { vec![IoSpec::fp32("output", &[4])] }
151///     fn run(&self, inputs: Vec<HostTensor>) -> furnace::Result<Vec<HostTensor>> {
152///         // Checked already: one FP32 tensor of [batch, 4]
153///         let HostTensor { shape, values: Values::F32(values) } = &inputs[0] else {
154///             unreachable!("checked against inputs()")
155///         };
156///         let doubled = values.iter().map(|v| v * 2.0).collect();
157///         Ok(vec![HostTensor { shape: shape.clone(), values: Values::F32(doubled) }])
158///     }
159/// }
160///
161/// let model = Model::custom(Doubler);
162/// assert_eq!(model.predict(vec![1.0, 2.0, 3.0, 4.0]).unwrap(), [2.0, 4.0, 6.0, 8.0]);
163/// ```
164pub trait TensorModel: Send + Sync + std::fmt::Debug {
165    /// The name clients use: `/v2/models/{name}`, `/models/{name}/predict`
166    fn name(&self) -> &str;
167
168    /// The backend the model runs on, as reported by `/model/info`: `cpu`, `wgpu`, ...
169    fn backend(&self) -> &str;
170
171    /// The inputs, in the order [`TensorModel::run`] takes them
172    fn inputs(&self) -> Vec<IoSpec>;
173
174    /// The outputs, in the order [`TensorModel::run`] returns them
175    fn outputs(&self) -> Vec<IoSpec>;
176
177    /// Run one batch. The server has already checked the inputs against
178    /// [`TensorModel::inputs`]: one tensor each, in that order, with the batch as the first
179    /// dimension, every fixed dimension as declared, and the same batch size throughout.
180    /// Blocking; the server calls it off the async runtime.
181    fn run(&self, inputs: Vec<HostTensor>) -> Result<Vec<HostTensor>>;
182
183    /// The batch size the model was built for, if it takes no other: larger batches are then
184    /// run in chunks of it
185    fn fixed_batch(&self) -> Option<usize> {
186        None
187    }
188
189    /// The integers the engine can hold. Integer inputs outside this range are refused before
190    /// [`TensorModel::run`] sees them.
191    fn int_range(&self) -> (i64, i64) {
192        (i64::MIN, i64::MAX)
193    }
194}
195
196/// A loaded model plus its metadata and runtime statistics: what the server serves.
197///
198/// Build one with [`Model::custom`], or with furnace-burn for a Burn model. Requests
199/// run through [`Model::infer`] / [`Model::infer_many`] (named tensors), or the flat
200/// [`Model::predict`] family for a model of one `f32` tensor in and one out. All of them block;
201/// call them off the async runtime.
202#[derive(Debug)]
203pub struct Model {
204    inner: Box<dyn TensorModel>,
205    info: ModelInfo,
206    model_type: &'static str,
207    inputs: Vec<IoSpec>,
208    outputs: Vec<IoSpec>,
209    /// One `f32` tensor in and one out, both with fixed per-item shapes
210    simple: bool,
211    /// The range of the engine's integers, for integer inputs
212    int_range: (i64, i64),
213    /// Shared with the model this one replaced on a reload, so requests still running on the
214    /// old one are counted too
215    stats: std::sync::Arc<Mutex<ModelStats>>,
216}
217
218/// Which entry point a request came in through. It decides only how invalid input is reported:
219/// the flat `predict*` methods keep the error variants and messages they have always returned.
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221enum Door {
222    Named,
223    Flat,
224}
225
226/// The legacy single-tensor description: per-item shape (open dimensions as 0) and dtype
227fn tensor_spec(spec: Option<&IoSpec>) -> TensorSpec {
228    TensorSpec {
229        shape: spec
230            .map(|s| s.shape.iter().map(|d| d.unwrap_or(0)).collect())
231            .unwrap_or_default(),
232        dtype: match spec.map(|s| s.dtype) {
233            Some(DataType::Int64) => "int64",
234            Some(DataType::Int32) => "int32",
235            _ => "float32",
236        }
237        .to_string(),
238        min_value: None,
239        max_value: None,
240    }
241}
242
243impl Model {
244    /// Serve your own model (see [`TensorModel`])
245    pub fn custom(model: impl TensorModel + 'static) -> Self {
246        Self::new(Box::new(model), "custom", 0)
247    }
248
249    /// Wrap a model; `model_type` and `model_size_bytes` are reported by `/model/info`
250    pub fn new(
251        inner: Box<dyn TensorModel>,
252        model_type: &'static str,
253        model_size_bytes: u64,
254    ) -> Self {
255        let inputs = inner.inputs();
256        let outputs = inner.outputs();
257        let int_range = inner.int_range();
258        let simple = match (inputs.as_slice(), outputs.as_slice()) {
259            ([input], [output]) => [input, output].iter().all(|spec| {
260                spec.dtype == DataType::Fp32
261                    && spec.static_shape().is_some_and(|shape| !shape.is_empty())
262            }),
263            _ => false,
264        };
265        let info = ModelInfo {
266            name: inner.name().to_string(),
267            version: "1.0.0".to_string(),
268            input_spec: tensor_spec(inputs.first()),
269            output_spec: tensor_spec(outputs.first()),
270            inputs: inputs.iter().map(TensorInfo::from).collect(),
271            outputs: outputs.iter().map(TensorInfo::from).collect(),
272            model_type: model_type.to_string(),
273            backend: inner.backend().to_string(),
274            created_at: crate::time::rfc3339_now(),
275            model_size_bytes,
276        };
277
278        Self {
279            inner,
280            info,
281            model_type,
282            int_range,
283            inputs,
284            outputs,
285            simple,
286            stats: std::sync::Arc::new(Mutex::new(ModelStats::new())),
287        }
288    }
289
290    /// Whether concurrent requests of named tensors can be joined into one batch: every input
291    /// has a fixed per-item shape. (Simple models batch on the flat path instead.)
292    pub fn batches_named(&self) -> bool {
293        !self.simple && self.inputs.iter().all(|spec| spec.static_shape().is_some())
294    }
295
296    /// Whether the model takes one `f32` tensor and returns one, with fixed per-item shapes.
297    /// Such models serve `/predict` and batch concurrent requests together; the others take
298    /// named tensors through [`Model::infer`].
299    pub fn is_simple(&self) -> bool {
300        self.simple
301    }
302
303    /// The inputs, in the order [`Model::infer`] takes them
304    pub fn inputs(&self) -> &[IoSpec] {
305        &self.inputs
306    }
307
308    /// The outputs, in the order [`Model::infer`] returns them
309    pub fn outputs(&self) -> &[IoSpec] {
310        &self.outputs
311    }
312
313    /// Run one request of named tensors: `inputs` in the order of [`Model::inputs`], each with
314    /// the batch as its first dimension. Returns the outputs in the order of [`Model::outputs`].
315    /// Blocking, like [`Model::predict_batch`].
316    pub fn infer(&self, inputs: Vec<HostTensor>) -> Result<Vec<HostTensor>> {
317        self.infer_many(vec![inputs])
318            .pop()
319            .expect("one request yields one result")
320    }
321
322    /// Several requests of named tensors as one forward pass, joined along the batch
323    /// dimension, the outputs split back. Each request is checked on its own, so an invalid one
324    /// gets its own error and the rest still run. Blocking, like [`Model::predict_batch`].
325    pub fn infer_many(&self, requests: Vec<Vec<HostTensor>>) -> Vec<Result<Vec<HostTensor>>> {
326        self.run_requests(requests.into_iter().map(Ok).collect(), Door::Named)
327    }
328
329    /// The pipeline behind every entry point. A request that arrives as an error (one the flat
330    /// adapters found) is counted and answered with it.
331    fn run_requests(
332        &self,
333        requests: Vec<Result<Vec<HostTensor>>>,
334        door: Door,
335    ) -> Vec<Result<Vec<HostTensor>>> {
336        let mut results: Vec<Option<Result<Vec<HostTensor>>>> =
337            (0..requests.len()).map(|_| None).collect();
338        let mut valid = Vec::with_capacity(requests.len());
339        let mut errors = 0;
340        for (i, inputs) in requests.into_iter().enumerate() {
341            let checked = inputs.and_then(|inputs| {
342                self.check_inputs(&inputs, door)
343                    .map(|batch| (inputs, batch))
344            });
345            match checked {
346                Ok((inputs, batch)) => valid.push((i, inputs, batch)),
347                Err(e) => {
348                    results[i] = Some(Err(e));
349                    errors += 1;
350                }
351            }
352        }
353
354        let start = Instant::now();
355        let total: usize = valid.iter().map(|(_, _, batch)| batch).sum();
356        let batches: Vec<(usize, usize)> = valid.iter().map(|(i, _, b)| (*i, *b)).collect();
357        let output = match valid.len() {
358            0 => None,
359            1 => {
360                let (_, inputs, _) = valid.pop().expect("one valid request");
361                Some(self.run_checked(inputs, total))
362            }
363            _ => Some(
364                join_requests(valid.into_iter().map(|(_, inputs, _)| inputs).collect())
365                    .and_then(|inputs| self.run_checked(inputs, total)),
366            ),
367        };
368        let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
369        let now = SystemTime::now();
370
371        let mut stats = self.stats.lock().unwrap_or_else(|e| e.into_inner());
372        for _ in 0..errors {
373            stats.record_error();
374        }
375        if let Some(output) = output {
376            stats.record_batch(total);
377            match output {
378                Ok(outputs) if batches.len() == 1 => {
379                    stats.record_success(elapsed_ms, now);
380                    results[batches[0].0] = Some(Ok(outputs));
381                }
382                Ok(outputs) => {
383                    let mut offset = 0;
384                    for (i, batch) in batches {
385                        let mine = outputs
386                            .iter()
387                            .map(|output| output.slice_batch(offset, offset + batch))
388                            .collect();
389                        offset += batch;
390                        stats.record_success(elapsed_ms, now);
391                        results[i] = Some(Ok(mine));
392                    }
393                }
394                // A lone request keeps its error; a shared failure is reported to each
395                Err(e) if batches.len() == 1 => {
396                    stats.record_error();
397                    results[batches[0].0] = Some(Err(e));
398                }
399                Err(e) => {
400                    for (i, _) in batches {
401                        stats.record_error();
402                        results[i] = Some(Err(duplicate(&e)));
403                    }
404                }
405            }
406        }
407        results
408            .into_iter()
409            .map(|r| r.expect("every request has a result"))
410            .collect()
411    }
412
413    fn run_checked(&self, inputs: Vec<HostTensor>, batch: usize) -> Result<Vec<HostTensor>> {
414        let outputs = self.run_in_chunks(inputs, batch)?;
415        self.check_outputs(&outputs, batch)?;
416        Ok(outputs)
417    }
418
419    /// The batch size, if the inputs fit the model
420    fn check_inputs(&self, inputs: &[HostTensor], door: Door) -> Result<usize> {
421        let invalid =
422            |message: String| -> FurnaceError { ModelError::InvalidInput(message).into() };
423        if inputs.len() != self.inputs.len() {
424            return Err(invalid(format!(
425                "expected {} inputs ({}), got {}",
426                self.inputs.len(),
427                self.inputs
428                    .iter()
429                    .map(|i| i.name.as_str())
430                    .collect::<Vec<_>>()
431                    .join(", "),
432                inputs.len()
433            )));
434        }
435        let mut batch = None;
436        for (spec, tensor) in self.inputs.iter().zip(inputs) {
437            let name = &spec.name;
438            match (&tensor.values, spec.dtype.is_int()) {
439                (Values::F32(values), false) => {
440                    if let Some(pos) = values.iter().position(|v| !v.is_finite()) {
441                        if door == Door::Flat {
442                            let per_item: usize = tensor.shape.iter().skip(1).product();
443                            return Err(ModelError::InvalidDataType(format!(
444                                "Invalid value at batch[{}][{}]: {} (not finite)",
445                                pos / per_item,
446                                pos % per_item,
447                                values[pos]
448                            ))
449                            .into());
450                        }
451                        return Err(invalid(format!(
452                            "'{name}' has a non-finite value at index {pos}"
453                        )));
454                    }
455                }
456                (Values::I64(values), true) => {
457                    let (min, max) = self.int_range;
458                    if let Some(pos) = values.iter().position(|v| !(min..=max).contains(v)) {
459                        return Err(invalid(format!(
460                            "'{name}' has {} at index {pos}, outside {min}..={max}, the range \
461                             of this backend's integers",
462                            values[pos]
463                        )));
464                    }
465                }
466                _ => {
467                    return Err(invalid(format!(
468                        "'{name}' must be {}",
469                        spec.dtype.v2_name()
470                    )));
471                }
472            }
473            // The batch dimension comes first: a rank-0 tensor has none, and joining or splitting
474            // it would index past its shape
475            if tensor.shape.is_empty() {
476                return Err(invalid(format!(
477                    "'{name}' has no batch dimension: its shape is [], the model takes {:?}",
478                    spec.v2_shape()
479                )));
480            }
481            let item = &tensor.shape[1..];
482            let fits = item.len() == spec.shape.len()
483                && item
484                    .iter()
485                    .zip(&spec.shape)
486                    .all(|(&d, want)| want.is_none_or(|w| w == d) && d > 0);
487            if !fits {
488                return Err(invalid(format!(
489                    "'{name}' has shape {:?}; the model takes {:?} (-1: any)",
490                    tensor.shape,
491                    spec.v2_shape()
492                )));
493            }
494            let expected_len: usize = tensor.shape.iter().product();
495            if tensor.values.len() != expected_len {
496                return Err(invalid(format!(
497                    "'{name}' has shape {:?} ({expected_len} values) but {} values",
498                    tensor.shape,
499                    tensor.values.len()
500                )));
501            }
502            match batch {
503                None => batch = Some(tensor.batch()),
504                Some(b) if b != tensor.batch() => {
505                    return Err(invalid(format!(
506                        "inputs disagree on the batch size: {b} and {} ('{name}')",
507                        tensor.batch()
508                    )));
509                }
510                Some(_) => {}
511            }
512        }
513        let batch = batch.unwrap_or(0);
514        Self::check_batch_size(batch)?;
515        Ok(batch)
516    }
517
518    /// Run the inputs, in chunks of the graph's fixed batch size if it has one
519    fn run_in_chunks(&self, inputs: Vec<HostTensor>, batch: usize) -> Result<Vec<HostTensor>> {
520        let chunk = match self.inner.fixed_batch() {
521            Some(chunk) if chunk != batch => chunk,
522            _ => return self.inner.run(inputs),
523        };
524        if !batch.is_multiple_of(chunk) {
525            return Err(ModelError::InvalidInput(format!(
526                "the model runs batches of {chunk}; got {batch} items"
527            ))
528            .into());
529        }
530        let mut per_output: Vec<Vec<HostTensor>> = Vec::new();
531        for start in (0..batch).step_by(chunk) {
532            let part = inputs
533                .iter()
534                .map(|t| t.slice_batch(start, start + chunk))
535                .collect();
536            for (i, output) in self.inner.run(part)?.into_iter().enumerate() {
537                if per_output.len() <= i {
538                    per_output.push(Vec::new());
539                }
540                per_output[i].push(output);
541            }
542        }
543        per_output
544            .into_iter()
545            .map(HostTensor::concat_batch)
546            .collect()
547    }
548
549    fn check_outputs(&self, outputs: &[HostTensor], batch: usize) -> Result<()> {
550        if outputs.len() != self.outputs.len() {
551            return Err(ModelError::InferenceFailed(format!(
552                "model returned {} outputs, expected {}",
553                outputs.len(),
554                self.outputs.len()
555            ))
556            .into());
557        }
558        if let Some(output) = outputs.iter().find(|o| o.batch() != batch) {
559            return Err(ModelError::InferenceFailed(format!(
560                "model returned shape {:?} for a batch of {batch}",
561                output.shape
562            ))
563            .into());
564        }
565        Ok(())
566    }
567
568    /// Run one item of a simple model (see [`Model::is_simple`]): its flattened input in, its
569    /// flattened output back. Blocking, like [`Model::predict_batch`].
570    pub fn predict(&self, input: Vec<f32>) -> Result<Vec<f32>> {
571        self.predict_batch(vec![input])
572            .map(|mut batch| batch.pop().expect("batch of one yields one output"))
573    }
574
575    /// Run inference on a batch of flattened inputs.
576    ///
577    /// This is CPU/GPU-bound and blocking; call it from a blocking context
578    /// (e.g. `tokio::task::spawn_blocking`) inside async code.
579    pub fn predict_batch(&self, inputs: Vec<Vec<f32>>) -> Result<Vec<Vec<f32>>> {
580        let batch_size = inputs.len();
581        let flattened = match self.flatten_batch(inputs) {
582            Ok(flattened) => flattened,
583            Err(e) => {
584                self.stats
585                    .lock()
586                    .unwrap_or_else(|e| e.into_inner())
587                    .record_error();
588                return Err(e);
589            }
590        };
591
592        let output_size: usize = self.info.output_spec.shape.iter().product();
593        Ok(self
594            .predict_flat(flattened, batch_size)?
595            .chunks_exact(output_size)
596            .map(<[f32]>::to_vec)
597            .collect())
598    }
599
600    /// Run inference on `batch_size` items stored back to back in one row-major buffer.
601    ///
602    /// Returns the outputs the same way: `batch_size * output_size` values. This avoids
603    /// per-item allocations and copies, so it is the fastest entry point for large inputs.
604    /// Blocking, like [`Model::predict_batch`].
605    pub fn predict_flat(&self, input: Vec<f32>, batch_size: usize) -> Result<Vec<f32>> {
606        self.predict_many(vec![FlatInput {
607            data: input,
608            batch_size,
609        }])
610        .pop()
611        .expect("one request yields one result")
612    }
613
614    /// Run several independent requests as one forward pass and split the outputs back.
615    ///
616    /// Each request is validated on its own, so an invalid one gets its own error and
617    /// the rest still run. Results come back in request order, each holding that
618    /// request's `batch_size * output_size` values. The requests together must not
619    /// exceed [`MAX_BATCH_SIZE`] items. Statistics count every request, plus one batch
620    /// per forward pass. Blocking, like [`Model::predict_batch`].
621    pub fn predict_many(&self, requests: Vec<FlatInput>) -> Vec<Result<Vec<f32>>> {
622        let batches: Vec<usize> = requests.iter().map(|r| r.batch_size).collect();
623        let requests = requests.into_iter().map(|r| self.flat_request(r)).collect();
624        self.run_requests(requests, Door::Flat)
625            .into_iter()
626            .zip(batches)
627            .map(|(result, batch)| result.and_then(|outputs| self.flat_output(outputs, batch)))
628            .collect()
629    }
630
631    /// A flat request as the one tensor of a simple model, without copying its buffer. The
632    /// batch size and length are checked here, first, as the flat methods always have.
633    fn flat_request(&self, FlatInput { data, batch_size }: FlatInput) -> Result<Vec<HostTensor>> {
634        Self::check_batch_size(batch_size)?;
635        let item = &self.info.input_spec.shape;
636        if data.len() != batch_size * item.iter().product::<usize>() {
637            return Err(ModelError::InputValidation {
638                batch: batch_size,
639                item: item.clone(),
640                got: data.len(),
641            }
642            .into());
643        }
644        let mut shape = Vec::with_capacity(item.len() + 1);
645        shape.push(batch_size);
646        shape.extend_from_slice(item);
647        Ok(vec![HostTensor {
648            shape,
649            values: Values::F32(data),
650        }])
651    }
652
653    /// The flat values of a simple model's one output
654    fn flat_output(&self, outputs: Vec<HostTensor>, batch_size: usize) -> Result<Vec<f32>> {
655        let output_size: usize = self.info.output_spec.shape.iter().product();
656        match outputs.into_iter().next() {
657            Some(HostTensor {
658                values: Values::F32(values),
659                ..
660            }) if values.len() == batch_size * output_size => Ok(values),
661            Some(output) => Err(ModelError::InferenceFailed(format!(
662                "model returned shape {:?}, expected [{batch_size}, ..{:?}]",
663                output.shape, self.info.output_spec.shape
664            ))
665            .into()),
666            None => Err(ModelError::InferenceFailed("model returned no output".to_string()).into()),
667        }
668    }
669
670    fn check_batch_size(batch_size: usize) -> Result<()> {
671        if batch_size == 0 {
672            return Err(ModelError::EmptyBatch.into());
673        }
674        if batch_size > MAX_BATCH_SIZE {
675            return Err(ModelError::BatchTooLarge {
676                size: batch_size,
677                max: MAX_BATCH_SIZE,
678            }
679            .into());
680        }
681        Ok(())
682    }
683
684    fn flatten_batch(&self, inputs: Vec<Vec<f32>>) -> Result<Vec<f32>> {
685        Self::check_batch_size(inputs.len())?;
686        let input_size: usize = self.info.input_spec.shape.iter().product();
687        let mut flattened = Vec::with_capacity(inputs.len() * input_size);
688        for input in inputs {
689            self.validate_input_shape(&input)?;
690            flattened.extend_from_slice(&input);
691        }
692        Ok(flattened)
693    }
694
695    /// Continue `previous`'s statistics, so a reloaded model's counters do not restart at zero.
696    /// The two share them from now on: requests still running on `previous` count as well.
697    pub fn adopt_stats(&mut self, previous: &Model) {
698        self.stats = previous.stats.clone();
699    }
700
701    /// Run one all-zero input through the model without touching the statistics, so the first
702    /// real request does not pay for lazy initialization (weight transforms, buffer pools,
703    /// page faults on the memory-mapped weights). Returns how long it took.
704    ///
705    /// A model with an input whose shape is only fixed per request cannot be warmed up without
706    /// guessing it, and is skipped (`Ok(None)`).
707    pub fn warm_up(&self) -> Result<Option<std::time::Duration>> {
708        let start = Instant::now();
709        // A graph with a fixed batch size runs only that many items at once
710        let batch = self.inner.fixed_batch().unwrap_or(1);
711        let Some(inputs) = self
712            .inputs
713            .iter()
714            .map(|spec| {
715                let mut shape = vec![batch];
716                shape.extend(spec.static_shape()?);
717                let len = shape.iter().product();
718                let values = if spec.dtype.is_int() {
719                    Values::I64(vec![0; len])
720                } else {
721                    Values::F32(vec![0.0; len])
722                };
723                Some(HostTensor { shape, values })
724            })
725            .collect::<Option<Vec<_>>>()
726        else {
727            return Ok(None);
728        };
729        self.inner.run(inputs)?;
730        Ok(Some(start.elapsed()))
731    }
732
733    /// Name, shapes, backend and the rest of what `GET /model/info` reports
734    pub fn get_info(&self) -> &ModelInfo {
735        &self.info
736    }
737
738    /// A snapshot of the request counters
739    pub fn get_stats(&self) -> ModelStats {
740        self.stats.lock().unwrap_or_else(|e| e.into_inner()).clone()
741    }
742
743    /// Whether `input` has one item's worth of values for a simple model's input
744    pub fn validate_input_shape(&self, input: &[f32]) -> Result<()> {
745        let expected_size: usize = self.info.input_spec.shape.iter().product();
746        if input.len() != expected_size {
747            return Err(ModelError::InputValidation {
748                batch: 1,
749                item: self.info.input_spec.shape.clone(),
750                got: input.len(),
751            }
752            .into());
753        }
754        Ok(())
755    }
756
757    /// The backend's name, as in [`ModelInfo::backend`]
758    pub fn get_backend_info(&self) -> String {
759        self.info.backend.clone()
760    }
761
762    /// How the model was defined, as in [`ModelInfo::model_type`]
763    pub fn model_type(&self) -> &'static str {
764        self.model_type
765    }
766}
767
768/// Requests' tensors joined along the batch dimension, input by input
769fn join_requests(requests: Vec<Vec<HostTensor>>) -> Result<Vec<HostTensor>> {
770    let count = requests.first().map_or(0, Vec::len);
771    let mut columns: Vec<Vec<HostTensor>> = (0..count).map(|_| Vec::new()).collect();
772    for inputs in requests {
773        for (column, tensor) in columns.iter_mut().zip(inputs) {
774            column.push(tensor);
775        }
776    }
777    columns.into_iter().map(HostTensor::concat_batch).collect()
778}
779
780/// The error of a forward pass, for each request that shared it. `FurnaceError` is not `Clone`
781/// (`LoadFailed` boxes its source), so the variant is rebuilt with the source kept as text.
782fn duplicate(error: &FurnaceError) -> FurnaceError {
783    let FurnaceError::Model(error) = error else {
784        return ModelError::InferenceFailed(error.to_string()).into();
785    };
786    match error {
787        ModelError::LoadFailed { path, source } => ModelError::LoadFailed {
788            path: path.clone(),
789            source: source.to_string().into(),
790        },
791        ModelError::InvalidFormat(m) => ModelError::InvalidFormat(m.clone()),
792        ModelError::FileNotFound(path) => ModelError::FileNotFound(path.clone()),
793        ModelError::InferenceFailed(m) => ModelError::InferenceFailed(m.clone()),
794        ModelError::InputValidation { batch, item, got } => ModelError::InputValidation {
795            batch: *batch,
796            item: item.clone(),
797            got: *got,
798        },
799        ModelError::EmptyBatch => ModelError::EmptyBatch,
800        ModelError::BatchTooLarge { size, max } => ModelError::BatchTooLarge {
801            size: *size,
802            max: *max,
803        },
804        ModelError::InvalidDataType(m) => ModelError::InvalidDataType(m.clone()),
805        ModelError::InvalidInput(m) => ModelError::InvalidInput(m.clone()),
806        ModelError::InvalidArgument { arg, value, reason } => ModelError::InvalidArgument {
807            arg: arg.clone(),
808            value: value.clone(),
809            reason: reason.clone(),
810        },
811    }
812    .into()
813}
814
815#[cfg(test)]
816mod tests {
817    use super::*;
818
819    fn get_test_model() -> Model {
820        let dummy = DummyModel::new("test".to_string(), vec![784], vec![10]);
821        Model::new(Box::new(dummy), "dummy", 0)
822    }
823
824    fn tensors(ids: Vec<i64>, scale: Vec<f32>) -> Vec<HostTensor> {
825        vec![
826            HostTensor {
827                shape: vec![scale.len(), 4],
828                values: Values::I64(ids),
829            },
830            HostTensor {
831                shape: vec![scale.len()],
832                values: Values::F32(scale),
833            },
834        ]
835    }
836
837    #[test]
838    fn named_tensors_in_and_out() {
839        let model = Model::new(Box::new(DummyTensorsModel::new(None)), "dummy", 0);
840        assert!(!model.is_simple());
841        let info = model.get_info();
842        assert_eq!(info.inputs[0].datatype, "INT64");
843        assert_eq!(info.inputs[0].shape, [-1, 4]);
844        assert_eq!(info.inputs[1].shape, [-1]);
845        assert_eq!(info.input_spec.dtype, "int64");
846
847        let outputs = model
848            .infer(tensors(vec![1, 2, 3, 4, 5, 6, 7, 8], vec![0.5, 2.0]))
849            .unwrap();
850        assert_eq!(outputs[0].shape, [2, 4]);
851        assert_eq!(
852            outputs[0].values,
853            Values::F32(vec![0.5, 1.0, 1.5, 2.0, 10.0, 12.0, 14.0, 16.0])
854        );
855        assert_eq!(outputs[1].values, Values::I64(vec![10, 26]));
856        assert_eq!(model.get_stats().success_count, 1);
857        // A model whose inputs are all fixed per item is warmed up
858        assert!(model.warm_up().unwrap().is_some());
859    }
860
861    #[test]
862    fn named_tensors_are_checked() {
863        let model = Model::new(Box::new(DummyTensorsModel::new(None)), "dummy", 0);
864        let check = |inputs: Vec<HostTensor>, expected: &str| {
865            let err = model.infer(inputs).unwrap_err().to_string();
866            assert!(err.contains(expected), "{expected:?} not in {err:?}");
867        };
868        let mut inputs = tensors(vec![1, 2, 3, 4], vec![1.0]);
869        inputs.pop();
870        check(inputs, "expected 2 inputs (ids, scale)");
871        let mut swapped = tensors(vec![1, 2, 3, 4], vec![1.0]);
872        swapped.swap(0, 1);
873        check(swapped, "'ids' must be INT64");
874        let mut short = tensors(vec![1, 2, 3], vec![1.0]);
875        short[0].shape = vec![1, 3];
876        check(short, "'ids' has shape [1, 3]");
877        check(
878            tensors(vec![1, 2, 3, 4, 5, 6, 7, 8], vec![1.0]),
879            "'ids' has shape [1, 4] (4 values) but 8 values",
880        );
881        let mut disagree = tensors(vec![1, 2, 3, 4, 5, 6, 7, 8], vec![1.0, 2.0]);
882        disagree[1] = HostTensor {
883            shape: vec![1],
884            values: Values::F32(vec![1.0]),
885        };
886        check(disagree, "disagree on the batch size");
887        check(
888            tensors(vec![1, 2, 3, 4], vec![f32::NAN]),
889            "'scale' has a non-finite value",
890        );
891        assert_eq!(model.get_stats().error_count, 6);
892    }
893
894    #[test]
895    fn requests_of_named_tensors_share_a_forward_pass() {
896        let model = Model::new(Box::new(DummyTensorsModel::new(None)), "dummy", 0);
897        assert!(model.batches_named());
898        let mut bad_shape = tensors(vec![1, 2, 3, 4], vec![1.0]);
899        bad_shape[0].shape = vec![2, 2];
900        let results = model.infer_many(vec![
901            tensors(vec![1, 2, 3, 4], vec![1.0]),
902            bad_shape,
903            tensors(vec![1, 1, 1, 1, 2, 2, 2, 2], vec![2.0, 0.5]),
904            tensors(vec![i64::from(i32::MAX) + 1, 0, 0, 0], vec![1.0]),
905        ]);
906        // The two valid requests ran as one batch of three items and got their own rows back
907        let first = results[0].as_ref().unwrap();
908        assert_eq!(first[0].shape, [1, 4]);
909        assert_eq!(first[1].values, Values::I64(vec![10]));
910        let third = results[2].as_ref().unwrap();
911        assert_eq!(third[0].shape, [2, 4]);
912        assert_eq!(
913            third[0].values,
914            Values::F32(vec![2.0, 2.0, 2.0, 2.0, 1.0, 1.0, 1.0, 1.0])
915        );
916        assert_eq!(third[1].values, Values::I64(vec![4, 8]));
917        // The invalid ones got their own errors, before reaching the model
918        assert!(
919            results[1]
920                .as_ref()
921                .unwrap_err()
922                .to_string()
923                .contains("'ids'")
924        );
925        let range = results[3].as_ref().unwrap_err().to_string();
926        assert!(range.contains("outside"), "{range}");
927        let stats = model.get_stats();
928        assert_eq!((stats.batch_count, stats.batched_items), (1, 3));
929        assert_eq!((stats.success_count, stats.error_count), (2, 2));
930    }
931
932    #[test]
933    fn fixed_batch_graphs_run_in_chunks() {
934        let model = Model::new(Box::new(DummyTensorsModel::new(Some(2))), "dummy", 0);
935        let outputs = model
936            .infer(tensors((1..=24).collect(), vec![1.0; 6]))
937            .unwrap();
938        assert_eq!(outputs[0].shape, [6, 4]);
939        assert_eq!(outputs[1].values, Values::I64(vec![10, 26, 42, 58, 74, 90]));
940        let err = model
941            .infer(tensors((1..=12).collect(), vec![1.0; 3]))
942            .unwrap_err()
943            .to_string();
944        assert!(err.contains("runs batches of 2"), "{err}");
945        // Warming up runs the batch the graph was built for, not one item
946        assert!(model.warm_up().unwrap().is_some());
947    }
948
949    /// A simple model exported for batches of two only
950    #[derive(Debug)]
951    struct PairsOnly(DummyModel);
952
953    impl TensorModel for PairsOnly {
954        fn name(&self) -> &str {
955            self.0.name()
956        }
957        fn backend(&self) -> &str {
958            self.0.backend()
959        }
960        fn inputs(&self) -> Vec<IoSpec> {
961            self.0.inputs()
962        }
963        fn outputs(&self) -> Vec<IoSpec> {
964            self.0.outputs()
965        }
966        fn run(&self, inputs: Vec<HostTensor>) -> Result<Vec<HostTensor>> {
967            self.0.run(inputs)
968        }
969        fn fixed_batch(&self) -> Option<usize> {
970            Some(2)
971        }
972    }
973
974    #[test]
975    fn a_batch_the_graph_cannot_run_is_the_clients_error() {
976        let dummy = DummyModel::new("pairs".to_string(), vec![3], vec![2]);
977        let model = Model::new(Box::new(PairsOnly(dummy)), "dummy", 0);
978        let err = model
979            .predict_many(vec![FlatInput {
980                data: vec![1.0; 9],
981                batch_size: 3,
982            }])
983            .pop()
984            .unwrap()
985            .unwrap_err();
986        assert!(err.is_client_error(), "{err:?}");
987        assert!(!err.to_string().contains("Inference failed"), "{err}");
988        assert!(err.to_string().contains("runs batches of 2"), "{err}");
989        let pairs = model.predict_many(vec![FlatInput {
990            data: vec![1.0; 12],
991            batch_size: 4,
992        }]);
993        assert_eq!(pairs[0].as_ref().unwrap().len(), 8);
994        assert!(model.warm_up().unwrap().is_some());
995    }
996
997    #[test]
998    fn a_shared_failure_keeps_its_error_for_every_request() {
999        // Three items for a graph of pairs: the joined pass fails, and each request gets the
1000        // client error itself rather than its text as an inference failure
1001        let model = Model::new(Box::new(DummyTensorsModel::new(Some(2))), "dummy", 0);
1002        let results = model.infer_many(vec![
1003            tensors(vec![1, 2, 3, 4], vec![1.0]),
1004            tensors(vec![1, 2, 3, 4, 5, 6, 7, 8], vec![1.0, 2.0]),
1005        ]);
1006        for result in results {
1007            let err = result.unwrap_err();
1008            assert!(
1009                matches!(
1010                    err,
1011                    crate::error::FurnaceError::Model(ModelError::InvalidInput(_))
1012                ),
1013                "{err:?}"
1014            );
1015            assert!(err.to_string().contains("runs batches of 2"), "{err}");
1016        }
1017        assert_eq!(model.get_stats().error_count, 2);
1018    }
1019
1020    #[test]
1021    fn an_input_without_a_batch_dimension_is_rejected() {
1022        let model = Model::new(Box::new(DummyTensorsModel::new(None)), "dummy", 0);
1023        let mut inputs = tensors(vec![1, 2, 3, 4], vec![1.0]);
1024        inputs[1].shape = vec![];
1025        assert_eq!(inputs[1].batch(), 0);
1026        let err = model.infer(inputs).unwrap_err().to_string();
1027        assert!(err.contains("'scale' has no batch dimension"), "{err}");
1028    }
1029
1030    #[test]
1031    fn test_model_info() {
1032        let model = get_test_model();
1033        let info = model.get_info();
1034        assert_eq!(info.model_type, "dummy");
1035        assert_eq!(info.backend, "cpu");
1036        assert_eq!(info.input_spec.shape, vec![784]);
1037        assert_eq!(info.output_spec.shape, vec![10]);
1038        assert_eq!(info.input_spec.dtype, "float32");
1039    }
1040
1041    #[test]
1042    fn test_inference_with_valid_input() {
1043        let model = get_test_model();
1044        let output = model.predict(vec![0.5; 784]).unwrap();
1045        assert_eq!(output.len(), 10);
1046    }
1047
1048    #[test]
1049    fn test_batch_inference_splits_outputs() {
1050        let model = get_test_model();
1051        let outputs = model.predict_batch(vec![vec![0.5; 784]; 3]).unwrap();
1052        assert_eq!(outputs.len(), 3);
1053        assert!(outputs.iter().all(|o| o.len() == 10));
1054    }
1055
1056    #[test]
1057    fn test_predict_flat_matches_batch() {
1058        let model = get_test_model();
1059        let flat = model.predict_flat(vec![0.5; 784 * 3], 3).unwrap();
1060        let batch = model.predict_batch(vec![vec![0.5; 784]; 3]).unwrap();
1061        assert_eq!(flat, batch.concat());
1062        assert_eq!(model.get_stats().success_count, 2);
1063    }
1064
1065    #[test]
1066    fn test_predict_flat_rejects_bad_input() {
1067        let model = get_test_model();
1068        // Length not a multiple of the batch: the message counts values
1069        let err = model.predict_flat(vec![0.5; 784 * 2 + 1], 2).unwrap_err();
1070        assert!(matches!(
1071            err,
1072            crate::error::FurnaceError::Model(ModelError::InputValidation { .. })
1073        ));
1074        assert_eq!(
1075            err.to_string(),
1076            "expected 1568 values (batch 2 × [784]), got 1569"
1077        );
1078        let err = model.predict_flat(vec![], 0).unwrap_err();
1079        assert!(matches!(
1080            err,
1081            crate::error::FurnaceError::Model(ModelError::EmptyBatch)
1082        ));
1083        let err = model
1084            .predict_flat(vec![0.5; 784], MAX_BATCH_SIZE + 1)
1085            .unwrap_err();
1086        assert!(matches!(
1087            err,
1088            crate::error::FurnaceError::Model(ModelError::BatchTooLarge { .. })
1089        ));
1090
1091        // Non-finite values report the batch item and index
1092        let mut input = vec![0.5; 784 * 2];
1093        input[784 + 5] = f32::NAN;
1094        let err = model.predict_flat(input, 2).unwrap_err().to_string();
1095        assert!(err.contains("batch[1][5]"), "{err}");
1096        assert_eq!(model.get_stats().error_count, 4);
1097    }
1098
1099    #[test]
1100    fn test_inference_with_invalid_shape() {
1101        let model = get_test_model();
1102        let result = model.predict(vec![0.5; 100]);
1103        assert!(matches!(
1104            result,
1105            Err(crate::error::FurnaceError::Model(
1106                ModelError::InputValidation { .. }
1107            ))
1108        ));
1109    }
1110
1111    #[test]
1112    fn test_inference_rejects_non_finite() {
1113        let model = get_test_model();
1114        let mut input = vec![0.5; 784];
1115        input[42] = f32::NAN;
1116        assert!(matches!(
1117            model.predict(input),
1118            Err(crate::error::FurnaceError::Model(
1119                ModelError::InvalidDataType(_)
1120            ))
1121        ));
1122    }
1123
1124    #[test]
1125    fn a_reloaded_model_shares_its_predecessors_stats() {
1126        let old = get_test_model();
1127        old.predict(vec![0.5; 784]).unwrap();
1128        let mut new = get_test_model();
1129        new.adopt_stats(&old);
1130        // A request that was still running on the old model when it was replaced
1131        old.predict(vec![0.5; 784]).unwrap();
1132        new.predict(vec![0.5; 784]).unwrap();
1133        assert_eq!(new.get_stats().success_count, 3);
1134        assert_eq!(old.get_stats().success_count, 3);
1135    }
1136
1137    #[test]
1138    fn test_model_stats() {
1139        let model = get_test_model();
1140        let stats = model.get_stats();
1141        assert_eq!(stats.inference_count, 0);
1142        assert!(stats.last_inference_time.is_none());
1143
1144        model.predict(vec![0.5; 784]).unwrap();
1145        let _ = model.predict(vec![0.5; 1]);
1146
1147        let stats = model.get_stats();
1148        assert_eq!(stats.inference_count, 2);
1149        assert_eq!(stats.success_count, 1);
1150        assert_eq!(stats.error_count, 1);
1151        assert!(stats.last_inference_time.is_some());
1152        assert!(stats.min_inference_time_ms.is_some());
1153    }
1154}