diff --git a/pyre/extra_tests/parity_tests/recursion_limit_survives_jit_warmup.py b/pyre/extra_tests/parity_tests/recursion_limit_survives_jit_warmup.py new file mode 100644 index 00000000000..3b84e0cb0be --- /dev/null +++ b/pyre/extra_tests/parity_tests/recursion_limit_survives_jit_warmup.py @@ -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") diff --git a/pyre/pyre-interpreter/src/builtins.rs b/pyre/pyre-interpreter/src/builtins.rs index 983496a7554..b82c037c42c 100644 --- a/pyre/pyre-interpreter/src/builtins.rs +++ b/pyre/pyre-interpreter/src/builtins.rs @@ -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), ) @@ -4321,7 +4321,7 @@ pub(crate) fn builtin_range(args: &[PyObjectRef]) -> Result bool { @@ -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, ) } } @@ -4474,6 +4474,32 @@ fn builtin_len(args: &[PyObjectRef]) -> Result { 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 { + 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 { // operation.py `abs(space, w_val)`. The gateway Signature binds the keyword @@ -5898,11 +5924,20 @@ fn builtin_isinstance(args: &[PyObjectRef]) -> Result Result { - 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"))] diff --git a/pyre/pyre-interpreter/src/stack_check.rs b/pyre/pyre-interpreter/src/stack_check.rs index f4efb821d8f..053ffb782a8 100644 --- a/pyre/pyre-interpreter/src/stack_check.rs +++ b/pyre/pyre-interpreter/src/stack_check.rs @@ -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 @@ -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 @@ -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] @@ -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(); } @@ -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(); @@ -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(); diff --git a/pyre/pyrex/src/lib.rs b/pyre/pyrex/src/lib.rs index 90c4ce35f2c..fea4142eb93 100644 --- a/pyre/pyrex/src/lib.rs +++ b/pyre/pyrex/src/lib.rs @@ -382,6 +382,14 @@ fn read_stdin_source() -> std::io::Result { .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 @@ -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(); })