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
106pub 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
301fn 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 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 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}