1use std::collections::HashSet;
10
11use rayon::prelude::*;
12
13use crate::collective::Collective;
14use crate::config::Params;
15use crate::data::binning::bin_rows;
16use crate::data::sample::gather_sample;
17use crate::data::{BinMapper, BinnedDataset, FeatureGroup, FeatureMeta, GROUP_BIN_BUDGET};
18use crate::error::{Result, SilvicError};
19
20const JSON_SPECIAL: &[char] = &['"', ',', ':', '[', ']', '{', '}'];
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Layout {
25 RowMajor,
26 ColumnMajor,
27}
28
29#[derive(Debug, Clone, Copy)]
30pub enum DenseData<'a> {
31 F32(&'a [f32]),
32 F64(&'a [f64]),
33}
34
35#[derive(Debug, Clone, Copy)]
37pub struct DenseMatrix<'a> {
38 pub data: DenseData<'a>,
39 pub rows: usize,
40 pub cols: usize,
41 pub layout: Layout,
42}
43
44impl DenseMatrix<'_> {
45 #[inline]
46 pub fn get(&self, row: usize, col: usize) -> f64 {
47 let index = match self.layout {
48 Layout::RowMajor => row * self.cols + col,
49 Layout::ColumnMajor => col * self.rows + row,
50 };
51 match self.data {
52 DenseData::F32(values) => f64::from(values[index]),
53 DenseData::F64(values) => values[index],
54 }
55 }
56
57 fn len(&self) -> usize {
58 match self.data {
59 DenseData::F32(values) => values.len(),
60 DenseData::F64(values) => values.len(),
61 }
62 }
63}
64
65#[derive(Debug, Clone)]
67pub struct DatasetInput<'a> {
68 pub features: DenseMatrix<'a>,
69 pub label: &'a [f32],
70 pub weight: Option<&'a [f32]>,
71 pub group: Option<&'a [u32]>,
73 pub feature_names: Option<Vec<String>>,
75 pub categorical_features: Vec<usize>,
76}
77
78impl BinnedDataset {
79 pub fn construct(input: &DatasetInput<'_>, params: &Params, collective: &mut dyn Collective) -> Result<Self> {
82 let checked = check_max_bin(params)
83 .and_then(|()| validate(input))
84 .and_then(|()| feature_names(input));
85 let row_counts = gather_row_counts(input.features.rows, checked.as_ref().err(), collective)?;
86 let names = checked?;
87 let row_offset = row_counts[..collective.rank()].iter().sum();
88 let sample = gather_sample(
89 &input.features,
90 &row_counts,
91 params.bin_construct_sample_cnt,
92 params.data_random_seed,
93 collective,
94 )?;
95 let categorical: HashSet<usize> = input.categorical_features.iter().copied().collect();
96 let mappers: Vec<BinMapper> = sample
97 .values
98 .par_iter()
99 .enumerate()
100 .map(|(feature, values)| {
101 if categorical.contains(&feature) {
102 BinMapper::categorical(values, sample.rows, params)
103 } else {
104 BinMapper::numerical(values, sample.rows, params)
105 }
106 })
107 .collect();
108 drop(sample);
109
110 let (features, mut groups, total_bins) = layout(names, mappers);
111 bin_rows(&input.features, &features, &mut groups);
112 tracing::debug!(
113 rows = input.features.rows,
114 groups = groups.len(),
115 total_bins,
116 "binned training data"
117 );
118 Ok(BinnedDataset {
119 num_rows: input.features.rows,
120 row_offset,
121 features,
122 groups,
123 total_bins,
124 label: input.label.to_vec(),
125 weight: input.weight.map(<[f32]>::to_vec),
126 query_boundaries: query_boundaries(input.group),
127 })
128 }
129
130 pub fn construct_with_reference(
133 input: &DatasetInput<'_>,
134 reference: &BinnedDataset,
135 collective: &mut dyn Collective,
136 ) -> Result<Self> {
137 let checked = validate(input).and_then(|()| {
138 if input.features.cols == reference.features.len() {
139 return Ok(());
140 }
141 Err(SilvicError::data(format!(
142 "{} features, the reference dataset has {}",
143 input.features.cols,
144 reference.features.len()
145 )))
146 });
147 let row_counts = gather_row_counts(input.features.rows, checked.as_ref().err(), collective)?;
148 checked?;
149 let row_offset = row_counts[..collective.rank()].iter().sum();
150 let mut groups: Vec<FeatureGroup> = reference
151 .groups
152 .iter()
153 .map(|group| FeatureGroup {
154 features: group.features.clone(),
155 row_stride: group.row_stride,
156 hist_offset: group.hist_offset,
157 total_bins: group.total_bins,
158 data: Vec::new(),
159 })
160 .collect();
161 bin_rows(&input.features, &reference.features, &mut groups);
162 Ok(BinnedDataset {
163 num_rows: input.features.rows,
164 row_offset,
165 features: reference.features.clone(),
166 groups,
167 total_bins: reference.total_bins,
168 label: input.label.to_vec(),
169 weight: input.weight.map(<[f32]>::to_vec),
170 query_boundaries: query_boundaries(input.group),
171 })
172 }
173}
174
175fn validate(input: &DatasetInput<'_>) -> Result<()> {
176 let matrix = &input.features;
177 if matrix.rows.checked_mul(matrix.cols) != Some(matrix.len()) {
178 return Err(SilvicError::data(format!(
179 "feature matrix holds {} values, not {} rows x {} columns",
180 matrix.len(),
181 matrix.rows,
182 matrix.cols
183 )));
184 }
185 validate_label(input.label, matrix.rows)?;
186 if let Some(weight) = input.weight {
187 validate_weight(weight, matrix.rows)?;
188 }
189 if let Some(group) = input.group {
190 validate_group(group, matrix.rows)?;
191 }
192 if let Some(&index) = input.categorical_features.iter().find(|&&index| index >= matrix.cols) {
193 return Err(SilvicError::data(format!(
194 "categorical feature {index} is out of range for {} features",
195 matrix.cols
196 )));
197 }
198 Ok(())
199}
200
201fn validate_label(label: &[f32], rows: usize) -> Result<()> {
202 if label.len() != rows {
203 return Err(SilvicError::data(format!("{} labels for {rows} rows", label.len())));
204 }
205 if let Some((row, value)) = label.iter().enumerate().find(|(_, v)| !v.is_finite()) {
206 return Err(SilvicError::data(format!(
207 "label of row {row} is {value}; labels must be finite"
208 )));
209 }
210 Ok(())
211}
212
213fn validate_weight(weight: &[f32], rows: usize) -> Result<()> {
214 if weight.len() != rows {
215 return Err(SilvicError::data(format!("{} weights for {rows} rows", weight.len())));
216 }
217 if let Some((row, value)) = weight.iter().enumerate().find(|(_, v)| !(v.is_finite() && **v >= 0.0)) {
218 return Err(SilvicError::data(format!(
219 "weight of row {row} is {value}; weights must be finite and non-negative"
220 )));
221 }
222 Ok(())
223}
224
225fn validate_group(group: &[u32], rows: usize) -> Result<()> {
226 let total: u64 = group.iter().map(|&size| u64::from(size)).sum();
227 if total != rows as u64 {
228 return Err(SilvicError::data(format!(
229 "query sizes sum to {total}, not the {rows} rows"
230 )));
231 }
232 if total > u64::from(u32::MAX) {
233 return Err(SilvicError::data(format!(
234 "{rows} rows with queries; query boundaries are 32-bit"
235 )));
236 }
237 Ok(())
238}
239
240fn feature_names(input: &DatasetInput<'_>) -> Result<Vec<String>> {
243 let cols = input.features.cols;
244 let Some(names) = &input.feature_names else {
245 return Ok((0..cols).map(|i| format!("Column_{i}")).collect());
246 };
247 if names.len() != cols {
248 return Err(SilvicError::data(format!(
249 "{} feature names for {cols} features",
250 names.len()
251 )));
252 }
253 let mut seen = HashSet::with_capacity(cols);
254 let mut out = Vec::with_capacity(cols);
255 for name in names {
256 if name.contains(JSON_SPECIAL) {
257 return Err(SilvicError::data(format!(
258 "feature name {name:?} contains a JSON special character"
259 )));
260 }
261 let name = name.replace([' ', '='], "_");
262 if !seen.insert(name.clone()) {
263 return Err(SilvicError::data(format!(
264 "feature name {name:?} appears more than once"
265 )));
266 }
267 out.push(name);
268 }
269 Ok(out)
270}
271
272fn check_max_bin(params: &Params) -> Result<()> {
273 if !(2..=255).contains(¶ms.max_bin) {
274 return Err(SilvicError::config(format!(
275 "max_bin must be in 2..=255, got {}",
276 params.max_bin
277 )));
278 }
279 Ok(())
280}
281
282fn gather_row_counts(
286 rows: usize,
287 local_error: Option<&SilvicError>,
288 collective: &mut dyn Collective,
289) -> Result<Vec<u64>> {
290 let mut status = (rows as u64).to_le_bytes().to_vec();
291 if let Some(error) = local_error {
292 let reason = match error {
293 SilvicError::Data(message) | SilvicError::Config(message) => message.to_string(),
294 other => other.to_string(),
295 };
296 status.extend(
298 if reason.is_empty() {
299 "invalid input".to_string()
300 } else {
301 reason
302 }
303 .into_bytes(),
304 );
305 }
306 let gathered = collective.allgather_bytes(&status)?;
307 let mut counts = Vec::with_capacity(gathered.len());
308 for (rank, bytes) in gathered.iter().enumerate() {
309 let malformed = || SilvicError::collective(format!("malformed row count from rank {rank}"));
310 let (count, reason) = bytes.split_first_chunk::<8>().ok_or_else(malformed)?;
311 if !reason.is_empty() {
312 return Err(SilvicError::data(format!(
313 "rank {rank}: {}",
314 String::from_utf8_lossy(reason)
315 )));
316 }
317 counts.push(u64::from_le_bytes(*count));
318 }
319 Ok(counts)
320}
321
322fn query_boundaries(group: Option<&[u32]>) -> Option<Vec<u32>> {
323 let group = group?;
324 let mut boundaries = Vec::with_capacity(group.len() + 1);
325 let mut end = 0u32;
326 boundaries.push(end);
327 for &size in group {
328 end += size;
329 boundaries.push(end);
330 }
331 Some(boundaries)
332}
333
334fn layout(names: Vec<String>, mappers: Vec<BinMapper>) -> (Vec<FeatureMeta>, Vec<FeatureGroup>, usize) {
337 let mut features = Vec::with_capacity(mappers.len());
338 let mut groups: Vec<FeatureGroup> = Vec::new();
339 let mut offset = 0u32;
340 for (index, (name, bins)) in names.into_iter().zip(mappers).enumerate() {
341 let num_bins = bins.num_bins();
342 if num_bins == 0 {
343 features.push(FeatureMeta {
344 name,
345 bins,
346 num_bins,
347 group: 0,
348 column: 0,
349 hist_offset: offset,
350 });
351 continue;
352 }
353 if groups
354 .last()
355 .is_none_or(|group| group.total_bins + num_bins > GROUP_BIN_BUDGET)
356 {
357 groups.push(FeatureGroup {
358 features: Vec::new(),
359 row_stride: 0,
360 hist_offset: offset,
361 total_bins: 0,
362 data: Vec::new(),
363 });
364 }
365 let group_index = groups.len() as u32 - 1;
366 let group = groups.last_mut().expect("a group");
367 features.push(FeatureMeta {
368 name,
369 bins,
370 num_bins,
371 group: group_index,
372 column: group.features.len() as u32,
373 hist_offset: offset,
374 });
375 group.features.push(index);
376 group.total_bins += num_bins;
377 offset += num_bins;
378 }
379 for group in &mut groups {
380 group.row_stride = group.features.len().next_multiple_of(4) as u32;
381 }
382 (features, groups, offset as usize)
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use crate::collective::LocalCollective;
389 use crate::quantize::mix32;
390 use crate::test_util::run_ranks;
391
392 fn matrix_values(rows: usize, cols: usize, row_offset: usize) -> Vec<f64> {
394 let mut out = Vec::with_capacity(rows * cols);
395 for row in row_offset..row_offset + rows {
396 for col in 0..cols {
397 let unit = f64::from(mix32(mix32(row as u32) ^ mix32(col as u32 + 1)) >> 8) / 16_777_216.0;
398 out.push(match col % 4 {
399 0 => (unit - 0.3) * 10.0,
400 1 if unit < 0.1 => f64::NAN,
401 1 => unit * unit,
402 2 => (unit * 7.0).floor(),
403 _ => (unit * unit * 9.0).floor() - 1.0,
404 });
405 }
406 }
407 out
408 }
409
410 fn input<'a>(values: &'a [f64], label: &'a [f32], cols: usize) -> DatasetInput<'a> {
411 DatasetInput {
412 features: DenseMatrix {
413 data: DenseData::F64(values),
414 rows: label.len(),
415 cols,
416 layout: Layout::RowMajor,
417 },
418 label,
419 weight: None,
420 group: None,
421 feature_names: None,
422 categorical_features: (3..cols).step_by(4).collect(),
423 }
424 }
425
426 fn check_bins(dataset: &BinnedDataset, matrix: &DenseMatrix<'_>, case: &str) {
427 for (feature, meta) in dataset.features.iter().enumerate() {
428 if meta.is_trivial() {
429 continue;
430 }
431 for row in 0..matrix.rows {
432 let expected = meta.bins.value_to_bin(matrix.get(row, feature));
433 assert_eq!(
434 dataset.bin(feature, row),
435 expected,
436 "{case}: feature {feature} row {row}"
437 );
438 }
439 }
440 }
441
442 #[test]
443 fn construct_bins_every_row() {
444 let (rows, cols) = (10_000, 9);
445 let values = matrix_values(rows, cols, 0);
446 let label = vec![0.5f32; rows];
447 let group = [4000u32, 0, 6000];
448 let mut data = input(&values, &label, cols);
449 data.group = Some(&group);
450 data.feature_names = Some((0..cols).map(|i| format!("f {i}=x")).collect());
451 let dataset = BinnedDataset::construct(&data, &Params::default(), &mut LocalCollective).expect("construct");
452 assert_eq!(dataset.row_offset, 0);
453 assert_eq!(dataset.query_boundaries, Some(vec![0, 4000, 4000, 10_000]));
454 assert_eq!(dataset.features[0].name, "f_0_x");
455 assert!(dataset.features[3].is_categorical());
456 assert!(dataset.features[1].bins.value_to_bin(f64::NAN) as u32 == dataset.features[1].num_bins - 1);
457 check_bins(&dataset, &data.features, "row-major f64");
458
459 let column_major: Vec<f32> = (0..cols)
460 .flat_map(|c| (0..rows).map(move |r| (r, c)))
461 .map(|(r, c)| values[r * cols + c] as f32)
462 .collect();
463 let mut data32 = data.clone();
464 data32.features = DenseMatrix {
465 data: DenseData::F32(&column_major),
466 rows,
467 cols,
468 layout: Layout::ColumnMajor,
469 };
470 let dataset32 = BinnedDataset::construct(&data32, &Params::default(), &mut LocalCollective).expect("f32");
471 check_bins(&dataset32, &data32.features, "column-major f32");
472 }
473
474 #[test]
475 fn grouping_respects_budget_and_stride() {
476 let wide = BinMapper::Numerical {
477 upper_bounds: (0..254).map(f64::from).chain([f64::INFINITY]).collect(),
478 nan_bin: true,
479 min: 0.0,
480 max: 1.0,
481 };
482 let narrow = BinMapper::Categorical { categories: vec![1, 2] };
483 let mappers: Vec<BinMapper> = (0..70)
484 .map(|i| match i % 7 {
485 0 => BinMapper::Trivial,
486 1 => narrow.clone(),
487 _ => wide.clone(),
488 })
489 .collect();
490 let names = (0..70).map(|i| format!("Column_{i}")).collect();
491 let (features, groups, total_bins) = layout(names, mappers);
492 assert!(groups.len() > 1);
493 let mut offset = 0;
494 let mut grouped: Vec<usize> = Vec::new();
495 for (index, group) in groups.iter().enumerate() {
496 assert!(group.total_bins <= GROUP_BIN_BUDGET, "group {index}: budget");
497 assert_eq!(group.row_stride % 4, 0, "group {index}: stride");
498 assert!(
499 group.row_stride as usize >= group.features.len()
500 && group.row_stride as usize - group.features.len() < 4
501 );
502 assert_eq!(group.hist_offset, offset, "group {index}: offset");
503 for (column, &feature) in group.features.iter().enumerate() {
504 let meta = &features[feature];
505 assert_eq!(
506 (meta.group, meta.column, meta.hist_offset),
507 (index as u32, column as u32, offset)
508 );
509 offset += meta.num_bins;
510 }
511 assert_eq!(offset - group.hist_offset, group.total_bins);
512 if let Some(next) = groups.get(index + 1) {
513 let next_bins = features[next.features[0]].num_bins;
514 assert!(group.total_bins + next_bins > GROUP_BIN_BUDGET, "group {index}: greedy");
515 }
516 grouped.extend(&group.features);
517 }
518 assert_eq!(offset as usize, total_bins);
519 let expected: Vec<usize> = (0..70).filter(|i| i % 7 != 0).collect();
520 assert_eq!(grouped, expected, "non-trivial features in input order");
521 }
522
523 #[test]
524 fn two_ranks_share_mappers() {
525 let cols = 9;
526 for (case, rows, sample_cnt) in [
527 ("all rows sampled", [3000, 5000], 200_000),
528 ("sampled", [3000, 5000], 2000),
529 ] {
530 let total = rows[0] + rows[1];
531 let values = matrix_values(total, cols, 0);
532 let label = vec![1.0f32; total];
533 let params = Params {
534 bin_construct_sample_cnt: sample_cnt,
535 ..Params::default()
536 };
537 let built = run_ranks(2, |rank, collective| {
538 let start = if rank == 0 { 0 } else { rows[0] };
539 let end = start + rows[rank];
540 let local = input(&values[start * cols..end * cols], &label[start..end], cols);
541 let dataset = BinnedDataset::construct(&local, ¶ms, collective).expect("construct");
542 check_bins(&dataset, &local.features, case);
543 dataset
544 });
545 assert_eq!(built[0].row_offset, 0, "{case}");
546 assert_eq!(built[1].row_offset, rows[0] as u64, "{case}");
547 assert_eq!(built[0].features, built[1].features, "{case}: mappers");
548 if sample_cnt >= total {
549 let whole = input(&values, &label, cols);
550 let single = BinnedDataset::construct(&whole, ¶ms, &mut LocalCollective).expect("one rank");
551 assert_eq!(single.features, built[0].features, "{case}: one rank");
552 }
553 }
554 }
555
556 #[test]
557 fn reference_reproduces_layout() {
558 let cols = 9;
559 let values = matrix_values(4000, cols, 0);
560 let label = vec![0.0f32; 4000];
561 let train = BinnedDataset::construct(&input(&values, &label, cols), &Params::default(), &mut LocalCollective)
562 .expect("train");
563 let valid_values = matrix_values(1500, cols, 77_777);
564 let valid_label = vec![2.0f32; 1500];
565 let valid_input = input(&valid_values, &valid_label, cols);
566 let valid =
567 BinnedDataset::construct_with_reference(&valid_input, &train, &mut LocalCollective).expect("reference");
568 assert_eq!(valid.features, train.features);
569 assert_eq!(valid.total_bins, train.total_bins);
570 for (a, b) in valid.groups.iter().zip(&train.groups) {
571 assert_eq!(
572 (&a.features, a.row_stride, a.hist_offset, a.total_bins),
573 (&b.features, b.row_stride, b.hist_offset, b.total_bins)
574 );
575 assert_eq!(a.data.len(), 1500 * a.row_stride as usize);
576 }
577 check_bins(&valid, &valid_input.features, "reference");
578
579 let narrow = input(&valid_values[..1500 * 3], &valid_label, 3);
580 assert!(BinnedDataset::construct_with_reference(&narrow, &train, &mut LocalCollective).is_err());
581 }
582
583 #[test]
584 fn invalid_rank_fails_every_rank() {
585 let cols = 4;
586 let values = matrix_values(20, cols, 0);
587 let label = vec![1.0f32; 20];
588 let train = BinnedDataset::construct(&input(&values, &label, cols), &Params::default(), &mut LocalCollective)
589 .expect("train");
590 let expected = SilvicError::data("rank 1: 9 labels for 10 rows");
591 for reference in [None, Some(&train)] {
592 let results = run_ranks(2, |rank, collective| {
593 let rows = &values[rank * 10 * cols..(rank + 1) * 10 * cols];
594 let mut local = input(rows, &label[..10 - rank], cols);
595 local.features.rows = 10;
596 match reference {
597 None => BinnedDataset::construct(&local, &Params::default(), collective),
598 Some(reference) => BinnedDataset::construct_with_reference(&local, reference, collective),
599 }
600 });
601 for (rank, result) in results.into_iter().enumerate() {
602 let reference = reference.is_some();
603 assert_eq!(
604 result.err(),
605 Some(expected.clone()),
606 "rank {rank}, reference {reference}"
607 );
608 }
609 }
610 }
611
612 #[test]
613 fn rejects_bad_input() {
614 let cols = 4;
615 let values = matrix_values(10, cols, 0);
616 let label = vec![1.0f32; 10];
617 let params = Params::default();
618 let construct = |data: &DatasetInput<'_>| BinnedDataset::construct(data, ¶ms, &mut LocalCollective);
619 assert!(construct(&input(&values, &label, cols)).is_ok());
620
621 let short_label = vec![1.0f32; 9];
622 let mut bad = input(&values, &short_label, cols);
623 bad.features.rows = 10;
624 assert!(construct(&bad).is_err(), "label length");
625 let nan_label = [vec![1.0f32; 9], vec![f32::NAN]].concat();
626 assert!(construct(&input(&values, &nan_label, cols)).is_err(), "label NaN");
627 let negative = [vec![1.0f32; 9], vec![-1.0]].concat();
628 let mut bad = input(&values, &label, cols);
629 bad.weight = Some(&negative);
630 assert!(construct(&bad).is_err(), "negative weight");
631 bad.weight = Some(&label[..5]);
632 assert!(construct(&bad).is_err(), "weight length");
633 let mut bad = input(&values, &label, cols);
634 bad.group = Some(&[3, 3]);
635 assert!(construct(&bad).is_err(), "group sum");
636 let mut bad = input(&values, &label, cols);
637 bad.categorical_features = vec![4];
638 assert!(construct(&bad).is_err(), "categorical index");
639 let mut bad = input(&values, &label, cols);
640 bad.feature_names = Some(vec!["a".into(), "b".into()]);
641 assert!(construct(&bad).is_err(), "name count");
642 bad.feature_names = Some(vec!["a".into(), "b".into(), "c".into(), "a".into()]);
643 assert!(construct(&bad).is_err(), "duplicate name");
644 bad.feature_names = Some(vec!["a".into(), "b".into(), "c".into(), "d:e".into()]);
645 assert!(construct(&bad).is_err(), "json character");
646 let mut bad = input(&values[..39], &label, cols);
647 bad.features.rows = 10;
648 assert!(construct(&bad).is_err(), "matrix length");
649 }
650
651 #[test]
654 #[ignore = "benchmark: allocates 7 GB"]
655 fn binning_throughput() {
656 let (rows, cols) = (10_000_000usize, 140usize);
657 let mut values = vec![0f32; rows * cols];
658 values.par_chunks_mut(cols).enumerate().for_each(|(row, out)| {
659 for (col, value) in out.iter_mut().enumerate() {
660 let hash = mix32(mix32(row as u32) ^ mix32(col as u32 + 1));
661 *value = if col % 10 == 9 {
662 (hash % 12) as f32
663 } else {
664 (hash >> 8) as f32 / 16_777_216.0 - 0.5
665 };
666 }
667 });
668 let label = vec![0f32; rows];
669 let data = DatasetInput {
670 features: DenseMatrix {
671 data: DenseData::F32(&values),
672 rows,
673 cols,
674 layout: Layout::RowMajor,
675 },
676 label: &label,
677 weight: None,
678 group: None,
679 feature_names: None,
680 categorical_features: (9..cols).step_by(10).collect(),
681 };
682 let start = std::time::Instant::now();
683 let dataset = BinnedDataset::construct(&data, &Params::default(), &mut LocalCollective).expect("construct");
684 let construct_seconds = start.elapsed().as_secs_f64();
685 let start = std::time::Instant::now();
686 let rebinned = BinnedDataset::construct_with_reference(&data, &dataset, &mut LocalCollective).expect("rebin");
687 let bin_seconds = start.elapsed().as_secs_f64();
688 assert_eq!(rebinned.groups, dataset.groups);
689 let values_count = (rows * cols) as f64;
690 println!(
691 "{rows} x {cols} f32 row-major, {} groups, {} bins: construct {construct_seconds:.2} s ({:.3e} values/s), \
692 binning alone {bin_seconds:.2} s ({:.3e} values/s)",
693 dataset.groups.len(),
694 dataset.total_bins,
695 values_count / construct_seconds,
696 values_count / bin_seconds,
697 );
698 }
699}