Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/pyre-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ jobs:
run: cargo fmt --all -- --check
- name: Check majit ownership boundary
run: python3 scripts/check-majit-boundary.py
- name: Check synthetic fixture headers
# `pyre/bench/synth/` is shared, so a directive `check.py` requires reds
# every fixture that arrives without one. Asked here because the pass
# reads files and builds nothing (0.2 s over 479 fixtures), while the
# synthetic suite cannot answer until the backend build it sits behind
# has finished.
run: python3 pyre/check.py --check-headers

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Install CPython 3.14 before running the header check

In the inspected cargo-fmt job, no actions/setup-python step installs CPython 3.14 before this command, so the Ubuntu 24.04 system python3 (3.12) exits while importing check.py: PYTHON3 = _resolve_python3() runs before parse_args() can handle --check-headers and rejects every non-3.14 interpreter. This makes cargo-fmt fail even for valid headers and, because the expensive jobs declare needs: cargo-fmt, blocks the rest of this workflow; set up 3.14 here or make the header-only path avoid oracle initialization.

Useful? React with 👍 / 👎.


cpyext-abi:
name: cpyext ABI
Expand Down
218 changes: 169 additions & 49 deletions pyre/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,22 @@ def _resolve_python3():
)


PYTHON3 = _resolve_python3()
_PYTHON3 = None


def python3():
"""The oracle interpreter, resolved once and remembered.

Resolved on first use rather than at import, because `_resolve_python3`
exits the process when no CPython 3.14 is on PATH -- and `--check-headers`
reads fixture files, never runs an oracle, and runs on a job that has no
reason to carry one.
"""
global _PYTHON3
if _PYTHON3 is None:
_PYTHON3 = _resolve_python3()
Comment on lines +103 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve the oracle before building normal runs

For an ordinary invocation on a host without CPython 3.14, lazy resolution now delays the deterministic configuration error until warmup or measure_startups, but main builds every selected backend first at lines 5138–5140. Previously the import-time resolution failed before any build, whereas now the user can spend minutes building—or receive an unrelated build failure—before learning that the required oracle is absent; retain the early return for --check-headers, then force python3() before starting backend builds for all normal runs.

Useful? React with 👍 / 👎.

return _PYTHON3

PYPY3 = os.environ.get("PYRE_CHECK_PYPY3") or (
"pypy3" if shutil.which("pypy3") else "pypy"
)
Expand All @@ -111,7 +126,7 @@ def _detect_pyre_stdlib():
return str(intree)
try:
proc = subprocess.run(
[PYTHON3, "-c", "import sysconfig; print(sysconfig.get_paths()['stdlib'])"],
[python3(), "-c", "import sysconfig; print(sysconfig.get_paths()['stdlib'])"],
capture_output=True, text=True, timeout=15,
)
except (OSError, subprocess.SubprocessError):
Expand All @@ -122,7 +137,20 @@ def _detect_pyre_stdlib():
return path if path and os.path.isdir(path) else None


PYRE_STDLIB = _detect_pyre_stdlib()
_UNSET = object()
_PYRE_STDLIB = _UNSET


def pyre_stdlib():
"""[`_detect_pyre_stdlib`]'s answer, computed once and remembered.

Deferred with [`python3`]: the out-of-tree fallback spawns the oracle.
"""
global _PYRE_STDLIB
if _PYRE_STDLIB is _UNSET:
_PYRE_STDLIB = _detect_pyre_stdlib()
return _PYRE_STDLIB


# Which wasm runtime the `pyre-wasm-runner` uses (`--wasm-engine`). wasmtime
# (cranelift) is fast in steady state but recompiles the ~14MB module on every
Expand Down Expand Up @@ -896,7 +924,7 @@ def pyre_env():
# `pyre_set_launch_env`.
#
# Only pyre is pinned this way. The oracles are spawned with the inherited
# environment (`run_timed([PYTHON3, script])`), so they keep writing and
# environment (`run_timed([python3(), script])`), so they keep writing and
# reading their own caches and import warm from their first run onward,
# while pyre now imports cold on every run. Startup subtraction covers the
# imports an empty program performs, not the ones a fixture adds on top, so
Expand All @@ -908,8 +936,9 @@ def pyre_env():
# Pin the vendored, `_sre.MAGIC`-matched stdlib so pyre never picks up a
# version-mismatched host `python3` off the PATH. An explicit PYRE_STDLIB
# in the environment wins.
if PYRE_STDLIB and "PYRE_STDLIB" not in env:
env["PYRE_STDLIB"] = PYRE_STDLIB
stdlib = pyre_stdlib()
if stdlib and "PYRE_STDLIB" not in env:
env["PYRE_STDLIB"] = stdlib
# Point the wasm runner at the built module by absolute path so it resolves
# regardless of the child's working directory (ignored by other backends).
if "PYRE_WASM_MODULE" not in env and Path(WASM_MODULE_PATH).exists():
Expand Down Expand Up @@ -2111,6 +2140,99 @@ def synth_spec_folds(path):
return names


def synth_fixture_headers(path):
"""Every directive `path` owes, read in one pass.

Which of the readers above apply depends on the fixture's kind, so that
rule lives here rather than being restated at each consumer. Raises
`ValueError` on this fixture's first unusable directive.
"""
selfcheck = synth_selfcheck(path)
headers = {
"selfcheck": selfcheck,
"skip_backends": synth_skip_backends(path),
"spec_folds": synth_spec_folds(path),
}
if selfcheck:
interpreted = synth_selfcheck_interpreted(path)
headers["interpreted"] = interpreted
headers["want_compiles"] = (
() if interpreted else synth_selfcheck_compiles(path)
)
else:
headers["max_pypy_ratio"] = synth_perf_gate(path)
headers["max_rss_mb"] = synth_rss_gate(path)
headers["skip_cpython"] = synth_skip_cpython(path)
headers["no_cpython"] = synth_no_cpython(path)
# `_apply_snapshot_gate` reads these two per backend and nothing there
# catches a `ValueError`, so an unusable value in either arrived as a
# traceback out of a backend run. Read here for the raise alone; the
# gate still reads them itself, once it knows which backend it is.
synth_ungated_jitstats(path)
synth_jitstats_bands(path)
Comment on lines +2171 to +2172

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the wasm ratio directive in the header pass

When a non-selfcheck fixture has a malformed # pyre-check: max-wasm-ratio= value (for example, nan), this new prepass reports success because synth_fixture_headers never calls wasm_ratio_gate. That directive is first parsed only from the wasm performance path after the backend build and fixture execution, so the cheap cargo-fmt gate misses exactly this class of unusable header and the expensive wasm job can still fail later; validate it here alongside the other per-backend directives.

AGENTS.md reference: AGENTS.md:L76-L87

Useful? React with 👍 / 👎.

return headers


def report_unusable_headers(errors):
"""Name every fixture whose header could not be read, not just the first.

`pyre/bench/synth/` is shared, so a directive this script requires reds
every fixture that arrives without it, and stopping at the first one costs
a CI run per name. Fixtures that fail the same way share a block, so the
guidance the reader wrote is printed once instead of once per fixture.
"""
groups = {}
for path, message in errors:
groups.setdefault(message.replace(str(path), "<fixture>"), []).append(path)
print(f"{red('ERROR')}: {len(errors)} fixture(s) with a header this suite cannot read")
for message, paths in groups.items():
print()
for path in paths:
print(f" {path}")
for line in message.splitlines():
print(f" {line}")


def synthetic_bench_paths(pattern):
"""The fixtures *pattern* names under [`SYNTHETIC_BENCH_DIR`]."""
paths = sorted(Path(SYNTHETIC_BENCH_DIR).glob(pattern))
if not paths and not Path(pattern).suffix:
paths = sorted(Path(SYNTHETIC_BENCH_DIR).glob(f"{pattern}.py"))
return [p for p in paths if p.is_file() and p.suffix == ".py"]


def read_synthetic_headers(paths):
"""`({path: headers}, [(path, message)])` -- the readable and the rest."""
headers, unusable = {}, []
for path in paths:
try:
headers[path] = synth_fixture_headers(path)
except ValueError as e:
unusable.append((path, str(e)))
return headers, unusable


def check_synthetic_headers(pattern):
"""`--check-headers`: read every fixture header and report, building nothing.

This pass touches files and nothing else -- no binary, no reference
interpreter, no cargo -- so it belongs beside `cargo fmt --check`, which
every expensive job in the workflow already waits on. Run from the suite it
cannot answer until the build it sits behind has finished, and an authoring
error costs a whole CI run to hear about. Returns a process exit status.
"""
paths = synthetic_bench_paths(pattern)
if not paths:
print(f"{red('ERROR')}: no synthetic benchmarks matched {pattern!r}")
return 1
unusable = read_synthetic_headers(paths)[1]
if unusable:
report_unusable_headers(unusable)
return 1
print(f"{len(paths)} synthetic fixture header(s) read, pattern={pattern!r}")
return 0


# `[spec-census] fold=<label> consulted=N fired=N suppressed=N site=... parent=...`
SPEC_CENSUS_FOLD_RE = re.compile(r"^\[spec-census\] fold=(\S+) .*?\bfired=(\d+)\b", re.M)

Expand Down Expand Up @@ -2971,7 +3093,7 @@ def _sample_startups(self):
empty_path = handle.name # zero-length program
try:
targets = [
("cpython", [PYTHON3, empty_path], None),
("cpython", [python3(), empty_path], None),
("pypy", [PYPY3, empty_path], None),
]
for backend in ALL_BACKENDS:
Expand Down Expand Up @@ -3593,7 +3715,7 @@ def _run_diag(cmd):
def warmup(self, script):
sys.stdout.write(f" {'warmup':<10s}")
sys.stdout.flush()
for runner in [PYTHON3, PYPY3]:
for runner in [python3(), PYPY3]:
try:
subprocess.run(
[runner, script],
Expand Down Expand Up @@ -3991,7 +4113,7 @@ def _ratio(elapsed_val, pypy_val):
if vs_cpython and t_cpython not in (None, "-"):
passed, bound, checked_elapsed, checked_baseline, retry_note = self._performance_gate_passed(
backend, script, timeout, elapsed, vs_cpython, float(t_cpython),
[PYTHON3, script], pypy_output, "cpython",
[python3(), script], pypy_output, "cpython",
)
if not passed:
detail = self._gate_fail_detail(
Expand Down Expand Up @@ -4201,7 +4323,7 @@ def run_bench(
if need_cpython:
sys.stdout.write(f" {'cpython':<10s}")
sys.stdout.flush()
cpython_output, t_cpu, cpython_code, _ = run_timed([PYTHON3, script])
cpython_output, t_cpu, cpython_code, _ = run_timed([python3(), script])
t_cpython = t_cpu
if cpython_code != 0:
print(f"{red('CRASH')} (exit {cpython_code})")
Expand Down Expand Up @@ -4397,7 +4519,7 @@ def run_cpython_suite(self):
sys.stdout.flush()
output, elapsed, code, stderr = run_timed(
[
PYTHON3, str(Path(__file__).parent / "cpython_tests" / "run.py"),
python3(), str(Path(__file__).parent / "cpython_tests" / "run.py"),
"--binary", str(self._pyre(backend)),
"--baseline", str(CPYTHON_SUITE_BASELINE),
"--jobs", str(max(1, (os.cpu_count() or 4) - 1)),
Expand Down Expand Up @@ -4499,20 +4621,16 @@ def _check_spec_folds(self, name, path, spec_folds, timeout, t_cpython, t_pypy):
self._append_comparison(b, name, t_cpython, t_pypy, "FAIL")
return False

def run_synthetic_bench(self, path, timeout):
def run_synthetic_bench(self, path, timeout, headers):
self.maybe_refresh_startups()
name = f"synth/{Path(path).stem}"
effective_timeout = scaled_timeout(timeout, self.args.timeout_scale)
try:
max_pypy_ratio = synth_perf_gate(path)
max_rss_mb = synth_rss_gate(path)
skip_backends = synth_skip_backends(path)
skip_cpython = synth_skip_cpython(path)
no_cpython = synth_no_cpython(path)
spec_folds = synth_spec_folds(path)
except ValueError as e:
print(f"{red('ERROR')}: {e}")
sys.exit(1)
max_pypy_ratio = headers["max_pypy_ratio"]
max_rss_mb = headers["max_rss_mb"]
skip_backends = headers["skip_backends"]
skip_cpython = headers["skip_cpython"]
no_cpython = headers["no_cpython"]
spec_folds = headers["spec_folds"]

print(f" {name}")

Expand All @@ -4535,7 +4653,7 @@ def run_synthetic_bench(self, path, timeout):
self.cpython_declared_skips.append(name)
else:
cpython_output, cpython_time, cpython_code, _ = run_timed(
[PYTHON3, path], timeout_s=SYNTHETIC_CPYTHON_REFERENCE_TIMEOUT_S,
[python3(), path], timeout_s=SYNTHETIC_CPYTHON_REFERENCE_TIMEOUT_S,
)
if cpython_code == 124:
# Not a crash: the fixture is sized for the JITs and cpython is
Expand Down Expand Up @@ -4612,46 +4730,38 @@ def run_synthetic_bench(self, path, timeout):

def run_synthetic_suite(self):
pattern = self.args.synthetic_pattern
paths = sorted(Path(SYNTHETIC_BENCH_DIR).glob(pattern))
if not paths and not Path(pattern).suffix:
paths = sorted(Path(SYNTHETIC_BENCH_DIR).glob(f"{pattern}.py"))
paths = [p for p in paths if p.is_file() and p.suffix == ".py"]
paths = synthetic_bench_paths(pattern)
if not paths:
print(f"{red('ERROR')}: no synthetic benchmarks matched {pattern!r}")
sys.exit(1)

print(bold("synthetic parity suite"))
print(dim(f"{len(paths)} benchmark(s), pattern={pattern!r}"))
# Read every fixture's header before running any of them: a missing or
# malformed directive is an authoring error, and it is reported with
# the others of its kind rather than one per run. `--check-headers`
# runs this same pass with nothing built, so ordinarily it has already
# answered by the time the suite reaches here.
headers, unusable = read_synthetic_headers(paths)
if unusable:
report_unusable_headers(unusable)
sys.exit(1)

for path in paths:
try:
selfcheck = synth_selfcheck(path)
skip_backends = synth_skip_backends(path) if selfcheck else ()
# Every header a selfcheck fixture owes, read inside the same
# try: a missing or malformed directive is an authoring error
# to report by name, not a traceback out of the suite loop.
selfcheck_folds = synth_spec_folds(path) if selfcheck else ()
interpreted = selfcheck and synth_selfcheck_interpreted(path)
want_compiles = (
synth_selfcheck_compiles(path)
if selfcheck and not interpreted
else ()
)
except ValueError as e:
print(f"{red('ERROR')}: {e}")
sys.exit(1)
if selfcheck:
header = headers[path]
if header["selfcheck"]:
self.run_selfcheck(
f"synth/{path.stem}",
str(path),
self.args.synthetic_timeout,
skip_backends=skip_backends,
require_jit=not interpreted,
spec_folds=selfcheck_folds,
want_compiles=want_compiles,
skip_backends=header["skip_backends"],
require_jit=not header["interpreted"],
spec_folds=header["spec_folds"],
want_compiles=header["want_compiles"],
)
else:
self.run_synthetic_bench(
str(path), self.args.synthetic_timeout,
str(path), self.args.synthetic_timeout, header,
)
# A fixture that loses its cpython reference also loses the
# cpython/pypy output cross-check, so the count belongs in the summary
Expand Down Expand Up @@ -4960,8 +5070,18 @@ def parse_backend_specs(specs):
default=20.0,
help="per-script timeout in seconds for synthetic benchmarks",
)
parser.add_argument(
"--check-headers",
action="store_true",
help="read every synthetic fixture's `# pyre-check:` header, report the "
"unusable ones and exit; builds and runs nothing",
)
parser.add_argument("pyre_path", nargs="?", default="")
args = parser.parse_args()
# Answered here, ahead of the backend resolution below: the check is over
# the fixture files, and nothing about it needs a backend to exist.
if args.check_headers:
sys.exit(check_synthetic_headers(args.synthetic_pattern))
try:
args.backends = parse_backend_specs(args.backend)
except argparse.ArgumentTypeError as e:
Expand Down
25 changes: 14 additions & 11 deletions pyre/pyre-interpreter/src/opcode_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1149,20 +1149,23 @@ pub extern "C" fn bh_lookup_exc_class_for_kind(kind_disc: i64) -> i64 {
}

/// C-ABI residual bridge for `exc_kind_discriminant`: the caught exception
/// value rides in as a `PyObjectRef`; its `kind` discriminant rides back as `i64`.
#[allow(improper_ctypes_definitions)]
pub extern "C" fn bh_w_exception_get_kind(evalue: pyre_object::PyObjectRef) -> i64 {
pyre_object::interp_exceptions::exc_kind_discriminant(evalue)
/// value rides in through the integer arg slot a residual call supplies, so
/// take it as `i64` here; its `kind` discriminant rides back as `i64`.
///
/// `PyObjectRef` is `*mut PyObject`, which is four bytes on wasm32 and eight
/// on the native targets. Spelling the parameter as the pointer would declare
/// an `i32` argument there while the emitted `call_indirect` supplies a word,
/// and the mismatch traps.
pub extern "C" fn bh_w_exception_get_kind(evalue: i64) -> i64 {
pyre_object::interp_exceptions::exc_kind_discriminant(evalue as pyre_object::PyObjectRef)
}

/// C-ABI residual bridge for `exception_object_matches_stop_iteration`: the
/// caught exception value rides in as a `PyObjectRef`; its boolean result rides
/// back in the integer result slot.
#[allow(improper_ctypes_definitions)]
pub extern "C" fn bh_exception_object_matches_stop_iteration(
evalue: pyre_object::PyObjectRef,
) -> i64 {
crate::error::exception_object_matches_stop_iteration(evalue) as i64
/// caught exception value rides in through the integer arg slot, the
/// [`bh_w_exception_get_kind`] twin; its boolean result rides back in the
/// integer result slot.
pub extern "C" fn bh_exception_object_matches_stop_iteration(evalue: i64) -> i64 {
crate::error::exception_object_matches_stop_iteration(evalue as pyre_object::PyObjectRef) as i64
}

#[cfg(test)]
Expand Down
Loading