Skip to main content

silvic_neuron/
training.rs

1use std::sync::Arc;
2
3use rayon::{prelude::*, ThreadPool};
4use serde_json::json;
5use silvic_core::{
6    backend::{BagSpec, BinSplit, DeviceTree, GradientStats, LeafRange, ObjectiveSetup},
7    objective,
8    quantize::{self, QuantSpec},
9    Backend, BinnedDataset, Result, SilvicError,
10};
11
12use crate::runtime::{Buffer, DType, Device, Execution, TensorSpec};
13
14const SOURCE: &str = include_str!("../kernels/training.py");
15const TILE: usize = 128;
16const HIST_BINS: usize = 128;
17const CHUNKS: usize = 16;
18const LIMBS: usize = 8;
19const NODE_FIELDS: usize = 11;
20
21fn error(message: impl Into<Arc<str>>) -> SilvicError {
22    SilvicError::device(message)
23}
24fn install<R: Send>(pool: &Option<Arc<ThreadPool>>, f: impl FnOnce() -> R + Send) -> R {
25    match pool {
26        Some(pool) => pool.install(f),
27        None => f(),
28    }
29}
30fn spec(name: &str, dtype: DType, shape: &[usize]) -> TensorSpec {
31    let mut shape = shape.to_vec();
32    shape.push(1);
33    TensorSpec::new(name, dtype, shape)
34}
35fn padded(rows: usize) -> Result<usize> {
36    rows.max(1)
37        .checked_add(TILE - 1)
38        .map(|n| n / TILE * TILE)
39        .ok_or_else(|| error("Neuron row capacity overflow"))
40}
41fn zeros(device: &Device, words: usize) -> Result<Buffer> {
42    let bytes = words
43        .max(1)
44        .checked_mul(4)
45        .ok_or_else(|| error("Neuron buffer size overflow"))?;
46    device.allocate(bytes)
47}
48
49struct Dataset {
50    rows: usize,
51    capacity: usize,
52    bins: Buffer,
53    scores: Buffer,
54    next_scores: Buffer,
55    columns: usize,
56    group_columns: Vec<usize>,
57}
58
59struct Work {
60    partition: Buffer,
61    values: Buffer,
62    limbs: Buffer,
63    meta: Buffer,
64    split: Buffer,
65    counts: Buffer,
66    offsets: Buffer,
67    partials: Buffer,
68    leaf_partials: Buffer,
69    histogram: Execution,
70    leaf_sum: Execution,
71    partition_flags: Execution,
72    partition_scatter: Execution,
73    copy_partition: Execution,
74    host_partials: Vec<f32>,
75    host_leaf_partials: Vec<f32>,
76    host_counts: Vec<u32>,
77    host_offsets: Vec<u32>,
78}
79
80struct TreeWork {
81    capacity: usize,
82    nodes: Buffer,
83    leaves: Buffer,
84    meta: Buffer,
85    executions: Vec<(Execution, Execution)>,
86}
87
88struct Loaded {
89    datasets: Vec<Dataset>,
90    train: BinnedDataset,
91    objective: ObjectiveSetup,
92    grad: Vec<f32>,
93    hess: Vec<f32>,
94    host_values: Vec<f32>,
95    quant_values: Vec<f32>,
96    host_limbs: Vec<i32>,
97    host_scores: Vec<f32>,
98    bag: Option<(BagSpec, Vec<u32>)>,
99    partition_len: usize,
100    quantized: bool,
101    quant_ready: bool,
102    work: Work,
103    tree: Option<TreeWork>,
104}
105
106/// Neuron-resident histogram, stable partition and binned tree-scoring backend.
107/// Objectives, stochastic quantization, bag draws and fixed-point encoding use
108/// core's host helpers; no CPU backend is constructed or used.
109pub struct NeuronBackend {
110    device: Device,
111    pool: Option<Arc<ThreadPool>>,
112    loaded: Option<Loaded>,
113}
114
115impl NeuronBackend {
116    pub fn new(ordinal: Option<usize>) -> Result<Self> {
117        Ok(Self {
118            device: Device::new(ordinal)?,
119            pool: None,
120            loaded: None,
121        })
122    }
123
124    fn state(&mut self) -> Result<&mut Loaded> {
125        self.loaded
126            .as_mut()
127            .ok_or_else(|| error("Neuron backend used before load"))
128    }
129}
130
131fn upload_dataset(device: &Device, data: &BinnedDataset) -> Result<Dataset> {
132    if data.num_rows > u32::MAX as usize {
133        return Err(error("Neuron supports at most 2^32 - 1 rows per rank"));
134    }
135    let capacity = padded(data.num_rows)?;
136    let mut group_columns = Vec::with_capacity(data.groups.len());
137    let mut columns = 0usize;
138    for group in &data.groups {
139        group_columns.push(columns);
140        columns = columns
141            .checked_add(group.row_stride as usize)
142            .ok_or_else(|| error("Neuron column overflow"))?;
143        if group.data.len()
144            != data
145                .num_rows
146                .checked_mul(group.row_stride as usize)
147                .ok_or_else(|| error("group size overflow"))?
148        {
149            return Err(SilvicError::data("feature group does not match its row stride"));
150        }
151    }
152    let words = columns
153        .max(1)
154        .checked_mul(capacity)
155        .ok_or_else(|| error("Neuron dataset size overflow"))?;
156    if words > i32::MAX as usize {
157        return Err(error("Neuron binned tensor exceeds signed 32-bit gather addressing"));
158    }
159    let mut bins = vec![0u32; words];
160    for (g, group) in data.groups.iter().enumerate() {
161        for row in 0..data.num_rows {
162            for col in 0..group.row_stride as usize {
163                bins[(group_columns[g] + col) * capacity + row] =
164                    u32::from(group.data[row * group.row_stride as usize + col]);
165            }
166        }
167    }
168    Ok(Dataset {
169        rows: data.num_rows,
170        capacity,
171        bins: device.upload(&bins)?,
172        scores: device.upload(&vec![0f32; capacity])?,
173        next_scores: zeros(device, capacity)?,
174        columns: columns.max(1),
175        group_columns,
176    })
177}
178
179fn bind(
180    device: &Device,
181    entry: &str,
182    constants: serde_json::Value,
183    inputs: Vec<TensorSpec>,
184    outputs: Vec<TensorSpec>,
185    ib: &[Buffer],
186    ob: &[Buffer],
187) -> Result<Execution> {
188    device
189        .compile(SOURCE, entry, &constants, &inputs, &outputs)?
190        .bind(ib, ob)
191}
192
193fn work(device: &Device, data: &Dataset) -> Result<Work> {
194    let n = data.capacity;
195    let blocks = n / TILE;
196    let partition = zeros(device, n)?;
197    let next_partition = zeros(device, n)?;
198    let values = zeros(device, 2 * n)?;
199    let limbs = zeros(device, LIMBS * n)?;
200    let meta = device.upload(&[0u32; 8])?;
201    let split = device.upload(&[0u32; 8])?;
202    let flags = zeros(device, n)?;
203    let counts = zeros(device, blocks)?;
204    let offsets = zeros(device, 2 * blocks)?;
205    let partials = zeros(device, CHUNKS * 2 * HIST_BINS)?;
206    let leaf_partials = zeros(device, CHUNKS * LIMBS)?;
207    let constants = json!({"ROWS": n, "COLUMNS": data.columns, "TILE": TILE, "CHUNKS": CHUNKS, "BINS": HIST_BINS});
208    let histogram = bind(
209        device,
210        "histogram",
211        constants,
212        vec![
213            spec("bins", DType::I32, &[data.columns * n]),
214            spec("partition", DType::I32, &[n]),
215            spec("values", DType::F32, &[2, n]),
216            spec("meta", DType::I32, &[8]),
217        ],
218        vec![spec("partials", DType::F32, &[CHUNKS, 2, HIST_BINS])],
219        &[data.bins.clone(), partition.clone(), values.clone(), meta.clone()],
220        std::slice::from_ref(&partials),
221    )?;
222    let leaf_sum = bind(
223        device,
224        "leaf_sums",
225        json!({"ROWS":n,"TILE":TILE,"CHUNKS":CHUNKS,"LIMBS":LIMBS}),
226        vec![
227            spec("partition", DType::I32, &[n]),
228            spec("limbs", DType::I32, &[LIMBS, n]),
229            spec("meta", DType::I32, &[8]),
230        ],
231        vec![spec("partials", DType::F32, &[CHUNKS, LIMBS])],
232        &[partition.clone(), limbs.clone(), meta.clone()],
233        std::slice::from_ref(&leaf_partials),
234    )?;
235    let partition_flags = bind(
236        device,
237        "partition_flags",
238        json!({"ROWS":n,"TILE":TILE}),
239        vec![
240            spec("bins", DType::I32, &[data.columns * n]),
241            spec("partition", DType::I32, &[n]),
242            spec("meta", DType::I32, &[8]),
243            spec("split", DType::I32, &[8]),
244        ],
245        vec![spec("flags", DType::I32, &[n]), spec("counts", DType::I32, &[blocks])],
246        &[data.bins.clone(), partition.clone(), meta.clone(), split.clone()],
247        &[flags.clone(), counts.clone()],
248    )?;
249    let partition_scatter = bind(
250        device,
251        "partition_scatter",
252        json!({"ROWS":n,"TILE":TILE}),
253        vec![
254            spec("partition", DType::I32, &[n]),
255            spec("flags", DType::I32, &[n]),
256            spec("offsets", DType::I32, &[2 * blocks]),
257            spec("meta", DType::I32, &[8]),
258        ],
259        vec![spec("result", DType::I32, &[n])],
260        &[partition.clone(), flags.clone(), offsets.clone(), meta.clone()],
261        std::slice::from_ref(&next_partition),
262    )?;
263    let copy_partition = bind(
264        device,
265        "copy_u32",
266        json!({"ROWS":n,"TILE":TILE}),
267        vec![spec("source", DType::I32, &[n])],
268        vec![spec("result", DType::I32, &[n])],
269        std::slice::from_ref(&next_partition),
270        std::slice::from_ref(&partition),
271    )?;
272    Ok(Work {
273        partition,
274        values,
275        limbs,
276        meta,
277        split,
278        counts,
279        offsets,
280        partials,
281        leaf_partials,
282        histogram,
283        leaf_sum,
284        partition_flags,
285        partition_scatter,
286        copy_partition,
287        host_partials: vec![0.0; CHUNKS * 2 * HIST_BINS],
288        host_leaf_partials: vec![0.0; CHUNKS * LIMBS],
289        host_counts: vec![0; blocks],
290        host_offsets: vec![0; 2 * blocks],
291    })
292}
293
294fn check_range(state: &Loaded, leaf: LeafRange) -> Result<()> {
295    if u64::from(leaf.begin) + u64::from(leaf.count) > state.partition_len as u64 {
296        return Err(error("leaf lies outside the active partition"));
297    }
298    Ok(())
299}
300
301// Four radix-2^16 digits encode a signed i64 without a lossy i64-to-f32 cast.
302// The top digit is signed. Every 128-row partial is <=128*65535<2^24,
303// so every intermediate integer addition in the f32 reduction is exact.
304fn encode_fixed(value: i64, out: &mut [i32; 4]) {
305    for (k, digit) in out.iter_mut().enumerate() {
306        *digit = if k == 3 {
307            (value >> 48) as i32
308        } else {
309            ((value as u64 >> (16 * k)) & 65535) as i32
310        };
311    }
312}
313fn fold_fixed(digits: &[f32]) -> Result<i128> {
314    let mut value = 0i128;
315    for (k, &digit) in digits.iter().enumerate() {
316        if !digit.is_finite() || digit.fract() != 0.0 || digit.abs() > (TILE * 65535) as f32 {
317            return Err(error("invalid Neuron fixed-point limb reduction"));
318        }
319        value += (digit as i128) << (16 * k);
320    }
321    Ok(value)
322}
323
324impl Backend for NeuronBackend {
325    fn name(&self) -> String {
326        format!("{} (neuron; host objectives)", self.device.name())
327    }
328    fn set_thread_pool(&mut self, pool: Arc<ThreadPool>) {
329        self.pool = Some(pool);
330    }
331
332    fn load(&mut self, train: &BinnedDataset, valid: &[&BinnedDataset], objective: &ObjectiveSetup) -> Result<()> {
333        if train.label.len() != train.num_rows || train.weight.as_ref().is_some_and(|w| w.len() != train.num_rows) {
334            return Err(SilvicError::data("training label/weight length differs from row count"));
335        }
336        if let ObjectiveSetup::LambdaRank { inverse_max_dcg, .. } = objective {
337            let q = train
338                .query_boundaries
339                .as_ref()
340                .ok_or_else(|| SilvicError::data("ranking requires query boundaries"))?;
341            if q.first() != Some(&0)
342                || q.last().copied().map(u64::from) != Some(train.num_rows as u64)
343                || q.windows(2).any(|w| w[0] > w[1])
344                || inverse_max_dcg.len() + 1 != q.len()
345            {
346                return Err(SilvicError::data("invalid ranking query boundaries"));
347            }
348        }
349        for (f, meta) in train.features.iter().enumerate().filter(|(_, m)| m.num_bins != 0) {
350            let group = train
351                .groups
352                .get(meta.group as usize)
353                .ok_or_else(|| SilvicError::data("feature references missing group"))?;
354            if meta.column >= group.row_stride
355                || !group.features.contains(&f)
356                || meta.num_bins > 256
357                || meta
358                    .hist_offset
359                    .checked_add(meta.num_bins)
360                    .is_none_or(|e| e as usize > train.total_bins)
361            {
362                return Err(SilvicError::data("invalid feature histogram layout"));
363            }
364            for row in 0..train.num_rows {
365                if group
366                    .data
367                    .get(row * group.row_stride as usize + meta.column as usize)
368                    .is_none_or(|&bin| u32::from(bin) >= meta.num_bins)
369                {
370                    return Err(SilvicError::data("feature bin outside histogram layout"));
371                }
372            }
373        }
374        let mut datasets = Vec::with_capacity(valid.len() + 1);
375        for data in std::iter::once(train).chain(valid.iter().copied()) {
376            if data.features != train.features
377                || data.groups.len() != train.groups.len()
378                || data.groups.iter().zip(&train.groups).any(|(a, b)| {
379                    a.row_stride != b.row_stride
380                        || a.features != b.features
381                        || a.hist_offset != b.hist_offset
382                        || a.total_bins != b.total_bins
383                })
384            {
385                return Err(SilvicError::data("validation set has a different binned layout"));
386            }
387            datasets.push(upload_dataset(&self.device, data)?);
388        }
389        let work = work(&self.device, &datasets[0])?;
390        let n = datasets[0].capacity;
391        let host_train = BinnedDataset {
392            num_rows: train.num_rows,
393            row_offset: train.row_offset,
394            features: train.features.clone(),
395            groups: train
396                .groups
397                .iter()
398                .map(|group| silvic_core::data::FeatureGroup {
399                    features: group.features.clone(),
400                    row_stride: group.row_stride,
401                    hist_offset: group.hist_offset,
402                    total_bins: group.total_bins,
403                    data: Vec::new(),
404                })
405                .collect(),
406            total_bins: train.total_bins,
407            label: train.label.clone(),
408            weight: train.weight.clone(),
409            query_boundaries: train.query_boundaries.clone(),
410        };
411        self.loaded = Some(Loaded {
412            datasets,
413            train: host_train,
414            objective: objective.clone(),
415            grad: vec![0.0; train.num_rows],
416            hess: vec![0.0; train.num_rows],
417            host_values: vec![0.0; 2 * n],
418            quant_values: vec![0.0; 2 * n],
419            host_limbs: vec![0; LIMBS * n],
420            host_scores: vec![0.0; train.num_rows],
421            bag: None,
422            partition_len: 0,
423            quantized: false,
424            quant_ready: false,
425            work,
426            tree: None,
427        });
428        Ok(())
429    }
430
431    fn init_scores(&mut self, dataset: usize, value: f64) -> Result<()> {
432        let data = self
433            .state()?
434            .datasets
435            .get_mut(dataset)
436            .ok_or_else(|| error("missing score dataset"))?;
437        let values = vec![value as f32; data.capacity];
438        data.scores.write(&values)
439    }
440
441    fn compute_gradients(&mut self) -> Result<GradientStats> {
442        let pool = self.pool.clone();
443        let state = self.state()?;
444        state.datasets[0].scores.read(&mut state.host_scores)?;
445        install(&pool, || {
446            objective::gradients(
447                &state.objective,
448                &state.host_scores,
449                &state.train.label,
450                state.train.weight.as_deref(),
451                state.train.query_boundaries.as_deref(),
452                &mut state.grad,
453                &mut state.hess,
454            )
455        });
456        let n = state.datasets[0].capacity;
457        state.host_values[..state.grad.len()].copy_from_slice(&state.grad);
458        state.host_values[n..n + state.hess.len()].copy_from_slice(&state.hess);
459        state.work.values.write(&state.host_values)?;
460        state.quantized = false;
461        state.quant_ready = false;
462        Ok(install(&pool, || GradientStats {
463            max_abs_grad: state
464                .grad
465                .par_iter()
466                .map(|v| f64::from(v.abs()))
467                .reduce(|| 0.0, f64::max),
468            max_hess: state
469                .hess
470                .par_iter()
471                .map(|v| f64::from(v.abs()))
472                .reduce(|| 0.0, f64::max),
473        }))
474    }
475
476    fn quantize(&mut self, spec: &QuantSpec) -> Result<()> {
477        if spec.grad_max_q < 0 || spec.grad_max_q > 32767 || spec.hess_max_q < 0 || spec.hess_max_q > 32767 {
478            return Err(error(
479                "quantized gradients/hessians must fit signed 16-bit histogram units",
480            ));
481        }
482        let pool = self.pool.clone();
483        let state = self.state()?;
484        let n = state.datasets[0].capacity;
485        let (g, h) = state.quant_values.split_at_mut(n);
486        install(&pool, || {
487            g[..state.grad.len()]
488                .par_iter_mut()
489                .zip(h[..state.hess.len()].par_iter_mut())
490                .enumerate()
491                .for_each(|(row, (g, h))| {
492                    let (qg, qh) = quantize::unpack(quantize::quantize_row(
493                        spec,
494                        state.grad[row],
495                        state.hess[row],
496                        state.train.row_offset + row as u64,
497                    ));
498                    *g = qg as f32;
499                    *h = qh as f32;
500                })
501        });
502        state.work.values.write(&state.quant_values)?;
503        state.quantized = true;
504        state.quant_ready = true;
505        Ok(())
506    }
507
508    fn reset_partition(&mut self, bag: &BagSpec) -> Result<u32> {
509        let pool = self.pool.clone();
510        let state = self.state()?;
511        let rows = state.train.num_rows as u32;
512        if state.bag.as_ref().is_none_or(|(cached, _)| cached != bag) {
513            let selected = install(&pool, || {
514                (0..rows)
515                    .into_par_iter()
516                    .filter(|&row| match *bag {
517                        BagSpec::All => true,
518                        BagSpec::Bernoulli { seed, round, fraction } => {
519                            quantize::in_bag(seed, round, state.train.row_offset + u64::from(row), fraction)
520                        }
521                    })
522                    .collect::<Vec<_>>()
523            });
524            state.bag = Some((*bag, selected));
525        }
526        let selected = &state.bag.as_ref().expect("bag initialized").1;
527        state.work.partition.write(selected)?;
528        state.partition_len = selected.len();
529        Ok(selected.len() as u32)
530    }
531
532    fn histogram(&mut self, leaf: LeafRange, quantized: bool, out: &mut [f64]) -> Result<()> {
533        let state = self.state()?;
534        check_range(state, leaf)?;
535        if out.len()
536            != state
537                .train
538                .total_bins
539                .checked_mul(2)
540                .ok_or_else(|| error("histogram size overflow"))?
541        {
542            return Err(error("histogram output has wrong length"));
543        }
544        if quantized && !state.quant_ready {
545            return Err(error("quantized histogram requested before quantize"));
546        }
547        if quantized != state.quantized {
548            state.work.values.write(if quantized {
549                &state.quant_values
550            } else {
551                &state.host_values
552            })?;
553            state.quantized = quantized;
554        }
555        out.fill(0.0);
556        let n = state.datasets[0].capacity;
557        for feature in state.train.features.iter().filter(|f| f.num_bins != 0) {
558            let column = state.datasets[0].group_columns[feature.group as usize] + feature.column as usize;
559            for bin in (0..feature.num_bins as usize).step_by(HIST_BINS) {
560                for start in (0..leaf.count as usize).step_by(CHUNKS * TILE) {
561                    let count = (leaf.count as usize - start).min(CHUNKS * TILE);
562                    state.work.meta.write(&[
563                        (leaf.begin as usize + start) as u32,
564                        count as u32,
565                        (column * n) as u32,
566                        bin as u32,
567                        0,
568                        0,
569                        0,
570                        0,
571                    ])?;
572                    state.work.histogram.execute()?;
573                    state.work.partials.read(&mut state.work.host_partials)?;
574                    for chunk in 0..CHUNKS {
575                        for lane in 0..HIST_BINS.min(feature.num_bins as usize - bin) {
576                            for component in 0..2 {
577                                let value = state.work.host_partials[(chunk * 2 + component) * HIST_BINS + lane];
578                                if quantized
579                                    && (!value.is_finite()
580                                        || value.fract() != 0.0
581                                        || value.abs() > (TILE * 32767) as f32)
582                                {
583                                    return Err(error("invalid exact Neuron quantized histogram partial"));
584                                }
585                                // There are <=u32::MAX rows and |unit|<=32767: every
586                                // integer prefix sum is <2^47, hence the f64 fold is exact.
587                                out[2 * (feature.hist_offset as usize + bin + lane) + component] += f64::from(value);
588                            }
589                        }
590                    }
591                }
592            }
593        }
594        Ok(())
595    }
596
597    fn partition(&mut self, leaf: LeafRange, split: &BinSplit) -> Result<u32> {
598        let state = self.state()?;
599        check_range(state, leaf)?;
600        let group = state
601            .train
602            .groups
603            .get(split.group as usize)
604            .ok_or_else(|| error("split references missing group"))?;
605        if split.column >= group.row_stride {
606            return Err(error("split column outside group"));
607        }
608        if leaf.count == 0 {
609            return Ok(0);
610        }
611        let n = state.datasets[0].capacity;
612        let column = state.datasets[0].group_columns[split.group as usize] + split.column as usize;
613        state.work.meta.write(&[
614            leaf.begin,
615            leaf.count,
616            (column * n) as u32,
617            state.partition_len as u32,
618            0,
619            0,
620            0,
621            0,
622        ])?;
623        state.work.split.write(&split.left_bins)?;
624        state.work.partition_flags.execute()?;
625        state.work.counts.read(&mut state.work.host_counts)?;
626        let left: u32 = state.work.host_counts.iter().try_fold(0u32, |s, &c| {
627            s.checked_add(c).ok_or_else(|| error("partition count overflow"))
628        })?;
629        if left > leaf.count {
630            return Err(error("invalid Neuron partition count"));
631        }
632        let blocks = n / TILE;
633        let mut l = leaf.begin;
634        let mut r = leaf.begin + left;
635        for block in 0..blocks {
636            state.work.host_offsets[block] = l;
637            state.work.host_offsets[blocks + block] = r;
638            let begin = (block * TILE).max(leaf.begin as usize);
639            let end = ((block + 1) * TILE).min(leaf.begin as usize + leaf.count as usize);
640            let count = end.saturating_sub(begin) as u32;
641            let lc = state.work.host_counts[block];
642            if lc > count {
643                return Err(error("invalid Neuron tile partition count"));
644            }
645            l += lc;
646            r += count - lc;
647        }
648        state.work.offsets.write(&state.work.host_offsets)?;
649        state.work.partition_scatter.execute()?;
650        state.work.copy_partition.execute()?;
651        Ok(left)
652    }
653
654    fn leaf_sums(&mut self, leaves: &[LeafRange], grad_scale: f32, hess_scale: f32) -> Result<Vec<[i64; 2]>> {
655        let pool = self.pool.clone();
656        let state = self.state()?;
657        for &leaf in leaves {
658            check_range(state, leaf)?;
659        }
660        let n = state.datasets[0].capacity;
661        install(&pool, || {
662            state
663                .host_limbs
664                .par_chunks_mut(4 * n)
665                .enumerate()
666                .for_each(|(component, out)| {
667                    let (values, scale) = if component == 0 {
668                        (&state.grad, grad_scale)
669                    } else {
670                        (&state.hess, hess_scale)
671                    };
672                    let (lo, rest) = out.split_at_mut(n);
673                    let (mid, rest) = rest.split_at_mut(n);
674                    let (hi, sign) = rest.split_at_mut(n);
675                    lo.par_iter_mut()
676                        .zip(mid.par_iter_mut())
677                        .zip(hi.par_iter_mut())
678                        .zip(sign.par_iter_mut())
679                        .zip(values.par_iter())
680                        .for_each(|((((lo, mid), hi), sign), &value)| {
681                            let mut digits = [0; 4];
682                            encode_fixed(quantize::to_fixed(value, scale), &mut digits);
683                            [*lo, *mid, *hi, *sign] = digits;
684                        });
685                })
686        });
687        state.work.limbs.write(&state.host_limbs)?;
688        let mut result = Vec::with_capacity(leaves.len());
689        for leaf in leaves {
690            let mut sums = [0i128; 2];
691            for start in (0..leaf.count as usize).step_by(CHUNKS * TILE) {
692                state.work.meta.write(&[
693                    (leaf.begin as usize + start) as u32,
694                    (leaf.count as usize - start).min(CHUNKS * TILE) as u32,
695                    0,
696                    0,
697                    0,
698                    0,
699                    0,
700                    0,
701                ])?;
702                state.work.leaf_sum.execute()?;
703                state.work.leaf_partials.read(&mut state.work.host_leaf_partials)?;
704                for chunk in 0..CHUNKS {
705                    for (component, sum) in sums.iter_mut().enumerate() {
706                        let offset = chunk * LIMBS + component * 4;
707                        *sum += fold_fixed(&state.work.host_leaf_partials[offset..offset + 4])?;
708                    }
709                }
710            }
711            result.push([
712                i64::try_from(sums[0]).map_err(|_| error("fixed-point gradient sum exceeds i64"))?,
713                i64::try_from(sums[1]).map_err(|_| error("fixed-point hessian sum exceeds i64"))?,
714            ]);
715        }
716        Ok(result)
717    }
718
719    fn add_tree(&mut self, tree: &DeviceTree) -> Result<()> {
720        let device = self.device.clone();
721        let state = self.state()?;
722        if tree.leaf_values.is_empty() || tree.nodes.len().checked_add(1) != Some(tree.leaf_values.len()) {
723            return Err(error("malformed device tree"));
724        }
725        let count = tree
726            .nodes
727            .len()
728            .checked_add(tree.leaf_values.len())
729            .ok_or_else(|| error("Neuron tree size overflow"))?;
730        let capacity = count
731            .checked_next_power_of_two()
732            .ok_or_else(|| error("Neuron tree capacity overflow"))?;
733        let words = capacity
734            .checked_mul(NODE_FIELDS)
735            .filter(|&words| words <= i32::MAX as usize)
736            .ok_or_else(|| error("Neuron tree exceeds signed 32-bit gather addressing"))?;
737        let mut nodes = vec![0u32; words];
738        let mut values = vec![0f32; capacity];
739        let internal = tree.nodes.len();
740        let mut depths = vec![0usize; internal];
741        let mut depth = 0usize;
742        for (i, node) in tree.nodes.iter().enumerate() {
743            let group = state
744                .train
745                .groups
746                .get(node.split.group as usize)
747                .ok_or_else(|| error("tree references missing group"))?;
748            if node.split.column >= group.row_stride {
749                return Err(error("tree split column outside group"));
750            }
751            nodes[i] = (state.datasets[0].group_columns[node.split.group as usize] + node.split.column as usize) as u32;
752            for (field, child) in [(1, node.left), (2, node.right)] {
753                let child = if child >= 0 {
754                    let child = child as usize;
755                    if child <= i || child >= internal {
756                        return Err(error("tree children must follow their parent"));
757                    }
758                    depths[child] = depths[i] + 1;
759                    child
760                } else {
761                    let leaf = (!child) as usize;
762                    if leaf >= tree.leaf_values.len() {
763                        return Err(error("invalid tree leaf index"));
764                    }
765                    depth = depth.max(depths[i] + 1);
766                    internal + leaf
767                };
768                nodes[field * capacity + i] = child as u32;
769            }
770            for word in 0..8 {
771                nodes[(3 + word) * capacity + i] = node.split.left_bins[word];
772            }
773        }
774        for (leaf, &value) in tree.leaf_values.iter().enumerate() {
775            let i = internal + leaf;
776            nodes[capacity + i] = i as u32;
777            nodes[2 * capacity + i] = i as u32;
778            values[i] = value;
779        }
780        if state.tree.as_ref().is_none_or(|work| work.capacity != capacity) {
781            let node_buffer = device.upload(&nodes)?;
782            let leaf_buffer = device.upload(&values)?;
783            let meta = device.upload(&[0u32; 8])?;
784            let mut executions = Vec::with_capacity(state.datasets.len());
785            for data in &state.datasets {
786                let add = bind(
787                    &device,
788                    "add_tree",
789                    json!({"ROWS":data.capacity,"COUNT":data.rows,"TILE":TILE,"NODES":capacity}),
790                    vec![
791                        spec("bins", DType::I32, &[data.columns * data.capacity]),
792                        spec("scores", DType::F32, &[data.capacity]),
793                        spec("nodes", DType::I32, &[NODE_FIELDS, capacity]),
794                        spec("leaves", DType::F32, &[capacity]),
795                        spec("meta", DType::I32, &[8]),
796                    ],
797                    vec![spec("result", DType::F32, &[data.capacity])],
798                    &[
799                        data.bins.clone(),
800                        data.scores.clone(),
801                        node_buffer.clone(),
802                        leaf_buffer.clone(),
803                        meta.clone(),
804                    ],
805                    std::slice::from_ref(&data.next_scores),
806                )?;
807                let copy = bind(
808                    &device,
809                    "copy_f32",
810                    json!({"ROWS":data.capacity,"TILE":TILE}),
811                    vec![spec("source", DType::F32, &[data.capacity])],
812                    vec![spec("result", DType::F32, &[data.capacity])],
813                    std::slice::from_ref(&data.next_scores),
814                    std::slice::from_ref(&data.scores),
815                )?;
816                executions.push((add, copy));
817            }
818            state.tree = Some(TreeWork {
819                capacity,
820                nodes: node_buffer,
821                leaves: leaf_buffer,
822                meta,
823                executions,
824            });
825        }
826        let work = state.tree.as_mut().expect("tree workspace initialized");
827        work.nodes.write(&nodes)?;
828        work.leaves.write(&values)?;
829        work.meta.write(&[depth as u32, 0, 0, 0, 0, 0, 0, 0])?;
830        for (add, copy) in &mut work.executions {
831            add.execute()?;
832            copy.execute()?;
833        }
834        Ok(())
835    }
836
837    fn scores(&mut self, dataset: usize) -> Result<Vec<f32>> {
838        let data = self
839            .state()?
840            .datasets
841            .get(dataset)
842            .ok_or_else(|| error("missing score dataset"))?;
843        let mut scores = vec![0f32; data.rows];
844        data.scores.read(&mut scores)?;
845        Ok(scores)
846    }
847}
848
849#[cfg(test)]
850mod tests {
851    use super::*;
852
853    #[test]
854    fn signed_limbs_cover_i64_edges() {
855        for value in [i64::MIN, i64::MIN + 1, -65537, -65536, -1, 0, 1, 65535, 65536, i64::MAX] {
856            let mut digits = [0; 4];
857            encode_fixed(value, &mut digits);
858            assert_eq!(fold_fixed(&digits.map(|x| x as f32)).unwrap(), i128::from(value));
859        }
860    }
861
862    #[test]
863    fn worst_case_tile_limb_reductions_are_exact() {
864        for value in [i64::MIN, i64::MAX, -1, 1, (1i64 << 30) - 1, -(1i64 << 30)] {
865            let mut digits = [0; 4];
866            encode_fixed(value, &mut digits);
867            let sums = digits.map(|digit| {
868                let mut sum = 0f32;
869                for _ in 0..TILE {
870                    sum += digit as f32;
871                }
872                sum
873            });
874            assert_eq!(fold_fixed(&sums).unwrap(), i128::from(value) * TILE as i128);
875        }
876    }
877
878    #[test]
879    fn mixed_signed_limb_fold_matches_wide_sum() {
880        let values = [i64::MAX, i64::MIN, 65535, -65536, 123456789012345, -123456789012345];
881        let mut sums = [0f32; 4];
882        let mut exact = 0i128;
883        for value in values {
884            let mut digits = [0; 4];
885            encode_fixed(value, &mut digits);
886            for k in 0..4 {
887                sums[k] += digits[k] as f32;
888            }
889            exact += i128::from(value);
890        }
891        assert_eq!(fold_fixed(&sums).unwrap(), exact);
892    }
893
894    fn dataset(rows: usize) -> BinnedDataset {
895        use silvic_core::data::{BinMapper, FeatureGroup, FeatureMeta};
896        let numerical = BinMapper::Numerical {
897            upper_bounds: (0..254)
898                .map(|i| i as f64)
899                .chain(std::iter::once(f64::INFINITY))
900                .collect(),
901            nan_bin: true,
902            min: 0.0,
903            max: 254.0,
904        };
905        BinnedDataset {
906            num_rows: rows,
907            row_offset: (1u64 << 32) + 113,
908            features: vec![
909                FeatureMeta {
910                    name: "category".into(),
911                    bins: BinMapper::Categorical {
912                        categories: (1..256).collect(),
913                    },
914                    num_bins: 256,
915                    group: 0,
916                    column: 0,
917                    hist_offset: 0,
918                },
919                FeatureMeta {
920                    name: "numeric_nan".into(),
921                    bins: numerical,
922                    num_bins: 256,
923                    group: 0,
924                    column: 1,
925                    hist_offset: 256,
926                },
927                FeatureMeta {
928                    name: "other_group".into(),
929                    bins: BinMapper::Categorical {
930                        categories: vec![1, 2, 3, 4],
931                    },
932                    num_bins: 5,
933                    group: 1,
934                    column: 0,
935                    hist_offset: 512,
936                },
937                FeatureMeta {
938                    name: "trivial".into(),
939                    bins: BinMapper::Trivial,
940                    num_bins: 0,
941                    group: 0,
942                    column: 0,
943                    hist_offset: 517,
944                },
945            ],
946            groups: vec![
947                FeatureGroup {
948                    features: vec![0, 1],
949                    row_stride: 4,
950                    hist_offset: 0,
951                    total_bins: 512,
952                    data: (0..rows)
953                        .flat_map(|r| [(r % 256) as u8, ((r * 13 + 7) % 256) as u8, 0, 0])
954                        .collect(),
955                },
956                FeatureGroup {
957                    features: vec![2],
958                    row_stride: 4,
959                    hist_offset: 512,
960                    total_bins: 5,
961                    data: (0..rows).flat_map(|r| [(r % 5) as u8, 0, 0, 0]).collect(),
962                },
963            ],
964            total_bins: 517,
965            label: (0..rows).map(|r| (r % 3) as f32).collect(),
966            weight: Some((0..rows).map(|r| 0.25 + (r % 4) as f32 * 0.25).collect()),
967            query_boundaries: Some(
968                (0..rows as u32)
969                    .step_by(31)
970                    .chain(std::iter::once(rows as u32))
971                    .collect(),
972            ),
973        }
974    }
975
976    #[test]
977    fn device_training_matches_core_across_objectives_and_modes() {
978        if std::env::var("SILVIC_TEST_NEURON").as_deref() != Ok("1") {
979            eprintln!("skipped Neuron device training regression; set SILVIC_TEST_NEURON=1 on real hardware");
980            return;
981        }
982        let data = dataset(4099);
983        let valid = dataset(259);
984        let objectives = [
985            ObjectiveSetup::Regression,
986            ObjectiveSetup::Binary { sigmoid: 1.3 },
987            ObjectiveSetup::LambdaRank {
988                sigmoid: 1.0,
989                norm: true,
990                truncation_level: 10,
991                label_gain: vec![0.0, 1.0, 3.0],
992                inverse_max_dcg: vec![1.0; data.query_boundaries.as_ref().unwrap().len() - 1],
993            },
994        ];
995        for objective in objectives {
996            let mut data = data.clone();
997            if matches!(&objective, ObjectiveSetup::Regression) {
998                data.weight = None;
999            }
1000            let mut neuron = NeuronBackend::new(None).expect("real Neuron device");
1001            let mut cpu = silvic_core::CpuBackend::new();
1002            neuron.load(&data, &[&valid], &objective).unwrap();
1003            cpu.load(&data, &[&valid], &objective).unwrap();
1004            for backend in [&mut neuron as &mut dyn Backend, &mut cpu as &mut dyn Backend] {
1005                backend.init_scores(0, 0.375).unwrap();
1006                backend.init_scores(1, -0.125).unwrap();
1007            }
1008            let stats = neuron.compute_gradients().unwrap();
1009            let expected_stats = cpu.compute_gradients().unwrap();
1010            assert_eq!(stats.max_abs_grad, expected_stats.max_abs_grad);
1011            assert_eq!(stats.max_hess, expected_stats.max_hess);
1012            let constant_hessian = objective::constant_hessian(&objective, data.weight.as_deref());
1013            let quant = QuantSpec::new(stats.max_abs_grad, stats.max_hess, constant_hessian, 255, true, 71, 4);
1014            neuron.quantize(&quant).unwrap();
1015            cpu.quantize(&quant).unwrap();
1016            let bag = BagSpec::Bernoulli {
1017                seed: 19,
1018                round: 3,
1019                fraction: 0.73,
1020            };
1021            let count = neuron.reset_partition(&bag).unwrap();
1022            assert_eq!(count, cpu.reset_partition(&bag).unwrap());
1023            let leaf = LeafRange {
1024                begin: 117,
1025                count: count - 233,
1026            };
1027            let mut actual = vec![0.0; 2 * data.total_bins];
1028            let mut expected = actual.clone();
1029            // Alternating modes must retain the previously quantized values.
1030            for quantized in [true, false, true] {
1031                neuron.histogram(leaf, quantized, &mut actual).unwrap();
1032                cpu.histogram(leaf, quantized, &mut expected).unwrap();
1033                for (i, (&a, &e)) in actual.iter().zip(&expected).enumerate() {
1034                    if quantized {
1035                        assert_eq!(a, e, "quantized bin {i}");
1036                    } else {
1037                        assert!((a - e).abs() <= 2e-5 * (1.0 + e.abs()), "float bin {i}: {a} vs {e}");
1038                    }
1039                }
1040            }
1041            let mut left_bins = [0u32; 8];
1042            left_bins[0] = 1;
1043            left_bins[7] = 0x8000_0001;
1044            let split = BinSplit {
1045                group: 0,
1046                column: 1,
1047                left_bins,
1048            };
1049            let left = neuron.partition(leaf, &split).unwrap();
1050            assert_eq!(left, cpu.partition(leaf, &split).unwrap());
1051            let selected: Vec<u32> = (0..data.num_rows as u32)
1052                .filter(|&r| quantize::in_bag(19, 3, data.row_offset + u64::from(r), 0.73))
1053                .collect();
1054            let range = leaf.begin as usize..(leaf.begin + leaf.count) as usize;
1055            let mut stable = selected.clone();
1056            let middle = &selected[range.clone()];
1057            let reordered: Vec<u32> = middle
1058                .iter()
1059                .copied()
1060                .filter(|&r| split.goes_left(data.bin(1, r as usize)))
1061                .chain(
1062                    middle
1063                        .iter()
1064                        .copied()
1065                        .filter(|&r| !split.goes_left(data.bin(1, r as usize))),
1066                )
1067                .collect();
1068            stable[range].copy_from_slice(&reordered);
1069            let mut actual_rows = vec![0u32; count as usize];
1070            neuron
1071                .loaded
1072                .as_ref()
1073                .unwrap()
1074                .work
1075                .partition
1076                .read(&mut actual_rows)
1077                .unwrap();
1078            assert_eq!(
1079                actual_rows, stable,
1080                "partition must be stable and preserve rows outside the leaf"
1081            );
1082            let leaves = [
1083                LeafRange {
1084                    begin: leaf.begin,
1085                    count: left,
1086                },
1087                LeafRange {
1088                    begin: leaf.begin + left,
1089                    count: leaf.count - left,
1090                },
1091                LeafRange { begin: count, count: 0 },
1092            ];
1093            let gs = quantize::fixed_point_scale(stats.max_abs_grad);
1094            let hs = quantize::fixed_point_scale(stats.max_hess);
1095            assert_eq!(
1096                neuron.leaf_sums(&leaves, gs, hs).unwrap(),
1097                cpu.leaf_sums(&leaves, gs, hs).unwrap()
1098            );
1099            let tree = DeviceTree {
1100                nodes: vec![silvic_core::backend::DeviceNode {
1101                    split,
1102                    left: !0,
1103                    right: !1,
1104                }],
1105                leaf_values: vec![0.25, -0.125],
1106            };
1107            for backend in [&mut neuron as &mut dyn Backend, &mut cpu as &mut dyn Backend] {
1108                backend.add_tree(&tree).unwrap();
1109            }
1110            for dataset in 0..2 {
1111                assert_eq!(neuron.scores(dataset).unwrap(), cpu.scores(dataset).unwrap());
1112            }
1113            let constant = DeviceTree {
1114                nodes: vec![],
1115                leaf_values: vec![0.0625],
1116            };
1117            for backend in [&mut neuron as &mut dyn Backend, &mut cpu as &mut dyn Backend] {
1118                backend.add_tree(&constant).unwrap();
1119            }
1120            for dataset in 0..2 {
1121                assert_eq!(neuron.scores(dataset).unwrap(), cpu.scores(dataset).unwrap());
1122            }
1123            assert!(neuron
1124                .histogram(LeafRange { begin: count, count: 1 }, true, &mut actual)
1125                .is_err());
1126            assert!(neuron
1127                .partition(
1128                    leaf,
1129                    &BinSplit {
1130                        group: 99,
1131                        column: 0,
1132                        left_bins: [0; 8]
1133                    }
1134                )
1135                .is_err());
1136        }
1137    }
1138}