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
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# CPython-suite gap: recursion tests do not compare cold and JIT-hot call depth.
# parity-tests reason: this guards pyre's native budget across JIT guard resume.

"""`sys.setrecursionlimit` must keep deciding how deep recursion goes after
the recursive function is JIT-hot.

The recursion depth is bounded twice: by the logical limit, and by a native
byte budget (`stack_check.rs` `MAX_STACK_SIZE`, scaled by `recursionlimit /
1000`). The budget is meant to sit above the limit's own cutoff so the limit is
what a program observes. A level that runs as compiled code and leaves it
through a guard failure nests the whole resume chain on the native stack and
costs several times what an interpreted level costs, so a budget calibrated
against the interpreter alone drops below the limit as soon as the function
goes hot: the same function measured five times in a row returned 997, 997,
997, 997, then 458.

Reading the same depth every round is the property; the absolute number is not
asserted, only that it is the limit and not the guard that stopped the
recursion.
"""

import sys
import threading


def deepest(limit_probe=None):
"""Recurse until RecursionError and report the depth reached."""
best = [0]

def plain(n):
best[0] = n
plain(n + 1)

try:
plain(0)
except RecursionError:
pass
return best[0]


def readings(rounds=12):
# A fresh code object per call would warm up separately; the point is that
# one code object stays stable across repeats, so `deepest` is shared.
return [deepest() for _ in range(rounds)]


def check(where, values, limit):
assert len(set(values)) == 1, (where, "depth moved across repeats", values)
reached = values[0]
assert reached <= limit, (where, "deeper than the limit", reached, limit)
assert reached >= limit * 9 // 10, (
where,
"stopped well short of the limit, so the byte guard fired first",
reached,
limit,
)


limit = sys.getrecursionlimit()
check("default limit", readings(), limit)

sys.setrecursionlimit(2000)
check("raised limit", readings(rounds=6), 2000)
sys.setrecursionlimit(limit)

# A thread gets its own native stack, and its own chance to be sized too small
# for the limit it is handed.
failure = []


def in_thread():
try:
check("thread", readings(), sys.getrecursionlimit())
except AssertionError as exc: # surface it on the main thread
failure.append(exc)


t = threading.Thread(target=in_thread)
t.start()
t.join()
assert not failure, failure[0]

print("OK")
45 changes: 40 additions & 5 deletions pyre/pyre-interpreter/src/builtins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2822,7 +2822,7 @@ pub fn install_default_builtins(ns: PyObjectRef) {
// keyword is rejected with "takes no keyword arguments".
crate::gateway::make_module_builtin_function_with_arity_and_sig(
"len",
builtin_len,
__pyre_wrap_builtin_len,
1,
crate::gateway::Signature::new(vec!["obj"], None, None, 0, 1),
)
Expand Down Expand Up @@ -4321,7 +4321,7 @@ pub(crate) fn builtin_range(args: &[PyObjectRef]) -> Result<PyObjectRef, crate::
}

/// True iff `callable` is the builtin `len` function object — a
/// builtin-code function whose code wraps [`builtin_len`]. The JIT
/// builtin-code function whose code wraps [`__pyre_wrap_builtin_len`]. The JIT
/// walker uses this to recognize a `len(x)` residual it can lower to the
/// container's inline length read.
pub fn is_builtin_len_function(callable: PyObjectRef) -> bool {
Expand All @@ -4335,7 +4335,7 @@ pub fn is_builtin_len_function(callable: PyObjectRef) -> bool {
}
crate::gateway::builtin_code_fn_eq(
crate::gateway::builtin_code_get(code),
builtin_len as crate::gateway::BuiltinCodeFn,
__pyre_wrap_builtin_len as crate::gateway::BuiltinCodeFn,
)
}
}
Expand Down Expand Up @@ -4474,6 +4474,32 @@ fn builtin_len(args: &[PyObjectRef]) -> Result<PyObjectRef, crate::PyError> {
Ok(w_int_new(crate::baseobjspace::len_w(obj)?))
}

/// Manual interp2app gateway for `len`.
///
/// `#[pyre_methods]` emits this same wrapper/descriptor pair automatically:
/// the wrapper reads the arity and each positional slot out of the slice before
/// entering the typed body. The gateway descent walker keys its heap-cache
/// entries for the args array off that element read, so this hand-written
/// wrapper must keep the same shape.
/// Builtins installed by hand must publish the equivalent `BuiltinCode.func`
/// PBC member so source translation can discover and codewrite its graph.
pub fn __pyre_wrap_builtin_len(args: &[PyObjectRef]) -> Result<PyObjectRef, crate::PyError> {
if args.len() != 1 {
return builtin_len(args);
}
let obj = args[0];
Ok(w_int_new(crate::baseobjspace::len_w(obj)?))
}

#[cfg(not(target_arch = "wasm32"))]
#[linkme::distributed_slice(crate::gateway::BUILTIN_WRAPPER_DESCRIPTORS)]
#[allow(non_upper_case_globals)]
static __pyre_wrap_builtin_len_target: crate::gateway::BuiltinWrapperDescriptor =
crate::gateway::BuiltinWrapperDescriptor {
path: concat!(module_path!(), "::", "__pyre_wrap_builtin_len"),
func: __pyre_wrap_builtin_len,
};

/// `abs(x)` — return the absolute value of a number.
pub fn builtin_abs(args: &[PyObjectRef]) -> Result<PyObjectRef, crate::PyError> {
// operation.py `abs(space, w_val)`. The gateway Signature binds the keyword
Expand Down Expand Up @@ -5898,11 +5924,20 @@ fn builtin_isinstance(args: &[PyObjectRef]) -> Result<PyObjectRef, crate::PyErro

/// Manual interp2app gateway for `isinstance`.
///
/// `#[pyre_methods]` emits this same wrapper/descriptor pair automatically.
/// `#[pyre_methods]` emits this same wrapper/descriptor pair automatically:
/// the wrapper reads the arity and each positional slot out of the slice before
/// entering the typed body. The gateway descent walker keys its heap-cache
/// entries for the args array off that element read, so this hand-written
/// wrapper must keep the same shape.
/// Builtins installed by hand must publish the equivalent `BuiltinCode.func`
/// PBC member so source translation can discover and codewrite its graph.
pub fn __pyre_wrap_builtin_isinstance(args: &[PyObjectRef]) -> Result<PyObjectRef, crate::PyError> {
builtin_isinstance(args)
if args.len() != 2 {
return builtin_isinstance(args);
}
let obj = args[0];
let cls = args[1];
Ok(w_bool_from(crate::baseobjspace::isinstance(obj, cls)?))
}

#[cfg(not(target_arch = "wasm32"))]
Expand Down
117 changes: 98 additions & 19 deletions pyre/pyre-interpreter/src/stack_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,29 @@ pub use crate::module::sys::state::{DEFAULT_RECURSION_LIMIT, MAX_RECURSION_LIMIT
///
/// stack.h picks the constant per architecture for exactly one reason:
/// 768 KB "is only enough for 406 levels on ppc64", so platforms whose
/// frames are bigger get the larger budget instead. Rust interpreter
/// frames are bigger than RPython's translated-C frames in the same way
/// — 768 KB bottoms out around 476 Python call levels here — so pyre
/// applies the same rule and takes `11 << 18` on the platforms that
/// have room for it. That keeps the byte budget above the recursion
/// limit's own cutoff, so `sys.setrecursionlimit(N)` is what bounds
/// Python call depth and the byte budget stays the hard-stack guard it
/// is upstream.
/// frames are bigger get the larger budget instead. pyre applies the
/// same rule to its own frames. The budget is scaled by `recursionlimit
/// / 1000` ([`pyre_stack_set_length_fraction`]), so this constant is
/// exactly "bytes for 1000 Python call levels" and has to cover the
/// most expensive level pyre can produce.
///
/// A level the interpreter runs costs ~1.7 KB of native stack
/// (`funccall_valuestack` → `OpcodeStepExecutor::call` → `eval_loop_jit`
/// → `eval_with_jit`). A level that enters compiled code and leaves it
/// through a guard failure costs ~6.3 KB, because the resume chain
/// nests on the native stack once per level:
/// `call_assembler_helper_trampoline` → `jit_blackhole_resume_from_guard`
/// → `blackhole_resume_via_rd_numb` → `BlackholeInterpreter::run` →
/// `handler_residual_call_r_r` → `bh_call_fn`. `48 << 18` is twice what
/// 1000 such levels need, which keeps the byte budget above the
/// recursion limit's own cutoff: `sys.setrecursionlimit(N)` is what
/// bounds Python call depth and the byte budget stays the hard-stack
/// guard it is upstream.
///
/// Sized against the interpreter alone (`11 << 18`), the guard fired at
/// 458 levels as soon as a recursive function went hot — under half the
/// default limit of 1000, and reached by nothing more than calling the
/// same recursive function five times.
///
/// `wasm32` keeps `3 << 18`: its linear-memory stack is sized at link
/// time (1 MB by default) with no guard page and no `getrlimit` for
Expand All @@ -71,14 +86,36 @@ pub use crate::module::sys::state::{DEFAULT_RECURSION_LIMIT, MAX_RECURSION_LIMIT
#[cfg(target_arch = "wasm32")]
pub const MAX_STACK_SIZE: usize = 3 << 18;
#[cfg(not(target_arch = "wasm32"))]
pub const MAX_STACK_SIZE: usize = 11 << 18;
pub const MAX_STACK_SIZE: usize = 48 << 18;

/// Default native stack for Python-created threads. Rust's platform default
/// can be as small as 2 MiB, which cannot hold pyre's translated Rust frame
/// shape even for stdlib recursion such as `functools.lru_cache`'s `fib(100)`.
/// `_thread.stack_size()` still reports/configures the Python-visible value;
/// zero selects this implementation default.
pub const DEFAULT_RUNTIME_THREAD_STACK_SIZE: usize = 8 * 1024 * 1024;
/// Native stack every thread that runs Python code is budgeted against.
///
/// Rust's platform default can be as small as 2 MiB, which cannot hold pyre's
/// translated Rust frame shape even for stdlib recursion such as
/// `functools.lru_cache`'s `fib(100)`. `_thread.stack_size()` still
/// reports/configures the Python-visible value; zero selects this
/// implementation default.
///
/// [`effective_stack_length`] leaves a quarter of the stack to the Rust/C
/// frames between the probe and the guard page, so 20 MiB buys a 15 MiB
/// ceiling. That covers the measured guard-resume cost at the default limit
/// (and the 90% lower bound at 2000 used by the parity fixture) without making
/// `support.infinite_recursion(20_000)` consume the much larger interpreter
/// thread before its native guard fires. At 8 MiB the clamp cut a thread's
/// budget below what the *default* limit is worth, so the guard, not the limit,
/// decided how deep a thread could recurse; at 64 MiB highly nested pure-Python
/// decoders spent minutes below the clamp and every default worker reserved an
/// excessive amount of address space.
///
/// The interpreter thread announces this too, though its own stack is much
/// larger (`pyrex::INTERPRETER_THREAD_STACK_SIZE`). `stack_length` is
/// process-global — the JIT's inline probe reads its address as an immediate
/// (`pyre_stack_get_length_adr`) — so whatever a thread stores there is what
/// every other thread's fast path reads until one of them takes the slow
/// path. A single figure for all Python-running threads keeps that shared
/// word meaningful; upstream gets the same uniformity for free because
/// `_ll_stack_os_limit` reads `getrlimit`, which is process-wide.
pub const DEFAULT_RUNTIME_THREAD_STACK_SIZE: usize = 20 * 1024 * 1024;

/// Process-wide requested byte budget, corresponding to RPython's
/// `MAX_STACK_SIZE * recursionlimit / 1000`. This is semantic configuration
Expand Down Expand Up @@ -808,12 +845,25 @@ mod tests {
}
}

/// The requested byte budget, before [`effective_stack_length`]'s clamp.
///
/// Budget assertions read this rather than `pyre_stack_get_length()`: the
/// clamp is three quarters of whatever stack the running thread has, so on
/// a host whose `RLIMIT_STACK` is smaller than the budget under test the
/// stored length says nothing about what was requested. What the recursion
/// limit controls is the request.
fn requested_budget() -> usize {
REQUESTED_STACK_LENGTH.load(Ordering::Relaxed)
}

#[test]
fn default_recursion_limit_matches_python() {
let _g = lock_tests();
reset_all();
assert_eq!(get_recursion_limit(), DEFAULT_RECURSION_LIMIT);
assert_eq!(pyre_stack_get_length(), MAX_STACK_SIZE);
assert_eq!(requested_budget(), MAX_STACK_SIZE);
assert_eq!(pyre_stack_get_length(), effective_stack_length());
reset_all();
}

#[test]
Expand All @@ -824,12 +874,12 @@ mod tests {
// underneath the currently-running interpreter stack.
set_recursion_limit(500).expect("500 is positive");
assert_eq!(get_recursion_limit(), 500);
assert_eq!(pyre_stack_get_length(), MAX_STACK_SIZE);
assert_eq!(requested_budget(), MAX_STACK_SIZE);

// Raising the limit grows both the logical limit and native budget.
set_recursion_limit(2000).expect("2000 is positive");
assert_eq!(get_recursion_limit(), 2000);
assert!(pyre_stack_get_length() > MAX_STACK_SIZE);
assert_eq!(requested_budget(), 2 * MAX_STACK_SIZE);

reset_all();
}
Expand Down Expand Up @@ -871,6 +921,30 @@ mod tests {
reset_all();
}

#[test]
fn configured_thread_stack_replaces_the_rlimit_fallback() {
let _g = lock_tests();
reset_all();
// The clamp describes the stack the *running* thread was given.
// `getrlimit(RLIMIT_STACK)` is only the fallback for a thread that
// never announced one, and it reports the process's original thread —
// typically 8 MiB, less than the budget a thread pyre spawns with a
// large explicit stack is entitled to. Announcing the real size must
// therefore be able to raise the clamp, not just lower it.
//
// Both stores are process-global, so keep the window to the two calls
// and restore at once (see small_recursion_limit_triggers_overflow_sooner).
configure_current_thread_stack_size(8 * MAX_STACK_SIZE);
pyre_stack_set_length_fraction(4.0);
let length = pyre_stack_get_length();
reset_all();
assert_eq!(
length,
4 * MAX_STACK_SIZE,
"an announced stack far above RLIMIT_STACK must carry the full request"
);
}

#[test]
fn backend_slowpath_raises_into_pending_exception() {
let _g = lock_tests();
Expand Down Expand Up @@ -1133,11 +1207,16 @@ mod tests {
// Store via FFI, read via raw pointer — confirms the atomic
// storage is bit-compatible with a plain usize load.
pyre_stack_set_length_fraction(0.5);
// What the fraction scales is the request; what lands in the word is
// that request after the OS clamp, which depends on this host's
// RLIMIT_STACK. Assert the two separately so neither reading needs a
// host with a stack larger than the budget under test.
assert_eq!(requested_budget(), MAX_STACK_SIZE / 2);
let length_adr = pyre_stack_get_length_adr();
let loaded = unsafe { *(length_adr as *const usize) };
assert_eq!(
loaded,
(MAX_STACK_SIZE as f64 * 0.5) as usize,
effective_stack_length(),
"raw load through adr must observe the FFI store"
);
reset_all();
Expand Down
25 changes: 24 additions & 1 deletion pyre/pyrex/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,14 @@ fn read_stdin_source() -> std::io::Result<String> {
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "stdin not utf-8"))
}

/// Native stack for the interpreter thread spawned by [`main_entry`].
///
/// Well above the byte budget `sys.setrecursionlimit` can buy
/// (`stack_check::DEFAULT_RUNTIME_THREAD_STACK_SIZE`): `stack_check` gates
/// Python call levels, and the native recursion it does not gate — nested
/// container `repr`, the parser, GC tracing — draws on the rest.
const INTERPRETER_THREAD_STACK_SIZE: usize = 256 * 1024 * 1024;

pub fn main_entry(binary_name: &'static str) {
configure_root_only_jit_stats();
// The sandboxed child runs single-threaded like pypy-c-sandbox: it neither
Expand Down Expand Up @@ -409,8 +417,23 @@ pub fn main_entry(binary_name: &'static str) {
{
pyre_interpreter::module::signal::signalstate::block_async_signals_on_origin_thread();
std::thread::Builder::new()
.stack_size(256 * 1024 * 1024)
.stack_size(INTERPRETER_THREAD_STACK_SIZE)
.spawn(|| {
// Same first statement `_thread`'s worker runs
// (`module/thread/mod.rs`): announce the stack this thread is
// budgeted against, and capture the base here at the outermost
// interpreter entry. Left out, `effective_stack_length` falls
// back to `getrlimit(RLIMIT_STACK)`, which describes the
// process's original thread and not this one, and the byte
// guard — not the recursion limit — ends up deciding how deep
// Python can recurse.
//
// The announced figure is the shared one, not
// INTERPRETER_THREAD_STACK_SIZE: the budget it sizes is stored
// in a process-global word every thread's inline probe reads.
pyre_interpreter::stack_check::configure_current_thread_stack_size(
pyre_interpreter::stack_check::DEFAULT_RUNTIME_THREAD_STACK_SIZE,
);
real_main(binary_name);
post_run_diagnostics();
})
Expand Down
Loading