From 887161c6b88355464c78141d51438ac4a010176a Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Tue, 3 Nov 2026 07:55:13 +0100 Subject: [PATCH 1/2] perf(analysis): deep-learning classification rows on several cores (#1252) Classify Pixels runs its rows on parallel::for_each_row, as IDW, kriging and viewshed do: each row writes only its own pixels of the class and probability bands, and each thread reads a pixel's band values into its own buffer. The output is the same bit for bit as on one thread (classify_on_several_threads_is_classify_on_one). 2000 x 2000 pixels, 4 bands, a 32-32 model: 5.2 s on one thread, 0.57 s on 16. --- crates/analysis/src/neural.rs | 90 +++++++++++++++++++++++++---------- 1 file changed, 65 insertions(+), 25 deletions(-) diff --git a/crates/analysis/src/neural.rs b/crates/analysis/src/neural.rs index 95aa16e88..fa9836b19 100644 --- a/crates/analysis/src/neural.rs +++ b/crates/analysis/src/neural.rs @@ -347,8 +347,13 @@ pub fn train( } /// Classifies every pixel: class numbers (1-based, `nodata` where a band has no data) and each -/// pixel's probability of that class. +/// pixel's probability of that class. Rows go on several threads when the work is large (#1252). pub fn classify(r: &Raster, model: &Mlp, nodata: f32) -> Result<(Vec, Vec), String> { + classify_on(r, model, nodata, None) +} + +/// [`classify`] on `threads` threads (none: as many as pay off). +fn classify_on(r: &Raster, model: &Mlp, nodata: f32, threads: Option) -> Result<(Vec, Vec), String> { model.validate()?; // A large model on a large raster is refused rather than run for minutes. let weights: usize = model.layers.iter().map(|l| l.w.len() + l.b.len()).sum(); @@ -358,32 +363,35 @@ pub fn classify(r: &Raster, model: &Mlp, nodata: f32) -> Result<(Vec, Vec= r.bands.len()) { return Err(format!("{} has no band {}", r.name, b + 1)); } - let (w, h) = (r.width as i64, r.height as i64); - let (mut class, mut prob) = (crate::rasterops::grid(w as usize, h as usize)?, crate::rasterops::grid(w as usize, h as usize)?); - let mut x = vec![0.0f32; model.bands.len()]; - crate::control::begin(h as usize); - for row in 0..h { - crate::control::check()?; - crate::control::reached(row as usize); - for col in 0..w { - // A non-finite value is treated as no data. - let ok = model.bands.iter().zip(x.iter_mut()).all(|(b, xv)| match r.value(*b, col, row).filter(|v| v.is_finite()) { - Some(v) => { - *xv = v; - true + let (w, h) = (r.width as usize, r.height as usize); + let (mut class, mut prob) = (crate::rasterops::new_band(w, h, nodata)?, crate::rasterops::new_band(w, h, nodata)?); + let work = w.saturating_mul(h).saturating_mul(weights); + let threads = threads.unwrap_or_else(|| crate::parallel::threads_for_rows(h, work, model.bands.len() * 4)); + crate::control::begin(h); + // Each row writes only its own pixels of both bands; each thread reads into its own buffer. + crate::parallel::for_each_row( + class.chunks_mut(w.max(1)).zip(prob.chunks_mut(w.max(1))), + threads, + || vec![0.0f32; model.bands.len()], + |x: &mut Vec, row, (class, prob): (&mut [f32], &mut [f32])| { + for (col, (c_out, p_out)) in class.iter_mut().zip(prob.iter_mut()).enumerate() { + // A non-finite value is treated as no data. + let ok = model.bands.iter().zip(x.iter_mut()).all(|(b, xv)| { + match r.value(*b, col as i64, row as i64).filter(|v| v.is_finite()) { + Some(v) => { + *xv = v; + true + } + None => false, + } + }); + if ok { + let (c, p) = model.predict(x); + (*c_out, *p_out) = ((c + 1) as f32, p); } - None => false, - }); - if ok { - let (c, p) = model.predict(&x); - class.push((c + 1) as f32); - prob.push(p); - } else { - class.push(nodata); - prob.push(nodata); } - } - } + }, + )?; Ok((class, prob)) } @@ -391,6 +399,38 @@ pub fn classify(r: &Raster, model: &Mlp, nodata: f32) -> Result<(Vec, Vec f32| { + (0..w * h).map(|i| f((i % w) as f32 / w as f32 * 4.0 - 2.0, (i / w) as f32 / h as f32 * 4.0 - 2.0)).collect() + }; + let a: Vec = band(&|x, _| x * 100.0 + 500.0); + let mut b: Vec = band(&|_, y| y * 50.0); + for v in b.iter_mut().step_by(53) { + *v = f32::NAN; + } + let transform = GeoTransform { origin_x: 0.0, origin_y: 45.0, pixel_width: 0.001, pixel_height: -0.001 }; + let r = Raster::new("img", w, h, vec![a, b], transform, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); + let (c1, p1) = classify_on(&r, &model, -1.0, Some(1)).unwrap(); + assert!(c1.contains(&1.0) && c1.contains(&2.0) && c1.contains(&-1.0), "both classes and no data"); + for threads in [2, 7, 16] { + let (c, p) = classify_on(&r, &model, -1.0, Some(threads)).unwrap(); + let bits = |v: &[f32]| v.iter().map(|x| x.to_bits()).collect::>(); + assert_eq!((bits(&c), bits(&p)), (bits(&c1), bits(&p1)), "{threads} threads"); + } + } + #[test] fn learns_a_nonlinear_boundary_and_validates() { // Class 1 inside a ring, class 0 outside: not separable by a line. From 1f7966ce4eaf515a099a409276c5a1739298272b Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Tue, 3 Nov 2026 08:12:12 +0100 Subject: [PATCH 2/2] perf(analysis): classify counts its work in the threshold's units; tests check the probabilities and the public path --- crates/analysis/src/neural.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/analysis/src/neural.rs b/crates/analysis/src/neural.rs index fa9836b19..455d293df 100644 --- a/crates/analysis/src/neural.rs +++ b/crates/analysis/src/neural.rs @@ -347,7 +347,8 @@ pub fn train( } /// Classifies every pixel: class numbers (1-based, `nodata` where a band has no data) and each -/// pixel's probability of that class. Rows go on several threads when the work is large (#1252). +/// pixel's probability of that class. Rows go on several threads when the work is large, except in +/// the browser, which uses one (#1252). pub fn classify(r: &Raster, model: &Mlp, nodata: f32) -> Result<(Vec, Vec), String> { classify_on(r, model, nodata, None) } @@ -365,7 +366,9 @@ fn classify_on(r: &Raster, model: &Mlp, nodata: f32, threads: Option) -> } let (w, h) = (r.width as usize, r.height as usize); let (mut class, mut prob) = (crate::rasterops::new_band(w, h, nodata)?, crate::rasterops::new_band(w, h, nodata)?); - let work = w.saturating_mul(h).saturating_mul(weights); + // In [`crate::parallel::MIN_ROW_WORK`]'s units (about 30 ns each): a weight of a pixel costs + // about 1 ns. + let work = w.saturating_mul(h).saturating_mul(weights) / 30; let threads = threads.unwrap_or_else(|| crate::parallel::threads_for_rows(h, work, model.bands.len() * 4)); crate::control::begin(h); // Each row writes only its own pixels of both bands; each thread reads into its own buffer. @@ -424,11 +427,16 @@ mod tests { let r = Raster::new("img", w, h, vec![a, b], transform, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); let (c1, p1) = classify_on(&r, &model, -1.0, Some(1)).unwrap(); assert!(c1.contains(&1.0) && c1.contains(&2.0) && c1.contains(&-1.0), "both classes and no data"); + // A classified pixel has its class's probability; one with no data has none. + assert!(c1.iter().zip(&p1).all(|(c, p)| if *c == -1.0 { *p == -1.0 } else { (0.0..=1.0).contains(p) && *p >= 0.5 })); for threads in [2, 7, 16] { let (c, p) = classify_on(&r, &model, -1.0, Some(threads)).unwrap(); let bits = |v: &[f32]| v.iter().map(|x| x.to_bits()).collect::>(); assert_eq!((bits(&c), bits(&p)), (bits(&c1), bits(&p1)), "{threads} threads"); } + // As many threads as pay off, which `classify` picks, give the same. + let (c, p) = classify(&r, &model, -1.0).unwrap(); + assert_eq!((c, p), (c1, p1)); } #[test]