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
96 changes: 96 additions & 0 deletions pyre/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import difflib
import math
import os
import platform
import re
import shutil
import statistics
import struct
Expand Down Expand Up @@ -143,6 +145,17 @@ def _detect_pyre_stdlib():

BENCH_DIR = "pyre/bench"
SYNTHETIC_BENCH_DIR = "pyre/bench/synth"
CPYTHON_SUITE_BASELINE = "pyre/cpython_tests/baseline.json"
# The baseline holds one verdict per module per backend, and dynasm emits
# arch-specific code, so a verdict only compares against the host it was
# observed on. `.github/workflows/pyre-ci.yml` pins its CPython suite job to
# `macos-latest` for the same reason.
CPYTHON_SUITE_BASELINE_HOST = ("darwin", "arm64")
# Per module, matching the CI job. `test.test_asyncio` alone needs ~2m47s of
# wall time, so a smaller per-module limit turns a slow module into a fake
# regression.
CPYTHON_SUITE_MODULE_TIMEOUT_S = 300
CPYTHON_SUITE_TIMEOUT_S = 2400
SNAP_DIR = "pyre/check.snap"
BENCH_COMPARE_BUFFER_S = 0.005
# Windows process CPU accounting advances in scheduler ticks (normally 1/64 s).
Expand Down Expand Up @@ -2374,6 +2387,79 @@ def run_selfcheck(self, name, script, timeout, expect="PASS", skip_backends=()):
print(f"{green('PASS')} {elapsed:.2f}s")
self._append_comparison(backend, name, "-", "-", f"{elapsed:.2f}s")

# ── vendored CPython suite ──

def run_cpython_suite(self):
"""Gate the vendored CPython test suite against its recorded baseline.

Every other stage in this file runs code we wrote, so it can only
catch a defect in a shape somebody already thought of. This one runs
CPython's own tests, and it is the stage that reports a JIT miscompile
nobody wrote a fixture for: `test.test_datetime` failed on a
specialised-pair subscript fold while the whole synthetic corpus and
every parity fixture stayed green.

The baseline records one verdict per module per backend, observed on
darwin-arm64, and dynasm's codegen is arch-specific -- so the
comparison only means anything there, which is also why the CI job
pins `runs-on: macos-latest`. On any other host the stage reports
that it did not run instead of counting as a pass.
"""
name = "cpython-suite"
backend = "dynasm"
print(f" {name}")
if not self.enabled(backend):
sys.stdout.write(f" {backend:<10s}")
print(dim("skip (backend not enabled)"))
self._append_comparison(backend, name, "-", "-", "skip")
return
host = (sys.platform, platform.machine())
if host != CPYTHON_SUITE_BASELINE_HOST:
sys.stdout.write(f" {backend:<10s}")
print(dim(
f"skip (baseline observed on {'-'.join(CPYTHON_SUITE_BASELINE_HOST)}, "
f"host is {'-'.join(host)})"
))
self._append_comparison(backend, name, "-", "-", "skip")
return
sys.stdout.write(f" {backend:<10s}")
sys.stdout.flush()
output, elapsed, code, stderr = run_timed(
[
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)),
"--timeout", str(CPYTHON_SUITE_MODULE_TIMEOUT_S),
],
timeout_s=CPYTHON_SUITE_TIMEOUT_S,
env=pyre_env(),
)
# `N to run,` rather than the runner's own "no regressions": a
# selection that came out empty prints every counter as zero and
# still exits 0, which reads as a pass and tests nothing.
selected = re.search(r"^(\d+) to run,", output, re.M)
if code == 124:
detail = f"timeout (>{CPYTHON_SUITE_TIMEOUT_S}s)"
elif selected is None:
detail = "runner printed no selection line"
elif selected.group(1) == "0":
detail = "selected 0 modules"
elif code != 0:
regressions = re.findall(r"^ - (\S+): (.*)$", output, re.M)
detail = "; ".join(f"{m} {d}" for m, d in regressions) or f"exit {code}"
else:
detail = ""
if detail:
self._record(backend, False, name, detail)
print(f"{red('FAIL')} {detail}")
_dump_failed_run(output, stderr)
self._append_comparison(backend, name, "-", "-", "FAIL")
return
self._record(backend, True, name, "")
print(f"{green('PASS')} {selected.group(1)} modules, {elapsed:.0f}s")
self._append_comparison(backend, name, "-", "-", f"{elapsed:.0f}s")

# ── synthetic parity suite ──

def run_synthetic_bench(self, path, timeout):
Expand Down Expand Up @@ -2706,6 +2792,11 @@ def parse_backend_specs(specs):
action="store_true",
help="skip pyre/bench/synth feature-parity benchmarks",
)
parser.add_argument(
"--no-cpython-suite",
action="store_true",
help="skip the vendored CPython suite gate (pyre/cpython_tests)",
)
parser.add_argument(
"--synthetic-only",
action="store_true",
Expand Down Expand Up @@ -2889,6 +2980,11 @@ def main():
print()
chk.run_synthetic_suite()

if not args.no_cpython_suite and not args.synthetic_only:
print()
print(bold("vendored CPython suite"))
chk.run_cpython_suite()
Comment on lines +2983 to +2986

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 Run the CPython gate only once on macOS CI

In the inspected .github/workflows/pyre-ci.yml, pyre-check-macos reuses the shared step that invokes default pyre/check.py, while the separate cpython-tests job already runs pyre/cpython_tests/run.py with the same dynasm baseline. Making this stage unconditional by default on darwin-arm64 therefore executes the roughly five-minute, 104-module gate twice on every CI run, doubling the expensive macOS work without adding coverage; disable it in one lane or remove the redundant standalone job.

Useful? React with 👍 / 👎.


rc = chk.print_summary()
sys.exit(rc)

Expand Down
82 changes: 82 additions & 0 deletions pyre/extra_tests/parity_tests/inline_callee_locals_across_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Locals of an inlined callee must survive a guard that fails inside the
# callee's own body. A callee's `LOAD_FAST`/`STORE_FAST` lower to
# `getarrayitem_vable_*`/`setarrayitem_vable_*` on its own frame array, and the
# split between the `locals_cells_stack_w` prefix and the operand-stack region
# is that frame's own local count. Both shapes are covered: a callee with MORE
# locals than its caller (a `STORE_FAST` mistaken for an operand-stack push
# folds away, and the resumed frame reads the slot back as NULL) and one with
# FEWER (an operand-stack cell mistaken for a local).
#
# The guard is an attribute read off a receiver whose class alternates between
# rounds, so every round after the first resumes inside the callee.

N = 400
ROUNDS = 60


class Shape:
def __init__(self, v):
self.v = v


class Other:
def __init__(self, v):
self.v = v


def sink(a, b, c):
return (a * 7 + b * 3 + c) % 1000003


def wide_callee(o, k):
# Six locals — more than `narrow_driver` has. `t0`/`t2` are stored before
# the guarded `o.v` read and consumed after it.
t0 = k * 2 + 1
t1 = t0 + 5
t2 = t1 * 3
v = o.v
return sink(t0, t2, v)


def narrow_driver(n, o):
acc = 0
for i in range(n):
acc = (acc + wide_callee(o, i)) % 1000003
return acc


def narrow_callee(o):
# Two locals — fewer than `wide_driver` has.
v = o.v
return (v, v + 1, v + 2)


def wide_driver(n, o):
a = 0
b = 1
c = 2
d = 3
e = 4
f = 5
g = 6
acc = 0
for i in range(n):
x, y, z = narrow_callee(o)
acc = (acc + x + y + z + a + b + c + d + e + f + g + i) % 1000003
return acc


# sum over i in 0..N-1 of (32 * i + 72), taken mod 1000003
NARROW_EXPECTED = (32 * (N * (N - 1) // 2) + 72 * N) % 1000003
# per iteration: (11 + 12 + 13) + (0 + 1 + ... + 6) + i
WIDE_EXPECTED = ((36 + 21) * N + N * (N - 1) // 2) % 1000003

warm = Shape(11)
flip = Other(11)

for round_ in range(ROUNDS):
for receiver in (warm, flip):
assert narrow_driver(N, receiver) == NARROW_EXPECTED
assert wide_driver(N, receiver) == WIDE_EXPECTED

print("OK")
32 changes: 25 additions & 7 deletions pyre/extra_tests/parity_tests/iterator_setstate_python314.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"""

import array
import sys


def check(condition, message):
Expand All @@ -22,6 +23,13 @@ def state(it):
return reduced[2] if len(reduced) > 2 else "EXHAUSTED"


def restored(make, index):
"""The cursor `__setstate__(index)` leaves on a fresh iterator."""
it = make()
it.__setstate__(index)
return state(it)


def remaining(it):
"""`__length_hint__`, which `array.arrayiterator` alone does not declare."""
return it.__length_hint__() if hasattr(it, "__length_hint__") else None
Expand Down Expand Up @@ -162,13 +170,23 @@ class MyInt(int):
label + " rejects a non-integer state: " + str(exc))
else:
raise AssertionError(label + " accepted a non-integer state")
try:
make().__setstate__(1 << 100)
except OverflowError as exc:
check(str(exc) == "Python int too large to convert to C ssize_t",
label + " overflow message: " + str(exc))
else:
raise AssertionError(label + " accepted an oversized state")
for oversized in (1 << 100, sys.maxsize + 1, -sys.maxsize - 2):
try:
make().__setstate__(oversized)
except OverflowError as exc:
check(str(exc) == "Python int too large to convert to C ssize_t",
label + " overflow message: " + str(exc))
else:
raise AssertionError(label + " accepted an oversized state")
# The two extremes that do fit are accepted, and land where any other value
# of their sign lands: the cursor is what gets clamped, not the width of the
# incoming int. The generic iterator is the exception on the positive side,
# having no length to clamp against (`seq_iter_clamp_length`).
check(restored(make, -sys.maxsize - 1) == restored(make, -1),
label + " accepts the smallest cursor")
check(restored(make, sys.maxsize)
== (sys.maxsize if label == "generic" else restored(make, 99)),
label + " accepts the largest cursor")
it = make()
it.__setstate__(MyInt(1))
check(state(it) == 1, label + " accepts an int subclass")
Expand Down
47 changes: 47 additions & 0 deletions pyre/extra_tests/parity_tests/nested_inline_caller_lineno.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# The line number a frame reports while a residual runs inside a call chain
# that was inlined more than one level deep.
#
# A residual executed inside an inlined callee temporarily publishes
# `last_instr` onto the outer traced frame, so that a frame reader running
# during the call (`sys._getframe().f_lineno`, a warning's registry key, a
# traceback) sees the line the call was made from rather than whatever the last
# resume point left behind. That coordinate is derived from the CALL
# instruction's JitCode offset, which indexes the outer frame's JitCode only
# while the walk is that frame's own. One level down, the offset belongs to the
# intermediate callee, and mapping it through the outer frame's pc tables names
# whatever line that byte happens to land on — here the `def` line's body start
# instead of the call.
#
# `driver` runs the traced loop, inlines `mid`, which inlines `leaf`; the
# `sys._getframe` residual inside `leaf` is what publishes the coordinate. The
# loop collects every line `driver` reports across the run, so a single wrong
# iteration is caught: the set must hold exactly the one call line.

import sys

N = 3000


def leaf(k):
# Frame depths: 0 = leaf, 1 = mid, 2 = driver.
return sys._getframe(2).f_lineno


def mid(k):
return leaf(k)


def driver(n):
seen = set()
i = 0
while i < n:
seen.add(mid(i)) # <-- the only line `driver` may ever report
i += 1
return sorted(seen)


CALL_LINE = driver.__code__.co_firstlineno + 4
observed = driver(N)
assert observed == [CALL_LINE], (CALL_LINE, observed)

print("OK")
5 changes: 4 additions & 1 deletion pyre/pyre-jit-trace/src/jitcode_dispatch/bridge_subwalk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1223,7 +1223,10 @@ pub(crate) fn drive_bridge_frame_subwalk<Sym: WalkSym>(

let outcome = {
let mut sub_wc = WalkContext {
callee_shadow: Some(Default::default()),
callee_shadow: Some(super::CalleeLocalsShadow {
code_ptr: callee_pjc.code_ptr,
..Default::default()
}),
inline_callee_consts: Some(consts),
fbw_mode: FbwWalkMode {
snapshot_sym: root_sym_ptr,
Expand Down
27 changes: 23 additions & 4 deletions pyre/pyre-jit-trace/src/jitcode_dispatch/diag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,20 +147,39 @@ pub(crate) fn inflight_foriter_body_pc(body: InflightForiterBody) -> Option<usiz
match body {
InflightForiterBody::Py(body_pc) => Some(body_pc),
InflightForiterBody::Jit {
outer_jitcode_index,
jitcode_index,
op_pc,
} => crate::state::pyjitcode_for_jitcode_index(outer_jitcode_index as i32).map(|jc| {
} => crate::state::pyjitcode_for_jitcode_index(jitcode_index).map(|jc| {
crate::py_coord::containing_py_pc_for_jitcode_pc(&jc.metadata, op_pc) as usize + 1
Comment on lines +152 to 153

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 Preserve the frame identity through FOR_ITER delivery

When an inlined callee's FOR_ITER body has the same numeric Python offset as a loop in the outer live frame, this conversion discards jitcode_index and returns only that offset. The downstream stash matching in fbw_state.rs consequently aliases the two loops, and deliver_inflight_foriter_item validates and pushes the callee's item against the outer frame, allowing a guard abort to resume the caller with the wrong item/stack. Keep the JitCode/frame identity through matching and delivery, or refuse legacy delivery for a non-root frame.

AGENTS.md reference: AGENTS.md:L32-L42

Useful? React with 👍 / 👎.

}),
}
}

/// Capture the native coordinates that identify a `for_iter_next` residual.
/// The Python continue-arm fallthrough is intentionally not derived here.
///
/// `op_pc` is an offset into the JitCode the walk is currently executing, so
/// the identity paired with it must be that JitCode's. Inside an inline
/// sub-walk that is the callee's ([`InlineCalleeConsts::jitcode_index`], the
/// same resolution `build_multi_frame_miframe` applies to its innermost
/// frame); `fbw_mode.snapshot_sym` still names the outer portal. Pairing the
/// portal with a callee offset invents a coordinate: `inflight_foriter_body_pc`
/// resolves it through the CALLER's pc tables and answers with a Python pc that
/// belongs to neither loop, so a callee loop's item is stashed under an
/// identity no resume coordinate can match, and a caller loop that happens to
/// resolve to the same pc has its own in-flight entry truncated away and
/// replaced by the callee's item.
pub(crate) fn fbw_foriter_body_from_op_pc<Sym: WalkSym>(
snapshot_sym: *const Sym,
ctx: &WalkContext<'_, '_, Sym>,
op_pc: usize,
) -> Option<InflightForiterBody> {
if let Some(consts) = ctx.inline_callee_consts {
return Some(InflightForiterBody::Jit {
jitcode_index: consts.jitcode_index,
op_pc,
});
}
let snapshot_sym = ctx.fbw_mode.snapshot_sym;
if snapshot_sym.is_null() {
return None;
}
Expand All @@ -171,7 +190,7 @@ pub(crate) fn fbw_foriter_body_from_op_pc<Sym: WalkSym>(
return None;
}
Some(InflightForiterBody::Jit {
outer_jitcode_index: unsafe { (*sym.jitcode()).index as u32 },
jitcode_index: unsafe { (*sym.jitcode()).index as i32 },
op_pc,
})
}
Expand Down
Loading
Loading