Skip to content

Commit 841420f

Browse files
committed
TST: Add pytest ULP accuracy harness
Builds `numpy_sr`: a pybind11 extension exposing one submodule per Highway target plus two references (an MPFR correctly-rounded oracle and Intel SVML), driven by a pytest plugin that scores every kernel in fractional ULP. - meson build behind the `tests` feature; highway and numpy/SVML as subprojects - vectorized ULP comparators, MPFR ref+residual oracle, FP-exception probe - suite covers IEEE specials, parity, tiny identities, fenv discipline, random and exhaustive sweeps, an adaptive worst-case hunt, and a frozen worst-case book replayed as a regression gate - `spin test` / `spin ulp-report` wrappers and a static HTML report viewer precise.h gains `FPExceptions::kInexact` so the harness can name the flag the kernels deliberately leave unmanaged.
1 parent ca4f3e6 commit 841420f

44 files changed

Lines changed: 3624 additions & 23 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,12 @@
1-
# Temporary #
2-
#############
3-
.cache
4-
.direnv
1+
__pycache__/
2+
/.cache/
3+
/build-install/
4+
/ulp-report.html
5+
/ulp-report.js
6+
/ulp-report.json
57

6-
# Compiled source #
7-
###################
8-
*.a
9-
*.dll
10-
*.exe
11-
*.o
12-
*.o.d
13-
*.py[ocd]
14-
*.so
15-
*.mod
16-
17-
# Logs #
18-
########
19-
*.log
20-
21-
# Patches #
22-
###########
23-
*.patch
24-
*.diff
8+
# meson subprojects: track the wrap manifests and the packagefile overlays
9+
# they graft onto upstream checkouts; ignore the checkouts themselves.
10+
/subprojects/*
11+
!/subprojects/*.wrap
12+
!/subprojects/packagefiles/

.spin/cmds.py

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,265 @@
1+
import os
12
import pathlib
23
import sys
4+
35
import click
6+
import spin.cmds.meson as _meson
47

58
curdir = pathlib.Path(__file__).parent
69
rootdir = curdir.parent
710
toolsdir = rootdir / "tools"
811
sys.path.insert(0, str(toolsdir))
912

13+
PACKAGE = "numpy_sr"
14+
15+
# Registered in `python/_conftest/hooks.py`; both are skipped unless opted into.
16+
MARKERS = ("slow", "exhaustive")
17+
18+
19+
def _markexpr(markers):
20+
"""`--marker slow,exhaustive` -> pytest's `-m "slow or exhaustive"`.
21+
22+
The conftest only arms its skip-the-marked gate while pytest's mark
23+
expression is empty, so `all` hands over a tautology: it disarms the gate
24+
without narrowing the selection."""
25+
names = list(
26+
dict.fromkeys(n for spec in markers for n in spec.split(",") if n.strip())
27+
)
28+
choices = (*MARKERS, "all")
29+
unknown = [n for n in names if n not in choices]
30+
if unknown:
31+
raise click.BadParameter(
32+
f"unknown marker(s): {', '.join(unknown)}; pick from {', '.join(choices)}"
33+
)
34+
if not names:
35+
return None
36+
if "all" in names:
37+
return f"{MARKERS[0]} or not {MARKERS[0]}"
38+
return " or ".join(names)
39+
40+
41+
def _is_path(arg):
42+
return not arg.startswith("-") and (
43+
arg.endswith(".py") or "::" in arg or os.sep in arg
44+
)
45+
46+
47+
def _as_path(arg, build_dir):
48+
"""`numpy_sr.tests.test_trig[::node]` -> its installed file. A `--pyargs`
49+
module selection loads conftest.py too late to register --target & co,
50+
so a selection has to reach pytest as a path. Returns None if unresolved."""
51+
if not (arg == PACKAGE or arg.startswith(f"{PACKAGE}.")):
52+
return None
53+
module, sep, node = arg.partition("::")
54+
base = pathlib.Path(_meson._get_site_packages(build_dir), *module.split("."))
55+
for candidate in (base.with_suffix(".py"), base):
56+
if candidate.exists():
57+
return f"{candidate}{sep}{node}"
58+
return None
59+
60+
61+
def _selection(pytest_args, tests, build_dir):
62+
"""Resolve test selections to paths and re-add the `--pyargs numpy_sr` that
63+
upstream drops as soon as any pytest argument is given -- without it pytest
64+
collects the whole of site-packages."""
65+
args = tuple(_as_path(a, build_dir) or a for a in pytest_args)
66+
resolved = _as_path(tests, build_dir) if tests else None
67+
if resolved:
68+
args, tests = (*args, resolved), None
69+
if tests or any(_is_path(a) for a in args):
70+
return args, tests
71+
return ("--pyargs", PACKAGE, *args), tests
72+
73+
74+
@click.command(
75+
help=f"""🔧 Run tests
76+
77+
Wraps `spin.cmds.meson.test`, exposing the suite's own pytest options
78+
directly and keeping collection rooted at `--pyargs {PACKAGE}`:
79+
80+
\b
81+
spin test --target avx2,sse4
82+
spin test --accuracy high --seed 12345
83+
spin test --collect-worst
84+
spin test --marker slow
85+
86+
Plain pytest options still go after `--`:
87+
88+
\b
89+
spin test --target sse2 -- -k trig -x
90+
"""
91+
)
92+
@click.argument("pytest_args", nargs=-1)
93+
@click.option(
94+
"--target",
95+
metavar="NAMES",
96+
help="Restrict ISA targets; comma-separated (default: every supported "
97+
"target except references).",
98+
)
99+
@click.option(
100+
"--accuracy",
101+
metavar="NAMES",
102+
help="Restrict accuracy profiles; comma-separated (default: high,low).",
103+
)
104+
@click.option(
105+
"--seed",
106+
metavar="INT",
107+
help="Root seed for every random stream (default: drawn fresh, echoed at "
108+
"the end of the run).",
109+
)
110+
@click.option(
111+
"--marker",
112+
"-m",
113+
"markers",
114+
metavar="NAMES",
115+
multiple=True,
116+
help="Opt into marker-gated tests, comma-separated or repeated: "
117+
f"{', '.join(MARKERS)}, or `all` to add them to the regular run. Naming "
118+
"one runs only its tests (default: neither, both are skipped).",
119+
)
120+
@click.option(
121+
"--collect-worst",
122+
is_flag=True,
123+
default=False,
124+
help="Fold measured inputs into the worst-case book instead of only "
125+
"replaying it as a gate.",
126+
)
127+
@click.option(
128+
"-j",
129+
"n_jobs",
130+
metavar="N_JOBS",
131+
default="1",
132+
help="Number of xdist workers; `auto` for all cores.",
133+
)
134+
@click.option(
135+
"--tests",
136+
"-t",
137+
metavar="TESTS",
138+
help=f"Module, class or function to run, e.g. {PACKAGE}.tests.test_trig.",
139+
)
140+
@click.option("--verbose", "-v", is_flag=True, default=False)
141+
@_meson.build_option
142+
@_meson.build_dir_option
143+
@click.pass_context
144+
def test(
145+
ctx,
146+
*,
147+
pytest_args,
148+
target,
149+
accuracy,
150+
seed,
151+
markers,
152+
collect_worst,
153+
n_jobs,
154+
tests,
155+
verbose,
156+
build,
157+
build_dir,
158+
):
159+
suite_args = []
160+
for name, value in (
161+
("--target", target),
162+
("--accuracy", accuracy),
163+
("--seed", seed),
164+
):
165+
if value is not None:
166+
suite_args += [name, value]
167+
if collect_worst:
168+
suite_args.append("--collect-worst")
169+
170+
markexpr = _markexpr(markers)
171+
if markexpr:
172+
if any(a == "-m" or a.startswith("-m") for a in pytest_args):
173+
raise click.UsageError(
174+
"--marker clashes with the `-m` handed to pytest after `--`; "
175+
"keep just one of them."
176+
)
177+
suite_args += ["-m", markexpr]
178+
179+
pytest_args, tests = _selection(tuple(suite_args) + pytest_args, tests, build_dir)
180+
ctx.invoke(
181+
_meson.test,
182+
pytest_args=pytest_args,
183+
n_jobs=n_jobs,
184+
tests=tests,
185+
verbose=verbose,
186+
build=build,
187+
build_dir=build_dir,
188+
)
189+
10190

11191
@click.command(help="Generate sollya c++/python based files")
12192
@click.option("-f", "--force", is_flag=True, help="Force regenerate all files")
13193
def sollya(*, force):
14194
import sollya # type: ignore[import]
15195

16196
sollya.main(force=force)
197+
198+
199+
# Markers in tools/ulp_report/index.html -> what replaces them in the bundle.
200+
# Tabulator stays a CDN <script>/<link>, so only our own css/js/data are inlined.
201+
_CSS_LINK = '<link rel="stylesheet" href="viewer.css">'
202+
_DATA_SLOT = '<script type="application/json" id="ulp-data"></script>'
203+
_SIDECAR = '<script src="../../ulp-report.js"></script>'
204+
_VIEWER = '<script src="viewer.js"></script>'
205+
206+
207+
def _replace_once(html, marker, repl):
208+
if html.count(marker) != 1:
209+
raise SystemExit(f"index.html: expected exactly one {marker!r}")
210+
return html.replace(marker, repl)
211+
212+
213+
def _bundle(json_text):
214+
viewdir = toolsdir / "ulp_report"
215+
html = (viewdir / "index.html").read_text()
216+
# "</" inside JSON strings would close the inline script tag early.
217+
data = json_text.replace("</", "<\\/")
218+
html = _replace_once(
219+
html, _CSS_LINK, f"<style>\n{(viewdir / 'viewer.css').read_text()}</style>"
220+
)
221+
html = _replace_once(
222+
html,
223+
_DATA_SLOT,
224+
f'<script type="application/json" id="ulp-data">{data}</script>',
225+
)
226+
html = _replace_once(html, _SIDECAR, "")
227+
return _replace_once(
228+
html, _VIEWER, f"<script>\n{(viewdir / 'viewer.js').read_text()}</script>"
229+
)
230+
231+
232+
@click.command(help="Bundle the ULP JSON report + viewer into one self-contained HTML")
233+
@click.argument("json_path", required=False, type=click.Path(path_type=pathlib.Path))
234+
@click.option(
235+
"-o",
236+
"--output",
237+
type=click.Path(path_type=pathlib.Path),
238+
default=pathlib.Path("ulp-report.html"),
239+
show_default=True,
240+
help="Output HTML path",
241+
)
242+
@click.option(
243+
"--open/--no-open",
244+
"show",
245+
default=True,
246+
show_default=True,
247+
help="Open the result in the default browser (--no-open for CI)",
248+
)
249+
def ulp_report(json_path, output, show):
250+
import webbrowser
251+
252+
json_path = json_path or rootdir / "ulp-report.json"
253+
if not json_path.exists():
254+
raise SystemExit(
255+
f"{json_path}: not found. Run the test suite first, or pass a report path."
256+
)
257+
json_text = json_path.read_text()
258+
output.write_text(_bundle(json_text))
259+
print(f"{output} ({output.stat().st_size:,} bytes)")
260+
# Sidecar for the live index.html over file://, where fetch() is CORS-blocked.
261+
sidecar = json_path.with_suffix(".js")
262+
sidecar.write_text(f"window.NPSR_ULP_DATA = {json_text};\n")
263+
print(f"{sidecar} ({sidecar.stat().st_size:,} bytes)")
264+
if show:
265+
webbrowser.open(output.resolve().as_uri())

meson.build

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
2+
project(
3+
'npsr',
4+
'cpp',
5+
version: '0.1.0',
6+
meson_version: '>= 1.3.0',
7+
license: 'BSD-3-Clause',
8+
default_options: [
9+
'cpp_std=c++17',
10+
'buildtype=release',
11+
],
12+
)
13+
14+
npsr_inc = include_directories('.')
15+
16+
# Builds the `numpy_sr` Python package: one foreach_target TU per operation
17+
# (python/_numpy_sr/<op>.cpp) linked into a single pybind11 extension that
18+
# exposes a submodule per runnable Highway target.
19+
if get_option('tests').disable_auto_if(meson.is_subproject()).allowed()
20+
subdir('python')
21+
endif

meson_options.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
2+
option('tests', type: 'feature', value: 'auto', description: 'Build per-operation shared libraries for the pytest harness')

npsr/lut-inl.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#define NPSR_LUT_INL_H_
66
#endif
77

8+
#include <limits>
89
#include <tuple>
910

1011
#include "npsr/hwy.h"

npsr/precise.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ class FPExceptions {
6868
#else
6969
static constexpr auto kUnderflow = 0;
7070
#endif
71+
#ifdef FE_INEXACT
72+
static constexpr auto kInexact = FE_INEXACT;
73+
#else
74+
static constexpr auto kInexact = 0;
75+
#endif
76+
// kInexact is deliberately excluded: nearly every rounded operation raises
77+
// it, so it carries no error signal for Raise()/Test() callers.
7178
static constexpr auto kAll = kInvalid | kDivByZero | kOverflow | kUnderflow;
7279

7380
void Raise(int errors) noexcept { mask_ |= errors; }

0 commit comments

Comments
 (0)