Skip to main content

silvic_neuron/
serving.rs

1//! Explicit float32 serving with resident forest metadata and bounded row tiles.
2
3use silvic_core::data::{DenseData, DenseMatrix, Layout};
4use silvic_core::model::{Model, ModelObjective};
5use silvic_core::tree::{Tree, CATEGORICAL_MASK};
6use silvic_core::{Result, SilvicError};
7
8use crate::runtime::{Buffer, DType, Device, Execution, TensorSpec};
9
10const TILE_ROWS: usize = 32;
11const META_FIELDS: usize = 9;
12const VALUE_FIELDS: usize = 3;
13
14struct PackedForest {
15    metadata: Vec<i32>,
16    values: Vec<f32>,
17    categories: Vec<u32>,
18    records: usize,
19}
20
21fn threshold_f32(value: f64) -> f32 {
22    let rounded = value as f32;
23    if f64::from(rounded) > value {
24        rounded.next_down()
25    } else {
26        rounded
27    }
28}
29
30fn device_index(value: usize, what: &str) -> Result<i32> {
31    i32::try_from(value)
32        .map_err(|_| SilvicError::model(format!("Neuron {what} exceeds signed 32-bit device addressing")))
33}
34
35impl PackedForest {
36    fn new(model: &Model) -> Result<Self> {
37        device_index(model.num_features(), "feature count")?;
38        device_index(model.trees.len(), "tree count")?;
39        let mut packed = Self {
40            metadata: Vec::new(),
41            values: Vec::new(),
42            categories: vec![0],
43            records: 0,
44        };
45        for (tree_index, tree) in model.trees.iter().enumerate() {
46            let nodes = checked_preorder(tree, model.num_features())
47                .map_err(|message| SilvicError::model(format!("tree {tree_index}: {message}")))?;
48            let category_base = packed.categories.len();
49            let category_end = category_base
50                .checked_add(tree.cat_threshold.len())
51                .ok_or_else(|| SilvicError::model("Neuron category storage overflows usize"))?;
52            device_index(category_end, "category storage")?;
53            packed.categories.extend_from_slice(&tree.cat_threshold);
54            if nodes.is_empty() {
55                packed.push(
56                    [0, -1, -1, 0, 0, 0, 0, tree_index as i32, 1],
57                    [f32::NAN, tree.leaf_value[0] as f32, tree.leaf_value[0] as f32],
58                )?;
59                continue;
60            }
61            for (position, &node) in nodes.iter().enumerate() {
62                let categorical = tree.num_cat > 0 && tree.decision_type[node] & CATEGORICAL_MASK != 0;
63                let (category_start, category_len) = if categorical {
64                    let category = tree.threshold[node] as usize;
65                    let start = tree.cat_boundaries[category] as usize;
66                    let end = tree.cat_boundaries[category + 1] as usize;
67                    (
68                        device_index(category_base + start, "category offset")?,
69                        device_index(end - start, "category length")?,
70                    )
71                } else {
72                    (0, 0)
73                };
74                let leaf = |child: i32| {
75                    if child < 0 {
76                        tree.leaf_value[!child as usize] as f32
77                    } else {
78                        0.0
79                    }
80                };
81                packed.push(
82                    [
83                        node as i32,
84                        tree.left_child[node],
85                        tree.right_child[node],
86                        tree.split_feature[node],
87                        i32::from(tree.decision_type[node] as u8 & !(CATEGORICAL_MASK as u8)) | i32::from(categorical),
88                        category_start,
89                        category_len,
90                        tree_index as i32,
91                        i32::from(position + 1 == nodes.len()),
92                    ],
93                    [
94                        threshold_f32(tree.threshold[node]),
95                        leaf(tree.left_child[node]),
96                        leaf(tree.right_child[node]),
97                    ],
98                )?;
99            }
100        }
101        // An inert tree outside the zero-length prefix keeps all runtime ports live.
102        if packed.records == 0 {
103            packed.push([0, -1, -1, 0, 0, 0, 0, 0, 1], [f32::NAN, 0.0, 0.0])?;
104        }
105        Ok(packed)
106    }
107
108    fn push(&mut self, metadata: [i32; META_FIELDS], values: [f32; VALUE_FIELDS]) -> Result<()> {
109        let records = self
110            .records
111            .checked_add(1)
112            .ok_or_else(|| SilvicError::model("Neuron forest record count overflows usize"))?;
113        let elements = records
114            .checked_mul(META_FIELDS)
115            .ok_or_else(|| SilvicError::model("Neuron forest metadata size overflows usize"))?;
116        device_index(elements, "forest metadata storage")?;
117        self.metadata.extend_from_slice(&metadata);
118        self.values.extend_from_slice(&values);
119        self.records = records;
120        Ok(())
121    }
122}
123
124// The same reachable-node validation as the existing serving compiler: ignore unused
125// trailing arrays, reject repeated internal nodes, and validate only read bitsets.
126fn checked_preorder(tree: &Tree, features: usize) -> std::result::Result<Vec<usize>, String> {
127    if tree.leaf_value.is_empty() {
128        return Err("no leaf values".into());
129    }
130    if tree.num_leaves <= 1 {
131        return Ok(Vec::new());
132    }
133    let nodes = tree.num_leaves - 1;
134    if nodes > i32::MAX as usize {
135        return Err("node count exceeds signed 32-bit device addressing".into());
136    }
137    if [
138        tree.split_feature.len(),
139        tree.threshold.len(),
140        tree.decision_type.len(),
141        tree.left_child.len(),
142        tree.right_child.len(),
143    ]
144    .iter()
145    .any(|&len| len < nodes)
146        || tree.leaf_value.len() < tree.num_leaves
147    {
148        return Err(format!("node arrays shorter than num_leaves={}", tree.num_leaves));
149    }
150    let mut seen = vec![false; nodes];
151    let mut stack = vec![0i32];
152    let mut preorder = Vec::new();
153    while let Some(child) = stack.pop() {
154        if child < 0 {
155            if !child as usize >= tree.num_leaves {
156                return Err(format!("child {child} names an out-of-range leaf"));
157            }
158            continue;
159        }
160        let node = child as usize;
161        if node >= nodes || seen[node] {
162            return Err(format!("node {node} is out of range or reached twice"));
163        }
164        seen[node] = true;
165        let feature = tree.split_feature[node];
166        if usize::try_from(feature).map_or(true, |feature| feature >= features) {
167            return Err(format!("node {node} splits on feature {feature} of {features}"));
168        }
169        if tree.num_cat > 0 && tree.decision_type[node] & CATEGORICAL_MASK != 0 {
170            let threshold = tree.threshold[node];
171            let category = threshold as usize;
172            let valid =
173                threshold >= 0.0 && category < tree.num_cat && category < tree.cat_boundaries.len().saturating_sub(1);
174            if !valid {
175                return Err(format!("node {node} names categorical bitset {threshold}"));
176            }
177            let start = tree.cat_boundaries[category];
178            let end = tree.cat_boundaries[category + 1];
179            if start < 0 || end < start || end as usize > tree.cat_threshold.len() {
180                return Err(format!("node {node} names categorical bitset {threshold}"));
181            }
182        }
183        preorder.push(node);
184        stack.push(tree.right_child[node]);
185        stack.push(tree.left_child[node]);
186    }
187    Ok(preorder)
188}
189
190fn check_matrix(rows: &DenseMatrix<'_>, features: usize) -> Result<()> {
191    if rows.cols < features {
192        return Err(SilvicError::config(format!(
193            "the matrix has {} columns, the model reads {features} features",
194            rows.cols
195        )));
196    }
197    let elements = rows
198        .rows
199        .checked_mul(rows.cols)
200        .ok_or_else(|| SilvicError::config("Neuron prediction matrix dimensions overflow usize"))?;
201    let available = match rows.data {
202        DenseData::F32(values) => values.len(),
203        DenseData::F64(values) => values.len(),
204    };
205    if elements > available || rows.rows > isize::MAX as usize / std::mem::size_of::<f32>() {
206        return Err(SilvicError::config(
207            "Neuron prediction matrix storage is too short or output is too large",
208        ));
209    }
210    Ok(())
211}
212
213/// A float32 Neuron predictor. Inputs, leaves, and ordered tree sums are float32;
214/// numerical thresholds round down. Device failures never select a CPU fallback.
215pub struct NeuronPredictor {
216    input: Buffer,
217    prefix: Buffer,
218    output: Buffer,
219    execution: Execution,
220    input_tile: Vec<f32>,
221    output_tile: [f32; TILE_ROWS],
222    num_features: usize,
223    num_trees: usize,
224    objective: ModelObjective,
225}
226
227impl NeuronPredictor {
228    /// Validates and uploads a forest once and binds reusable, bounded row tiles.
229    pub fn new(model: &Model, ordinal: Option<usize>) -> Result<Self> {
230        let packed = PackedForest::new(model)?;
231        let input_elements = model
232            .num_features()
233            .max(1)
234            .checked_mul(TILE_ROWS)
235            .ok_or_else(|| SilvicError::model("Neuron prediction tile size overflows usize"))?;
236        device_index(input_elements, "input tile storage")?;
237        let device = Device::new(ordinal)?;
238        let metadata = device.upload(&packed.metadata)?;
239        let values = device.upload(&packed.values)?;
240        let categories = device.upload(&packed.categories)?;
241        let input_tile = vec![0.0f32; input_elements];
242        let input = device.upload(&input_tile)?;
243        let prefix = device.upload(&[model.trees.len() as i32])?;
244        let output = device.allocate(TILE_ROWS * std::mem::size_of::<f32>())?;
245        let inputs = [
246            TensorSpec::new("input", DType::F32, vec![1, input_elements]),
247            TensorSpec::new("metadata", DType::I32, vec![1, packed.metadata.len()]),
248            TensorSpec::new("values", DType::F32, vec![1, packed.values.len()]),
249            TensorSpec::new("categories", DType::U32, vec![packed.categories.len(), 1]),
250            TensorSpec::new("prefix", DType::I32, vec![1, 1]),
251        ];
252        let outputs = [TensorSpec::new("scores", DType::F32, vec![1, TILE_ROWS])];
253        let kernel = device.compile(
254            include_str!("../kernels/serving.py"),
255            "score",
256            &serde_json::json!({"RECORDS": packed.records, "ROWS": TILE_ROWS}),
257            &inputs,
258            &outputs,
259        )?;
260        let execution = kernel.bind(
261            &[input.clone(), metadata, values, categories, prefix.clone()],
262            std::slice::from_ref(&output),
263        )?;
264        Ok(Self {
265            input,
266            prefix,
267            output,
268            execution,
269            input_tile,
270            output_tile: [0.0; TILE_ROWS],
271            num_features: model.num_features(),
272            num_trees: model.trees.len(),
273            objective: model.objective.clone(),
274        })
275    }
276
277    pub fn num_trees(&self) -> usize {
278        self.num_trees
279    }
280
281    pub fn num_features(&self) -> usize {
282        self.num_features
283    }
284
285    /// Writes one raw float32 score per row using a clamped tree prefix.
286    /// Accepts f32/f64 input in either layout; f64 is quantized before decisions.
287    /// Invalid dimensions, storage, or output lengths fail before any output write.
288    pub fn predict_raw(&mut self, rows: &DenseMatrix<'_>, num_trees: Option<usize>, out: &mut [f32]) -> Result<()> {
289        check_matrix(rows, self.num_features)?;
290        if out.len() != rows.rows {
291            return Err(SilvicError::config("Neuron prediction requires one output per row"));
292        }
293        if rows.rows == 0 {
294            return Ok(());
295        }
296        self.prefix
297            .write(&[num_trees.unwrap_or(self.num_trees).min(self.num_trees) as i32])?;
298        for first in (0..rows.rows).step_by(TILE_ROWS) {
299            let count = (rows.rows - first).min(TILE_ROWS);
300            for feature in 0..self.num_features {
301                let tile = &mut self.input_tile[feature * TILE_ROWS..(feature + 1) * TILE_ROWS];
302                for (lane, value) in tile[..count].iter_mut().enumerate() {
303                    let row = first + lane;
304                    let index = match rows.layout {
305                        Layout::RowMajor => row * rows.cols + feature,
306                        Layout::ColumnMajor => feature * rows.rows + row,
307                    };
308                    *value = match rows.data {
309                        DenseData::F32(values) => values[index],
310                        DenseData::F64(values) => values[index] as f32,
311                    };
312                }
313                tile[count..].fill(0.0);
314            }
315            self.input.write(&self.input_tile)?;
316            self.execution.execute()?;
317            self.output.read(&mut self.output_tile)?;
318            out[first..first + count].copy_from_slice(&self.output_tile[..count]);
319        }
320        Ok(())
321    }
322
323    /// Scores rows and optionally applies the objective in host float32 arithmetic.
324    pub fn predict(&mut self, rows: &DenseMatrix<'_>, raw_score: bool, num_trees: Option<usize>) -> Result<Vec<f32>> {
325        check_matrix(rows, self.num_features)?;
326        let mut out = vec![0.0f32; rows.rows];
327        self.predict_raw(rows, num_trees, &mut out)?;
328        if !raw_score {
329            if let ModelObjective::Binary { sigmoid } = self.objective {
330                let sigmoid = sigmoid as f32;
331                for value in &mut out {
332                    *value = 1.0f32 / (1.0f32 + (-sigmoid * *value).exp());
333                }
334            }
335        }
336        Ok(out)
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    fn model(trees: Vec<Tree>) -> Model {
345        Model {
346            objective: ModelObjective::Regression,
347            feature_names: vec!["f0".into()],
348            feature_infos: vec!["none".into()],
349            trees,
350            parameters: Vec::new(),
351            pandas_categorical: None,
352        }
353    }
354
355    #[test]
356    fn thresholds_round_toward_negative_infinity() {
357        for exact in [
358            -f64::MAX,
359            -1.0e-300,
360            -0.0,
361            0.0,
362            1.0e-300,
363            0.1,
364            f64::from(1.0f32.next_up()).next_down(),
365            f64::MAX,
366            f64::NEG_INFINITY,
367            f64::INFINITY,
368        ] {
369            let rounded = threshold_f32(exact);
370            assert!(f64::from(rounded) <= exact);
371            if rounded != f32::INFINITY {
372                assert!(f64::from(rounded.next_up()) > exact || exact == f64::INFINITY);
373            }
374        }
375        assert!(threshold_f32(f64::NAN).is_nan());
376        assert_eq!(threshold_f32(-0.0).to_bits(), (-0.0f32).to_bits());
377    }
378
379    #[test]
380    fn malformed_models_fail_before_device_creation() {
381        let mut tree = Tree::new();
382        tree.leaf_value.clear();
383        assert!(PackedForest::new(&model(vec![tree])).is_err());
384        let mut tree = Tree::new();
385        tree.num_leaves = 2;
386        assert!(PackedForest::new(&model(vec![tree])).is_err());
387        let mut tree = Tree::new();
388        tree.num_leaves = 2;
389        tree.split_feature = vec![0];
390        tree.threshold = vec![0.0];
391        tree.decision_type = vec![0];
392        tree.left_child = vec![0];
393        tree.right_child = vec![-2];
394        tree.leaf_value = vec![1.0, 2.0];
395        assert!(PackedForest::new(&model(vec![tree.clone()])).is_err());
396        tree.left_child[0] = -3;
397        assert!(PackedForest::new(&model(vec![tree.clone()])).is_err());
398        tree.left_child[0] = -1;
399        tree.split_feature[0] = -1;
400        assert!(PackedForest::new(&model(vec![tree.clone()])).is_err());
401        tree.split_feature[0] = 0;
402        tree.num_cat = 1;
403        tree.decision_type[0] = 1;
404        tree.cat_boundaries = vec![0, 2];
405        tree.cat_threshold = vec![u32::MAX];
406        assert!(PackedForest::new(&model(vec![tree.clone()])).is_err());
407        tree.cat_boundaries[1] = 1;
408        assert!(PackedForest::new(&model(vec![tree])).is_ok());
409    }
410
411    #[test]
412    fn invalid_matrix_boundaries_are_checked_without_device() {
413        for matrix in [
414            DenseMatrix {
415                data: DenseData::F32(&[]),
416                rows: 1,
417                cols: 0,
418                layout: Layout::RowMajor,
419            },
420            DenseMatrix {
421                data: DenseData::F32(&[]),
422                rows: 1,
423                cols: 1,
424                layout: Layout::ColumnMajor,
425            },
426            DenseMatrix {
427                data: DenseData::F64(&[]),
428                rows: usize::MAX,
429                cols: 2,
430                layout: Layout::RowMajor,
431            },
432            DenseMatrix {
433                data: DenseData::F64(&[]),
434                rows: isize::MAX as usize,
435                cols: 0,
436                layout: Layout::RowMajor,
437            },
438        ] {
439            assert!(check_matrix(&matrix, 1).is_err());
440        }
441        assert!(check_matrix(
442            &DenseMatrix {
443                data: DenseData::F32(&[]),
444                rows: 100,
445                cols: 0,
446                layout: Layout::RowMajor
447            },
448            0
449        )
450        .is_ok());
451        assert!(check_matrix(
452            &DenseMatrix {
453                data: DenseData::F64(&[]),
454                rows: 0,
455                cols: 1,
456                layout: Layout::ColumnMajor
457            },
458            1
459        )
460        .is_ok());
461    }
462}