Skip to content

Commit b34ccc9

Browse files
committed
Add centralized tolerance registry and integrate with Tester harness
Add a tolerance registry that documents per-backend numerical accuracy defaults extracted from a cross-backend audit of all ExecuTorch test files. Wire it into the Tester harness so backends that identify themselves via the new `backend` parameter get appropriate tolerance defaults automatically. New files: - backends/test/harness/tolerance.py: ToleranceConfig dataclass, BACKEND_TOLERANCES registry (15 backends), get_tolerance() lookup with fallback chain (dtype-specific → backend default → global default) - backends/test/harness/tests/test_tolerance.py: 37 unit tests Tester integration: - New optional `backend` param in Tester.__init__() - run_method_and_compare_outputs() consults registry when tolerances are not explicitly provided - Fully backward-compatible: no backend set = same defaults as before (atol=1e-3, rtol=1e-3, qtol=0); explicit values always win Part of #19910. See #13347 for the broader tolerance strategy discussion.
1 parent b73df0b commit b34ccc9

4 files changed

Lines changed: 465 additions & 4 deletions

File tree

‎backends/test/harness/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from .tester import Tester
2+
from .tolerance import get_tolerance, ToleranceConfig
23

3-
__all__ = ["Tester"]
4+
__all__ = ["Tester", "ToleranceConfig", "get_tolerance"]

‎backends/test/harness/tester.py‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def __init__(
4444
stage_classes: Dict[StageType, Callable] | None = None,
4545
dynamic_shapes: Optional[Tuple[Any]] = None,
4646
training: bool = False,
47+
backend: Optional[str] = None,
4748
):
4849
if training:
4950
module.train()
@@ -54,6 +55,7 @@ def __init__(
5455
self.original_module = module
5556
self.example_inputs = example_inputs
5657
self.dynamic_shapes = dynamic_shapes
58+
self.backend = backend
5759
self.stages: Dict[StageType, Stage] = OrderedDict.fromkeys(list(StageType))
5860
self.pipeline = {
5961
StageType.QUANTIZE: [StageType.EXPORT],
@@ -318,13 +320,27 @@ def run_method_and_compare_outputs(
318320
stage: Optional[StageType] = None,
319321
inputs: Optional[Tuple[torch.Tensor]] = None,
320322
num_runs=1,
321-
atol=1e-03,
322-
rtol=1e-03,
323-
qtol=0,
323+
atol=None,
324+
rtol=None,
325+
qtol=None,
324326
statistics_callback: Callable[[ErrorStatistics], None] | None = None,
325327
artifact_dir: Optional[str] = None,
326328
artifact_name: Optional[str] = None,
327329
):
330+
# Resolve tolerances: explicit values win, then registry, then defaults.
331+
if atol is None or rtol is None or qtol is None:
332+
if self.backend is not None:
333+
from executorch.backends.test.harness.tolerance import get_tolerance
334+
335+
config = get_tolerance(self.backend)
336+
atol = atol if atol is not None else config.atol
337+
rtol = rtol if rtol is not None else config.rtol
338+
qtol = qtol if qtol is not None else config.qtol
339+
else:
340+
atol = atol if atol is not None else 1e-03
341+
rtol = rtol if rtol is not None else 1e-03
342+
qtol = qtol if qtol is not None else 0
343+
328344
number_of_runs = 1 if inputs is not None else num_runs
329345
reference_stage = self.stages[StageType.EXPORT]
330346

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
import unittest
8+
9+
import torch
10+
from executorch.backends.test.harness.tolerance import (
11+
_dtype_to_key,
12+
BACKEND_TOLERANCES,
13+
get_tolerance,
14+
ToleranceConfig,
15+
)
16+
17+
18+
class TestToleranceConfig(unittest.TestCase):
19+
def test_default_values(self):
20+
config = ToleranceConfig()
21+
self.assertEqual(config.atol, 1e-3)
22+
self.assertEqual(config.rtol, 1e-3)
23+
self.assertEqual(config.qtol, 0)
24+
25+
def test_custom_values(self):
26+
config = ToleranceConfig(atol=1e-2, rtol=5e-2, qtol=2)
27+
self.assertEqual(config.atol, 1e-2)
28+
self.assertEqual(config.rtol, 5e-2)
29+
self.assertEqual(config.qtol, 2)
30+
31+
def test_frozen(self):
32+
config = ToleranceConfig()
33+
with self.assertRaises(AttributeError):
34+
config.atol = 0.5 # type: ignore[misc]
35+
36+
def test_with_quantization_scale(self):
37+
config = ToleranceConfig(atol=1e-3, rtol=1e-3, qtol=2)
38+
scale = 0.05
39+
adjusted = config.with_quantization_scale(scale)
40+
41+
self.assertAlmostEqual(adjusted.atol, 1e-3 + 0.05 * 2)
42+
self.assertEqual(adjusted.rtol, 1e-3)
43+
self.assertEqual(adjusted.qtol, 2)
44+
45+
def test_with_quantization_scale_zero_qtol(self):
46+
config = ToleranceConfig(atol=1e-3, rtol=1e-3, qtol=0)
47+
adjusted = config.with_quantization_scale(0.05)
48+
49+
self.assertEqual(adjusted.atol, 1e-3)
50+
51+
def test_with_quantization_scale_returns_new_instance(self):
52+
config = ToleranceConfig(atol=1e-3, rtol=1e-3, qtol=1)
53+
adjusted = config.with_quantization_scale(0.1)
54+
55+
self.assertIsNot(config, adjusted)
56+
self.assertEqual(config.atol, 1e-3)
57+
58+
def test_equality(self):
59+
a = ToleranceConfig(atol=1e-3, rtol=1e-3, qtol=0)
60+
b = ToleranceConfig(atol=1e-3, rtol=1e-3, qtol=0)
61+
self.assertEqual(a, b)
62+
63+
def test_inequality(self):
64+
a = ToleranceConfig(atol=1e-3, rtol=1e-3, qtol=0)
65+
b = ToleranceConfig(atol=1e-2, rtol=1e-3, qtol=0)
66+
self.assertNotEqual(a, b)
67+
68+
69+
class TestDtypeToKey(unittest.TestCase):
70+
def test_quantized_always_wins(self):
71+
self.assertEqual(_dtype_to_key(torch.float32, quantized=True), "quantized")
72+
self.assertEqual(_dtype_to_key(torch.float16, quantized=True), "quantized")
73+
self.assertEqual(_dtype_to_key(torch.bfloat16, quantized=True), "quantized")
74+
75+
def test_fp16(self):
76+
self.assertEqual(_dtype_to_key(torch.float16, quantized=False), "fp16")
77+
78+
def test_bf16(self):
79+
self.assertEqual(_dtype_to_key(torch.bfloat16, quantized=False), "bf16")
80+
81+
def test_fp32_default(self):
82+
self.assertEqual(_dtype_to_key(torch.float32, quantized=False), "default")
83+
84+
def test_fp64_default(self):
85+
self.assertEqual(_dtype_to_key(torch.float64, quantized=False), "default")
86+
87+
def test_int_types_default(self):
88+
self.assertEqual(_dtype_to_key(torch.int32, quantized=False), "default")
89+
self.assertEqual(_dtype_to_key(torch.int64, quantized=False), "default")
90+
91+
92+
class TestGetTolerance(unittest.TestCase):
93+
# --- Known backends ---
94+
95+
def test_xnnpack_default(self):
96+
config = get_tolerance("xnnpack")
97+
self.assertEqual(config.atol, 1e-3)
98+
self.assertEqual(config.rtol, 1e-3)
99+
100+
def test_xnnpack_fp16(self):
101+
config = get_tolerance("xnnpack", dtype=torch.float16)
102+
self.assertEqual(config.atol, 2e-3)
103+
self.assertEqual(config.rtol, 1e-3)
104+
105+
def test_xnnpack_quantized(self):
106+
config = get_tolerance("xnnpack", quantized=True)
107+
self.assertEqual(config.atol, 1e-3)
108+
self.assertEqual(config.rtol, 1e-3)
109+
self.assertEqual(config.qtol, 1)
110+
111+
def test_vulkan_default(self):
112+
config = get_tolerance("vulkan")
113+
self.assertEqual(config.atol, 1e-3)
114+
self.assertEqual(config.rtol, 1e-1)
115+
116+
def test_vulkan_fp16(self):
117+
config = get_tolerance("vulkan", dtype=torch.float16)
118+
self.assertEqual(config.atol, 1e-2)
119+
self.assertEqual(config.rtol, 1e-2)
120+
121+
def test_coreml_quantized(self):
122+
config = get_tolerance("coreml", quantized=True)
123+
self.assertEqual(config.atol, 5e-2)
124+
self.assertEqual(config.rtol, 5e-2)
125+
126+
def test_metal_bf16(self):
127+
config = get_tolerance("metal", dtype=torch.bfloat16)
128+
self.assertEqual(config.atol, 1e-2)
129+
self.assertEqual(config.rtol, 1e-2)
130+
131+
def test_metal_fp32(self):
132+
config = get_tolerance("metal")
133+
self.assertEqual(config.atol, 1e-5)
134+
self.assertEqual(config.rtol, 1e-5)
135+
136+
def test_qnn_quantized(self):
137+
config = get_tolerance("qnn", quantized=True)
138+
self.assertEqual(config.atol, 1e-1)
139+
self.assertEqual(config.rtol, 1.0)
140+
141+
def test_arm_quantized(self):
142+
config = get_tolerance("arm", quantized=True)
143+
self.assertEqual(config.qtol, 1)
144+
145+
def test_arm_bf16(self):
146+
config = get_tolerance("arm", dtype=torch.bfloat16)
147+
self.assertEqual(config.atol, 1e-2)
148+
149+
def test_nxp_default(self):
150+
config = get_tolerance("nxp")
151+
self.assertEqual(config.atol, 1e-8)
152+
self.assertEqual(config.rtol, 1e-5)
153+
154+
def test_nxp_quantized(self):
155+
config = get_tolerance("nxp", quantized=True)
156+
self.assertEqual(config.atol, 1.0)
157+
158+
def test_mlx_fp16(self):
159+
config = get_tolerance("mlx", dtype=torch.float16)
160+
self.assertEqual(config.atol, 1e-3)
161+
self.assertEqual(config.rtol, 1e-3)
162+
163+
def test_mlx_bf16(self):
164+
config = get_tolerance("mlx", dtype=torch.bfloat16)
165+
self.assertEqual(config.atol, 1e-2)
166+
self.assertEqual(config.rtol, 1e-2)
167+
168+
# --- Fallback behavior ---
169+
170+
def test_unknown_backend_returns_global_default(self):
171+
config = get_tolerance("nonexistent_backend")
172+
self.assertEqual(config.atol, 1e-3)
173+
self.assertEqual(config.rtol, 1e-3)
174+
self.assertEqual(config.qtol, 0)
175+
176+
def test_unknown_dtype_falls_back_to_backend_default(self):
177+
config = get_tolerance("xnnpack", dtype=torch.float64)
178+
self.assertEqual(config.atol, 1e-3)
179+
self.assertEqual(config.rtol, 1e-3)
180+
181+
def test_samsung_no_quantized_falls_back_to_default(self):
182+
config = get_tolerance("samsung", quantized=True)
183+
self.assertEqual(config.atol, 1e-3)
184+
self.assertEqual(config.rtol, 1e-3)
185+
186+
def test_webgpu_fp16_falls_back_to_default(self):
187+
config = get_tolerance("webgpu", dtype=torch.float16)
188+
self.assertEqual(config.atol, 1e-3)
189+
self.assertEqual(config.rtol, 1e-3)
190+
191+
# --- Op parameter (reserved for future use) ---
192+
193+
def test_op_parameter_currently_ignored(self):
194+
config_with_op = get_tolerance("xnnpack", op="aten.exp")
195+
config_without_op = get_tolerance("xnnpack")
196+
self.assertEqual(config_with_op, config_without_op)
197+
198+
# --- Registry completeness ---
199+
200+
def test_all_backends_have_default_key(self):
201+
for backend_name, backend_config in BACKEND_TOLERANCES.items():
202+
self.assertIn(
203+
"default",
204+
backend_config,
205+
f"Backend '{backend_name}' is missing a 'default' tolerance entry",
206+
)
207+
208+
def test_all_tolerance_values_are_non_negative(self):
209+
for backend_name, backend_config in BACKEND_TOLERANCES.items():
210+
for dtype_key, config in backend_config.items():
211+
self.assertGreaterEqual(
212+
config.atol,
213+
0,
214+
f"{backend_name}/{dtype_key} has negative atol",
215+
)
216+
self.assertGreaterEqual(
217+
config.rtol,
218+
0,
219+
f"{backend_name}/{dtype_key} has negative rtol",
220+
)
221+
self.assertGreaterEqual(
222+
config.qtol,
223+
0,
224+
f"{backend_name}/{dtype_key} has negative qtol",
225+
)
226+
227+
def test_registry_has_expected_backends(self):
228+
expected = {
229+
"xnnpack",
230+
"vulkan",
231+
"coreml",
232+
"mps",
233+
"metal",
234+
"qnn",
235+
"arm",
236+
"cortex_m",
237+
"samsung",
238+
"mlx",
239+
"openvino",
240+
"nxp",
241+
"cadence",
242+
"cuda",
243+
"webgpu",
244+
}
245+
self.assertEqual(set(BACKEND_TOLERANCES.keys()), expected)

0 commit comments

Comments
 (0)