Skip to content

Commit e27e9d0

Browse files
authored
test: report absolute and relative golden-value errors (#7321)
Signed-off-by: Yan Xu <yxu1@nvidia.com>
1 parent fdcf30e commit e27e9d0

4 files changed

Lines changed: 298 additions & 6 deletions

File tree

‎docs/developer/determinism/testing.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,37 @@ non-empty `tests` tuple or an explicit `exempt_reason` (for example a
5252
multi-rank NVLS collective that needs an NVLink peer group, or an allocator
5353
with no compute kernel). Exemptions are visible coverage debt, not silence.
5454

55+
## Golden-value error diagnostics
56+
57+
The functional comparison logs differences even when they pass tolerance. For
58+
each metric/check, it reports the check result, comparison precision, `atol`,
59+
`rtol`, and the number of samples outside tolerance. It shows the samples with
60+
the largest absolute and relative errors, plus the first sample outside
61+
tolerance if that sample is not already shown. Each sample includes its step,
62+
golden value, actual value, and allowed absolute error:
63+
64+
- Absolute error: `abs(actual - golden)`.
65+
- Relative error: `abs(actual - golden) / abs(golden)`, also shown as a percentage.
66+
- Allowed absolute error: `atol + rtol * abs(golden)`.
67+
68+
Relative error is undefined for a zero golden value and nonzero actual value;
69+
the absolute tolerance still applies. Non-finite or missing samples have
70+
undefined errors and are excluded from the finite-sample extrema.
71+
72+
The diagnostics use the same precision and aggregation as the check. Exact
73+
checks honor the golden's precision marker, approximate checks retain their
74+
five-decimal comparison, and iteration-time errors compare unrounded medians
75+
over the selected steady-state samples. Existing tolerance, failure-budget,
76+
and legacy-placeholder policies are unchanged. In particular, an approximate
77+
check can pass with some samples outside tolerance; the report shows those
78+
outliers even when the check passes. Exact checks retain zero tolerance.
79+
80+
A large error is a signal to investigate numerical correctness. A small
81+
historical-golden difference can also result from a valid change in reduction
82+
order. Use independent-reference correctness checks and same-implementation
83+
replay to assess the change before reviewing a golden refresh. Reporting an
84+
error never updates a golden value.
85+
5586
## How the requirement is enforced
5687

5788
1. **Repository invariant** (`tests/unit_tests/determinism/kernels/test_manifest.py`,

‎skills/mcore-testing/SKILL.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,8 @@ decimals until they are regenerated from a CI run.
216216

217217
---
218218

219+
Golden comparisons report absolute and relative errors; see [diagnostics](../../docs/developer/determinism/testing.md).
220+
219221
## Common Pitfalls
220222

221223
| Problem | Cause | Fix |

‎tests/functional_tests/python_test_utils/common.py‎

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,86 @@ def _round_values(values: List[Union[int, float, str]]) -> List[Union[int, float
247247
return [round(value, 5) if not isinstance(value, str) else value for value in values]
248248

249249

250+
def _log_comparison_errors(
251+
metric_name: str,
252+
test: Union[ApproximateTest, DeterministicTest],
253+
steps: List[Union[int, str]],
254+
actual: np.ndarray,
255+
golden: np.ndarray,
256+
is_close: np.ndarray,
257+
precision: ValuePrecision,
258+
passing: bool,
259+
) -> None:
260+
"""Report the largest errors at the precision and aggregation used by the check."""
261+
if np.array_equal(actual, golden):
262+
return
263+
264+
finite = np.isfinite(actual) & np.isfinite(golden)
265+
absolute_error = np.full(actual.shape, np.nan)
266+
relative_error = np.full(actual.shape, np.nan)
267+
# Cast before subtracting so integer metrics cannot wrap. Invalid/missing samples
268+
# retain undefined errors; they must not contaminate finite-sample extrema.
269+
with np.errstate(over="ignore", invalid="ignore", divide="ignore"):
270+
absolute_error[finite] = np.abs(
271+
actual[finite].astype(np.float64) - golden[finite].astype(np.float64)
272+
)
273+
np.divide(
274+
absolute_error, np.abs(golden.astype(np.float64)), out=relative_error, where=golden != 0
275+
)
276+
# Define an exact zero/zero match as zero relative error.
277+
relative_error[finite & (golden == 0) & (actual == 0)] = 0
278+
279+
outside_tolerance = np.flatnonzero(~is_close)
280+
logger.info(
281+
"Golden comparison for %s [%s]: %s; %d/%d samples outside tolerance; "
282+
"atol=%.9g, rtol=%.9g, precision=%s",
283+
metric_name,
284+
test.type_of_test_result.name,
285+
"PASSED" if passing else "FAILED",
286+
len(outside_tolerance),
287+
len(actual),
288+
test.atol,
289+
test.rtol,
290+
precision.value,
291+
)
292+
293+
samples = {}
294+
finite_indices = np.flatnonzero(finite)
295+
if len(finite_indices):
296+
index = int(finite_indices[np.argmax(absolute_error[finite])])
297+
samples[index] = ["max_absolute_error"]
298+
relative_indices = np.flatnonzero(finite & ~np.isnan(relative_error))
299+
if len(relative_indices):
300+
index = int(relative_indices[np.argmax(relative_error[relative_indices])])
301+
samples.setdefault(index, []).append("max_relative_error")
302+
if len(outside_tolerance) and outside_tolerance[0] not in samples:
303+
samples[int(outside_tolerance[0])] = ["first_outside_tolerance"]
304+
305+
value_format = ".5f" if precision == ValuePrecision.ROUNDED_5_DECIMAL_PLACES else ".17g"
306+
for index, labels in samples.items():
307+
if not finite[index]:
308+
abs_text = rel_text = allowed_text = "n/a (non-finite or missing)"
309+
else:
310+
abs_text = f"{absolute_error[index]:.9g}"
311+
rel_text = (
312+
"n/a (zero golden)"
313+
if np.isnan(relative_error[index])
314+
else f"{relative_error[index]:.9g} ({relative_error[index] * 100:.9g}%)"
315+
)
316+
allowed_text = f"{test.atol + test.rtol * abs(float(golden[index])):.9g}"
317+
logger.info(
318+
" %s at %s: golden=%s, actual=%s, absolute_error=%s, "
319+
"relative_error=%s, allowed_absolute_error=%s",
320+
", ".join(labels),
321+
steps[index],
322+
format(golden[index], value_format),
323+
format(actual[index], value_format),
324+
abs_text,
325+
rel_text,
326+
allowed_text,
327+
)
328+
329+
250330
def pipeline(
251331
compare_approximate_results: bool,
252332
golden_values: Dict[str, GoldenValueMetric],
@@ -276,6 +356,7 @@ def pipeline(
276356
f"Metric {metric_name} has no values in the golden file."
277357
)
278358

359+
sample_labels: List[Union[int, str]] = list(golden_value.values)
279360
golden_value_list = list(golden_value.values.values())
280361
actual_value_list = [
281362
actual_values[metric_name].values.get(value_step, "nan")
@@ -333,6 +414,8 @@ def pipeline(
333414
actual_value_list = [
334415
np.median([np.inf if isinstance(v, str) else v for v in actual_value_list])
335416
]
417+
sample_labels = [f"median over steps {', '.join(map(str, comparison_steps))}"]
418+
comparison_precision = ValuePrecision.FULL
336419
total_steps_evaluated = 1
337420
else:
338421
total_steps_evaluated = len(golden_value.values)
@@ -361,13 +444,19 @@ def pipeline(
361444
num_failing_steps_allowed / total_steps_evaluated
362445
)
363446

447+
_log_comparison_errors(
448+
metric_name,
449+
test,
450+
sample_labels,
451+
actual,
452+
golden,
453+
is_close,
454+
comparison_precision,
455+
passing,
456+
)
364457
if not passing:
365-
logger.info(
366-
"Actual values: %s", ", ".join([str(v) for v in (*actual_value_list,)])
367-
)
368-
logger.info(
369-
"Golden values: %s", ", ".join([str(v) for v in (*golden_value_list,)])
370-
)
458+
logger.info("Actual values: %s", actual_value_list)
459+
logger.info("Golden values: %s", golden_value_list)
371460
raise test.error_message(metric_name)
372461

373462
result = f"{test.type_of_test_result.name} test for metric {metric_name}: PASSED"

‎tests/functional_tests/python_test_utils/test_common.py‎

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,3 +436,173 @@ def test_failure_message_lists_all_failed_metrics(self):
436436
{"loss": [DeterministicTest()], "num-zeros": [DeterministicTest()]},
437437
)
438438
assert "num-zeros" in str(exc_info.value)
439+
440+
441+
class TestGoldenErrorDiagnostics:
442+
def test_reports_absolute_and_relative_extrema_at_their_own_steps(self, caplog):
443+
caplog.set_level("INFO", logger=common.__name__)
444+
golden = make_metric({10: 4.0, 30: -0.01, 60: 0.0}, value_precision=ValuePrecision.FULL)
445+
# The observed samples are deliberately stored in a different order.
446+
actual = make_metric({60: 0.0, 30: -0.02, 10: 5.0})
447+
448+
with pytest.raises(AssertionError, match="loss"):
449+
run({"loss": golden}, {"loss": actual}, {"loss": [DeterministicTest()]})
450+
451+
assert "2/3 samples outside tolerance" in caplog.text
452+
assert "max_absolute_error at 10: golden=4, actual=5, absolute_error=1" in caplog.text
453+
assert "relative_error=0.25 (25%)" in caplog.text
454+
assert "max_relative_error at 30:" in caplog.text
455+
assert "absolute_error=0.01, relative_error=1 (100%)" in caplog.text
456+
assert "allowed_absolute_error=0" in caplog.text
457+
assert "first_outside_tolerance" not in caplog.text
458+
459+
def test_reports_small_difference_that_passes_tolerance(self, caplog):
460+
caplog.set_level("INFO", logger=common.__name__)
461+
run(
462+
{"loss": make_metric({5: 1.0})},
463+
{"loss": make_metric({5: 1.04})},
464+
{"loss": [ApproximateTest(rtol=0.05)]},
465+
)
466+
467+
assert "[APPROXIMATE]: PASSED; 0/1 samples outside tolerance" in caplog.text
468+
assert "absolute_error=0.04, relative_error=0.04 (4%)" in caplog.text
469+
assert "allowed_absolute_error=0.05" in caplog.text
470+
471+
@pytest.mark.parametrize("atol,passes", [(1.0, True), (0.25, False)])
472+
def test_zero_golden_uses_absolute_tolerance(self, atol, passes, caplog):
473+
caplog.set_level("INFO", logger=common.__name__)
474+
args = (
475+
{"loss": make_metric({5: 0.0})},
476+
{"loss": make_metric({5: 0.5})},
477+
{"loss": [ApproximateTest(atol=atol, rtol=0.05)]},
478+
)
479+
if passes:
480+
run(*args)
481+
else:
482+
with pytest.raises(AssertionError, match="loss"):
483+
run(*args)
484+
485+
assert "absolute_error=0.5, relative_error=n/a (zero golden)" in caplog.text
486+
assert "max_relative_error" not in caplog.text
487+
488+
def test_near_zero_golden_reports_large_relative_error_within_atol(self, caplog):
489+
caplog.set_level("INFO", logger=common.__name__)
490+
run(
491+
{"loss": make_metric({5: 0.00001})},
492+
{"loss": make_metric({5: 0.00002})},
493+
{"loss": [ApproximateTest(atol=0.001, rtol=0)]},
494+
)
495+
496+
assert "[APPROXIMATE]: PASSED" in caplog.text
497+
assert "absolute_error=1e-05, relative_error=1 (100%)" in caplog.text
498+
assert "allowed_absolute_error=0.001" in caplog.text
499+
500+
@pytest.mark.parametrize("actual_value", ["nan", float("nan"), float("inf")])
501+
def test_nonfinite_values_have_undefined_errors(self, actual_value, caplog):
502+
caplog.set_level("INFO", logger=common.__name__)
503+
with pytest.raises(AssertionError, match="loss"):
504+
run(
505+
{"loss": make_metric({5: 1.0})},
506+
{"loss": make_metric({5: actual_value})},
507+
{"loss": [DeterministicTest()]},
508+
)
509+
510+
assert "first_outside_tolerance at 5:" in caplog.text
511+
assert "absolute_error=n/a (non-finite or missing)" in caplog.text
512+
assert "max_absolute_error" not in caplog.text
513+
514+
def test_missing_sample_is_identified_even_with_finite_extrema(self, caplog):
515+
caplog.set_level("INFO", logger=common.__name__)
516+
with pytest.raises(AssertionError, match="loss"):
517+
run(
518+
{"loss": make_metric({5: 1.0, 10: 1.0})},
519+
{"loss": make_metric({10: 1.25})},
520+
{"loss": [DeterministicTest()]},
521+
)
522+
523+
assert "max_absolute_error, max_relative_error at 10:" in caplog.text
524+
assert "first_outside_tolerance at 5:" in caplog.text
525+
assert "absolute_error=n/a (non-finite or missing)" in caplog.text
526+
527+
def test_single_sample_extrema_share_one_precision_appropriate_row(self, caplog):
528+
caplog.set_level("INFO", logger=common.__name__)
529+
with pytest.raises(AssertionError):
530+
run(
531+
{"loss": make_metric({5: 10.83456})},
532+
{"loss": make_metric({5: 10.83457})},
533+
{"loss": [ApproximateTest(atol=0, rtol=0)]},
534+
)
535+
assert caplog.text.count(" at 5: golden=") == 1
536+
assert "max_absolute_error, max_relative_error at 5:" in caplog.text
537+
assert "golden=10.83456, actual=10.83457," in caplog.text
538+
assert "Actual values: [10.83457]" in caplog.text
539+
assert "Golden values: [10.83456]" in caplog.text
540+
541+
def test_legacy_golden_placeholder_retains_undefined_diagnostics(self, caplog):
542+
caplog.set_level("INFO", logger=common.__name__)
543+
with pytest.raises(AssertionError):
544+
run(
545+
{"loss": make_metric({5: "nan", 10: 1.0})},
546+
{"loss": make_metric({5: 0.9, 10: 1.0})},
547+
{"loss": [DeterministicTest()]},
548+
)
549+
assert "first_outside_tolerance at 5: golden=inf" in caplog.text
550+
assert "absolute_error=n/a (non-finite or missing)" in caplog.text
551+
552+
def test_full_precision_error_is_not_rounded_away(self, caplog):
553+
caplog.set_level("INFO", logger=common.__name__)
554+
with pytest.raises(AssertionError, match="loss"):
555+
run(
556+
{"loss": make_metric({5: 0.5}, value_precision=ValuePrecision.FULL)},
557+
{"loss": make_metric({5: 0.5 + 2**-24})},
558+
{"loss": [DeterministicTest()]},
559+
)
560+
561+
assert "precision=full" in caplog.text
562+
assert "absolute_error=5.96046448e-08" in caplog.text
563+
564+
def test_reports_both_exact_failure_and_approximate_success(self, caplog):
565+
caplog.set_level("INFO", logger=common.__name__)
566+
with pytest.raises(AssertionError, match="loss"):
567+
run(
568+
{"loss": make_metric({5: 1.0}, value_precision=ValuePrecision.FULL)},
569+
{"loss": make_metric({5: 1.01})},
570+
{"loss": [DeterministicTest(), ApproximateTest(rtol=0.05)]},
571+
)
572+
573+
assert "[DETERMINISTIC]: FAILED" in caplog.text
574+
assert "[APPROXIMATE]: PASSED" in caplog.text
575+
576+
def test_approximate_failure_budget_is_visible(self, caplog):
577+
caplog.set_level("INFO", logger=common.__name__)
578+
values = {step: 1.0 for step in range(1, 101)}
579+
run(
580+
{"loss": make_metric(values)},
581+
{"loss": make_metric({**values, 100: 999.0})},
582+
{"loss": [ApproximateTest(rtol=0.05)]},
583+
)
584+
585+
assert "[APPROXIMATE]: PASSED; 1/100 samples outside tolerance" in caplog.text
586+
assert "max_absolute_error, max_relative_error at 100:" in caplog.text
587+
588+
def test_timing_error_is_labelled_as_a_median(self, caplog):
589+
caplog.set_level("INFO", logger=common.__name__)
590+
with pytest.raises(AssertionError, match="iteration-time"):
591+
run(
592+
{"iteration-time": make_metric({5: 0.25, 10: 0.25, 15: 0.25})},
593+
{"iteration-time": make_metric({5: 0.5, 10: 0.5, 15: 0.5})},
594+
{"iteration-time": [ApproximateTest(rtol=0.05)]},
595+
)
596+
597+
assert (
598+
"max_absolute_error, max_relative_error at median over steps 5, 10, 15:" in caplog.text
599+
)
600+
assert "absolute_error=0.25, relative_error=1 (100%)" in caplog.text
601+
assert "precision=full" in caplog.text
602+
603+
def test_matching_values_do_not_emit_error_diagnostics(self, caplog):
604+
caplog.set_level("INFO", logger=common.__name__)
605+
metric = make_metric({1: 0.0, 5: 1.0})
606+
run({"loss": metric}, {"loss": metric}, {"loss": [DeterministicTest()]})
607+
608+
assert "Golden comparison" not in caplog.text

0 commit comments

Comments
 (0)