ruda_test_utils/correctness/
base.rs1use std::path::Path;
2
3use crate::{
4 config::config,
5 correctness::{
6 color_printer::index_matches_filter,
7 render::print_tensors,
8 {DimFilter, TensorFilter},
9 },
10 test_tensor::read_host_data,
11 {HostData, ValidationResult},
12};
13
14const DEFAULT_MAX_REPORTED_MISMATCHES: usize = 8;
17
18pub fn assert_equals_approx(
20 lhs: &HostData,
22 rhs: &HostData,
24 epsilon: f32,
26) -> ValidationResult {
27 assert_equals_approx_inner(lhs, rhs, epsilon, None)
28}
29
30pub fn assert_equals_approx_in_slice<I>(
37 lhs: &HostData,
39 rhs: &HostData,
41 epsilon: f32,
43 filter: I,
45) -> ValidationResult
46where
47 I: IntoIterator,
48 I::Item: Into<DimFilter>,
49{
50 let filter: TensorFilter = filter.into_iter().map(Into::into).collect();
51 assert_equals_approx_inner(lhs, rhs, epsilon, Some(filter))
52}
53
54pub fn compare_host_data_files(
62 actual_path: &Path,
63 expected_path: &Path,
64 epsilon: f32,
65) -> ValidationResult {
66 let actual = match read_host_data(actual_path) {
67 Ok(v) => v,
68 Err(e) => {
69 return ValidationResult::Error(format!(
70 "failed to read host data file {}: {e}",
71 actual_path.display()
72 ));
73 }
74 };
75 let expected = match read_host_data(expected_path) {
76 Ok(v) => v,
77 Err(e) => {
78 return ValidationResult::Error(format!(
79 "failed to read host data file {}: {e}",
80 expected_path.display()
81 ));
82 }
83 };
84 assert_equals_approx(&actual, &expected, epsilon)
85}
86
87fn assert_equals_approx_inner(
90 lhs: &HostData,
92 rhs: &HostData,
94 epsilon: f32,
96 filter: Option<TensorFilter>,
98) -> ValidationResult {
99 let print_cfg = &config().print;
103 print_tensors(print_cfg, "diff", &[lhs, rhs], Some(epsilon));
104
105 if lhs.shape != rhs.shape {
106 return ValidationResult::Fail(format!(
107 "Shape mismatch: got {:?}, expected {:?}",
108 lhs.shape, rhs.shape,
109 ));
110 }
111
112 let shape = &lhs.shape;
113 let in_print_mode = print_cfg.enabled;
114
115 let mut summary_visitor = SummaryCollector::new(DEFAULT_MAX_REPORTED_MISMATCHES);
116
117 if let Some(f) = &filter
120 && !f.is_empty()
121 && f.len() != shape.len()
122 {
123 return ValidationResult::Error(format!(
124 "Comparison filter rank mismatch. Got {}, expected {} (tensor shape {:?})",
125 f.len(),
126 shape.len(),
127 shape,
128 ));
129 }
130
131 let test_failed = compare_tensors(
132 lhs,
133 rhs,
134 shape,
135 epsilon,
136 &mut summary_visitor,
137 &mut Vec::new(),
138 filter.as_ref(),
139 );
140
141 if test_failed {
142 return ValidationResult::Fail(summary_visitor.report(shape, in_print_mode));
143 }
144
145 ValidationResult::Pass
146}
147
148#[derive(Debug)]
149pub(crate) enum ElemStatus {
150 #[allow(dead_code)] Correct {
152 got: f32,
153 delta: f32,
154 epsilon: f32,
155 },
156 Wrong(WrongStatus),
157}
158
159#[derive(Debug)]
160pub(crate) enum WrongStatus {
161 GotWrongValue {
162 got: f32,
163 expected: f32,
164 delta: f32,
165 epsilon: f32,
166 },
167 ExpectedNan {
168 got: f32,
169 },
170 GotNan {
171 expected: f32,
172 },
173}
174
175pub(crate) trait CompareVisitor {
176 fn visit(&mut self, index: &[usize], status: ElemStatus);
177}
178
179pub(crate) struct SummaryCollector {
183 pub max_reported: usize,
184 pub mismatches: Vec<(Vec<usize>, WrongStatus)>,
185 pub total: usize,
186 pub mismatched: usize,
187 pub max_abs_delta: f32,
188 pub sum_abs_delta: f64,
189 pub worst_index: Option<Vec<usize>>,
190}
191
192impl SummaryCollector {
193 fn new(max_reported: usize) -> Self {
194 Self {
195 max_reported,
196 mismatches: Vec::new(),
197 total: 0,
198 mismatched: 0,
199 max_abs_delta: 0.0,
200 sum_abs_delta: 0.0,
201 worst_index: None,
202 }
203 }
204
205 fn record_delta(&mut self, index: &[usize], delta: f32) {
206 if delta.is_finite() {
207 self.sum_abs_delta += delta as f64;
208 }
209 if delta > self.max_abs_delta || self.worst_index.is_none() {
210 self.max_abs_delta = delta;
211 self.worst_index = Some(index.to_vec());
212 }
213 }
214
215 pub(crate) fn report(&self, shape: &[usize], skip_examples: bool) -> String {
216 let mut out = String::new();
217 out.push_str("Got incorrect results: ");
218 out.push_str(&format!(
219 "{}/{} elements mismatched",
220 self.mismatched, self.total
221 ));
222
223 if self.mismatched > 0 {
224 let mean = if self.mismatched > 0 {
225 self.sum_abs_delta / self.mismatched as f64
226 } else {
227 0.0
228 };
229 out.push_str(&format!(
230 " (max |Δ|={:.6}, mean |Δ|={:.6}",
231 self.max_abs_delta, mean
232 ));
233 if let Some(idx) = &self.worst_index {
234 out.push_str(&format!(", worst at {:?}", idx));
235 }
236 out.push(')');
237 out.push_str(&format!(" — shape={:?}", shape));
238 }
239
240 if skip_examples || self.mismatches.is_empty() {
243 return out;
244 }
245
246 out.push_str("\nFirst mismatches:");
247 for (idx, w) in &self.mismatches {
248 out.push_str(&format!("\n {:?}: {}", idx, format_wrong(w)));
249 }
250 if self.mismatched > self.mismatches.len() {
251 out.push_str(&format!(
252 "\n ... and {} more",
253 self.mismatched - self.mismatches.len()
254 ));
255 }
256
257 out
258 }
259}
260
261impl CompareVisitor for SummaryCollector {
262 fn visit(&mut self, index: &[usize], status: ElemStatus) {
263 self.total += 1;
264 if let ElemStatus::Wrong(w) = status {
265 self.mismatched += 1;
266 let delta = match &w {
267 WrongStatus::GotWrongValue { delta, .. } => *delta,
268 WrongStatus::ExpectedNan { .. } | WrongStatus::GotNan { .. } => f32::INFINITY,
269 };
270 self.record_delta(index, delta);
271 if self.mismatches.len() < self.max_reported {
272 self.mismatches.push((index.to_vec(), w));
273 }
274 }
275 }
276}
277
278fn format_wrong(w: &WrongStatus) -> String {
279 match w {
280 WrongStatus::GotWrongValue {
281 got,
282 expected,
283 delta,
284 epsilon,
285 } => format!("got {got}, expected {expected}, |Δ|={delta} > ε={epsilon}",),
286 WrongStatus::ExpectedNan { got } => format!("got {got}, expected NaN"),
287 WrongStatus::GotNan { expected } => format!("got NaN, expected {expected}"),
288 }
289}
290
291#[inline]
292fn compare_elem(got: f32, expected: f32, epsilon: f32) -> ElemStatus {
293 let epsilon = (epsilon * expected).abs().max(epsilon);
294
295 if got.is_nan() && expected.is_nan() {
297 return ElemStatus::Correct {
298 got,
299 delta: 0.,
300 epsilon,
301 };
302 }
303
304 if got.is_nan() || expected.is_nan() {
306 return if expected.is_nan() {
307 ElemStatus::Wrong(WrongStatus::ExpectedNan { got })
308 } else {
309 ElemStatus::Wrong(WrongStatus::GotNan { expected })
310 };
311 }
312
313 if got.is_infinite() && expected.is_infinite() {
315 if got.signum() == expected.signum() {
316 return ElemStatus::Correct {
317 got,
318 delta: 0.,
319 epsilon,
320 };
321 } else {
322 return ElemStatus::Wrong(WrongStatus::GotWrongValue {
323 got,
324 expected,
325 delta: f32::INFINITY,
326 epsilon,
327 });
328 }
329 }
330
331 let delta = (got - expected).abs();
333 if delta <= epsilon {
334 ElemStatus::Correct {
335 got,
336 delta,
337 epsilon,
338 }
339 } else {
340 ElemStatus::Wrong(WrongStatus::GotWrongValue {
341 got,
342 expected,
343 delta,
344 epsilon,
345 })
346 }
347}
348
349fn compare_tensors(
350 actual: &HostData,
351 expected: &HostData,
352 shape: &[usize],
353 epsilon: f32,
354 visitor: &mut dyn CompareVisitor,
355 index: &mut Vec<usize>,
356 filter: Option<&TensorFilter>,
357) -> bool {
358 let mut failed = false;
359
360 let dim = index.len();
361 if dim == shape.len() {
362 if let Some(filter) = filter
365 && !filter.is_empty()
366 && !index_matches_filter(index, filter)
367 {
368 return false;
369 }
370
371 let got = actual.get_f32(index);
372 let exp = expected.get_f32(index);
373 let status = compare_elem(got, exp, epsilon);
374
375 if matches!(status, ElemStatus::Wrong(_)) {
376 failed = true;
377 }
378
379 visitor.visit(index, status);
380 return failed;
381 }
382
383 for i in 0..shape[dim] {
385 index.push(i);
386 if compare_tensors(actual, expected, shape, epsilon, visitor, index, filter) {
387 failed = true;
388 }
389 index.pop();
390 }
391
392 failed
393}