diff --git a/CLAUDE.md b/CLAUDE.md index 206f93b1ab..b434e7c262 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1550 +**Current Version:** 0.5.1551 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index d86309b53e..ae90e9b1d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5684,13 +5684,13 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perex" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b2094dda4d997bf73a372cb660d02e9abdb0a13cbf834ddd2b2f8847bffe2cf" +checksum = "21df239ee18f99de6abff50953f6f15be1b5ebd11e6ae9661acdd93026e983db" [[package]] name = "perry" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "anyhow", "base64 0.22.1", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "perry-dispatch", "serde", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "cc", "libc", @@ -5771,7 +5771,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "aho-corasick", "anyhow", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "anyhow", "perry-hir", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "anyhow", "perry-hir", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "anyhow", "perry-dispatch", @@ -5814,7 +5814,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "anyhow", "perry-hir", @@ -5822,7 +5822,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "anyhow", "base64 0.22.1", @@ -5834,7 +5834,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "anyhow", "perry-hir", @@ -5842,7 +5842,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "anyhow", "async-trait", @@ -5870,14 +5870,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "serde", "serde_json", @@ -5885,7 +5885,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1550" +version = "0.5.1551" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "anyhow", "clap", @@ -5911,7 +5911,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "block2", "objc2", @@ -5921,7 +5921,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "argon2", "perry-ffi", @@ -5930,7 +5930,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "perry-ffi", "reqwest", @@ -5939,7 +5939,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "bcrypt", "perry-ffi", @@ -5947,7 +5947,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "perry-ffi", "rusqlite", @@ -5955,7 +5955,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "perry-ffi", "scraper", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "perry-ffi", "perry-runtime", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "chrono", "cron", @@ -5981,7 +5981,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "chrono", "perry-ffi", @@ -5989,7 +5989,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "perry-ffi", "rust_decimal", @@ -5997,7 +5997,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "perry-ffi", "serde_json", @@ -6005,7 +6005,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -6013,7 +6013,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "perry-ffi", "perry-runtime", @@ -6021,14 +6021,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "bytes", "http-body-util", @@ -6046,7 +6046,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "bytes", "lazy_static", @@ -6059,7 +6059,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "base64 0.22.1", "bytes", @@ -6091,7 +6091,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "lazy_static", "perry-ffi", @@ -6101,7 +6101,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "lru", "perry-ffi", @@ -6121,7 +6121,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "chrono", "perry-ffi", @@ -6129,7 +6129,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "bson", "futures-util", @@ -6141,7 +6141,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "chrono", "perry-ffi", @@ -6153,7 +6153,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "nanoid", "perry-ffi", @@ -6162,7 +6162,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "bytes", "perry-ffi", @@ -6177,7 +6177,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "const-oid 0.10.2", "der 0.8.1", @@ -6196,7 +6196,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "lettre", "perry-ffi", @@ -6206,7 +6206,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "notify", "perry-ffi", @@ -6218,7 +6218,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "perry-ffi", "printpdf", @@ -6226,7 +6226,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "perry-ffi", "sqlx", @@ -6235,7 +6235,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "perry-ffi", "perry-runtime", @@ -6244,7 +6244,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "governor", "perry-ffi", @@ -6252,7 +6252,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "fast_image_resize", "image", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "lazy_static", "perry-ffi", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "anyhow", "perry-ffi", @@ -6292,7 +6292,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "perry-ffi", "perry-runtime", @@ -6301,7 +6301,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "perry-ffi", "uuid", @@ -6309,7 +6309,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "perry-ffi", "perry-validation", @@ -6318,7 +6318,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "futures-util", "lazy_static", @@ -6331,7 +6331,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "brotli", "flate2", @@ -6341,7 +6341,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6351,7 +6351,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "anyhow", "perry-api-manifest", @@ -6372,11 +6372,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1550" +version = "0.5.1551" [[package]] name = "perry-parser" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "anyhow", "perry-diagnostics", @@ -6390,7 +6390,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "perex", "regex", @@ -6398,7 +6398,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "ahash", "anyhow", @@ -6458,14 +6458,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6560,14 +6560,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "anyhow", "perry-hir", @@ -6576,7 +6576,7 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "perry-ffi", "perry-ui-model", @@ -6584,7 +6584,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "base64 0.22.1", "itoa", @@ -6602,7 +6602,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "rand 0.10.2", "serde", @@ -6612,7 +6612,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6635,7 +6635,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "base64 0.22.1", "block2", @@ -6652,7 +6652,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "base64 0.22.1", "block2", @@ -6669,7 +6669,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1550" +version = "0.5.1551" [[package]] name = "perry-ui-test" @@ -6680,11 +6680,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1550" +version = "0.5.1551" [[package]] name = "perry-ui-tvos" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "base64 0.22.1", "block2", @@ -6701,7 +6701,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "base64 0.22.1", "block2", @@ -6718,7 +6718,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "block2", "libc", @@ -6732,7 +6732,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "base64 0.22.1", "libc", @@ -6751,7 +6751,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "base64 0.22.1", "libc", @@ -6764,7 +6764,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "anyhow", "base64 0.22.1", @@ -6780,7 +6780,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "idna", "regex", @@ -6790,7 +6790,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1550" +version = "0.5.1551" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 0bc188f5f7..b3a2237c9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1550" +version = "0.5.1551" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" @@ -399,7 +399,7 @@ chrono = "0.4" regex = "1.12" aho-corasick = "1.1" # The single regular-expression engine, through crates/perry-perex. -perex = "0.1" +perex = "0.1.2" hex = "0.4" tempfile = "3" itoa = "1.0" diff --git a/changelog.d/10181-regex-resume-from-position.md b/changelog.d/10181-regex-resume-from-position.md new file mode 100644 index 0000000000..30f275d619 --- /dev/null +++ b/changelog.d/10181-regex-resume-from-position.md @@ -0,0 +1,3 @@ +### Performance + +- **Non-ASCII RegExp `split`, `replace` and global `match` resume each search from where the previous one stopped** (#10164). On non-ASCII strings every search sought its start from an end of the string, which made these operations quadratic; they are now linear (log-log slope 1.02–1.05, from about 1.8). Capture strings are read from the match's position the same way. Requires `perex` 0.1.2. diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs index 9050c8c439..e6fdd12f94 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs @@ -48,6 +48,15 @@ fn global_loop( receiver: &RuntimeHandle<'_>, input: &RuntimeHandle<'_>, reuse: Option<&Reuse<'_, '_>>, +) -> (Vec>, usize) { + global_loop_collecting(receiver, input, reuse, true) +} + +fn global_loop_collecting( + receiver: &RuntimeHandle<'_>, + input: &RuntimeHandle<'_>, + reuse: Option<&Reuse<'_, '_>>, + collect: bool, ) -> (Vec>, usize) { let memory = MemoryBudget::new(api::SCRATCH_BYTES); let mut budget = Budget::new(api::WORK); @@ -65,7 +74,9 @@ fn global_loop( &mut budget, &memory, &mut || { - gc_collect_minor(); + if collect { + gc_collect_minor(); + } Ok(()) }, reuse, @@ -130,10 +141,15 @@ fn perex_reuse_serves_a_whole_global_loop_across_moving_collections() { let (fresh_matches, fresh_work) = global_loop(&fresh, &fresh_input, None); assert_eq!(fresh_matches, expected); // Six searches (five matches and the final miss). Binding per search charges - // program validation six times; reuse charged it once, in `setup`. + // program validation six times; reuse charged it once, in `setup`. Reuse + // also resumes each search from the previous one instead of seeking from + // an end of this non-ASCII subject, so it saves at least the validations. let validation = api::WORK - setup.remaining(); assert!(validation > 0); - assert_eq!(fresh_work, reused_work + 6 * validation); + assert!( + fresh_work >= reused_work + 6 * validation, + "reuse must save at least six program validations: fresh {fresh_work}, reused {reused_work}, one validation {validation}" + ); } #[test] @@ -224,3 +240,85 @@ fn perex_reuse_binds_a_different_string_afresh() { .unwrap(); assert_eq!(first_item(&scope, found.array), b"22"); } + +/// Work a global loop over `repeats` copies of a non-ASCII record charges. +fn non_ascii_loop_work(repeats: usize, reuse: bool) -> usize { + let local = RuntimeHandleScope::new(); + let input = text(&local, "ä1 ö22 ".repeat(repeats).as_bytes()); + let receiver = regex(&local, "[a-zäö]+\\d+", "gu"); + let subject = BoundSubject::new(unsafe { HeapSubject::new(input) }.unwrap()).unwrap(); + let mut setup = Budget::new(api::WORK); + let reused = Reuse::new(&local, &receiver, input, &subject, &mut setup); + let (matches, work) = + global_loop_collecting(&receiver, &input, reuse.then_some(&reused), false); + assert_eq!(matches.len(), 2 * repeats); + work +} + +#[test] +fn perex_reuse_positions_keep_a_non_ascii_global_loop_linear() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + // Doubling the subject doubles the searches. Seeking each one from an end + // of the subject makes the total roughly quadruple (#10164); resuming from + // the previous search keeps it roughly double. + let fresh = non_ascii_loop_work(2_000, false) as f64 / non_ascii_loop_work(1_000, false) as f64; + let reused = non_ascii_loop_work(2_000, true) as f64 / non_ascii_loop_work(1_000, true) as f64; + assert!( + fresh > 3.0, + "the unpositioned loop must be quadratic here, got {fresh:.2}x" + ); + assert!( + reused < 2.2, + "the positioned loop must be linear, got {reused:.2}x" + ); +} + +fn nanbox_text(scope: &RuntimeHandleScope, value: &str) -> f64 { + text(scope, value.as_bytes()).with_const_ptr::(|p| js_nanbox_string(p as i64)) +} + +fn utf16_length(value: f64) -> u32 { + let string = crate::value::js_nanbox_get_pointer(value) as *const StringHeader; + unsafe { (*string).utf16_len } +} + +#[test] +fn perex_split_and_replace_no_longer_hit_the_work_limit_on_linear_inputs() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + + // The #10164 reduction: a 32,000-unit split threw RangeError("Regular + // expression work limit exceeded"); Node returns 6,001 pieces. + let subject = "ä中12,Ö漢345;ef6😀".repeat(2_000); + let input = nanbox_text(&scope, &subject); + let input = scope.root_nanbox_f64(input); + assert_eq!(utf16_length(input.get_nanbox_f64()), 32_000); + let re = regex(&scope, "[,;😀]+", "u"); + let pieces = crate::regex::perex_split::regexp( + re.get_nanbox_f64(), + input.get_nanbox_f64(), + f64::from_bits(crate::value::TAG_UNDEFINED), + ) + .expect("a 32,000-unit split must not exhaust the work allowance"); + let pieces = crate::value::js_nanbox_get_pointer(pieces) as *const ArrayHeader; + assert_eq!(unsafe { (*pieces).length }, 6_001); + + // A 60,000-unit global replace threw the same error; Node's result wraps + // each of the 8,000 matches in brackets, 76,000 units in all. + let subject = "ä中😀12 Ö漢🦊345;".repeat(4_000); + let input = scope.root_nanbox_f64(nanbox_text(&scope, &subject)); + assert_eq!(utf16_length(input.get_nanbox_f64()), 60_000); + let re = regex(&scope, "[ä中😀Ö漢🦊]+", "gu"); + let template = scope.root_nanbox_f64(nanbox_text(&scope, "[$&]")); + let replaced = crate::regex::perex_replace::regexp( + re.get_nanbox_f64(), + input.get_nanbox_f64(), + template.get_nanbox_f64(), + ) + .expect("a 60,000-unit global replace must not exhaust the work allowance"); + assert_eq!(utf16_length(replaced), 76_000); +} diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_split.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_split.rs index 142cec6c40..8c4c192ea1 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_split.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_split.rs @@ -1287,3 +1287,31 @@ fn perex_split_user_species_regexp_keeps_the_observable_sticky_loop() { let splitter = scope.root_nanbox_f64(get(&re, b"splitter")); assert_eq!(get(&splitter, b"lastIndex"), 2.0); } + +/// Work one forward split of `repeats` non-ASCII records charges. +fn forward_split_work(repeats: usize) -> usize { + let scope = RuntimeHandleScope::new(); + let input = text(&scope, "ä中12,Ö漢345;ef6😀".repeat(repeats).as_bytes()); + let re = regex(&scope, "[,;😀]+".as_bytes(), b"u"); + let before = forward_splits(); + let out = run(&scope, &input, &re, -1.0); + assert_eq!(forward_splits(), before + 1, "the forward search must run"); + // Three pieces per record and the empty piece after the final emoji. + assert_eq!(get(&out, b"length"), (3 * repeats + 1) as f64); + split::LAST_FORWARD_WORK.with(Cell::get) +} + +/// On non-ASCII storage a search that seeks from an end of the subject makes a +/// loop of them quadratic (#10164). Resuming each from the previous one keeps +/// the forward split's work proportional to the input. +#[test] +fn perex_split_forward_search_resumes_each_search_on_non_ascii_input() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let ratio = forward_split_work(2_000) as f64 / forward_split_work(1_000) as f64; + assert!( + ratio < 2.3, + "doubling the input must roughly double the work, got {ratio:.2}x" + ); +} diff --git a/crates/perry-runtime/src/regex/perex_api.rs b/crates/perry-runtime/src/regex/perex_api.rs index 2e23d56d6d..d467743e99 100644 --- a/crates/perry-runtime/src/regex/perex_api.rs +++ b/crates/perry-runtime/src/regex/perex_api.rs @@ -9,6 +9,7 @@ use crate::string::StringHeader; use perex::binding::{BoundProgram, BoundSubject}; use perex::compiler::CompileError; use perex::executor::ExecError; +use perex::input::Position; use perex::{span::Span, Budget}; // One explicit host policy; no retained scratch cache or alternate engine. @@ -123,10 +124,15 @@ pub(crate) fn program<'s>( /// the same string, and the same receiver still holding the same program cell. /// Anything else (an `exec` override, a recompiled receiver, another string) /// binds afresh for that search exactly as before. +/// +/// `near` is where the previous search over the reused subject stood (#10164), +/// so the next search seeks from there instead of from an end of the subject. +/// It is only ever set from, and only ever used with, the reused binding. pub(crate) struct Reuse<'b, 's> { input: RuntimeHandle<'s>, subject: &'b BoundSubject>, program: Option>, + near: std::cell::Cell>, } struct ReusedProgram<'s> { @@ -168,9 +174,15 @@ impl<'b, 's> Reuse<'b, 's> { input, subject, program, + near: std::cell::Cell::new(None), } } + /// Where the last search over the reused subject stood, if any. + pub(crate) fn near(&self) -> Option { + self.near.get() + } + fn subject_for(&self, input: &RuntimeHandle<'_>) -> Option<&BoundSubject>> { let current = input.with_const_ptr::(|p| p); let bound = self.input.with_const_ptr::(|p| p); @@ -319,7 +331,12 @@ pub(crate) fn execute_with_resources( } }; let fresh_subject; - let subject = match reuse.and_then(|reuse| reuse.subject_for(&input)) { + let reused_subject = reuse.and_then(|reuse| reuse.subject_for(&input)); + // A position is valid only on the binding it came from. + let near = reuse + .filter(|_| reused_subject.is_some()) + .and_then(|reuse| reuse.near()); + let subject = match reused_subject { Some(subject) => subject, None => { fresh_subject = @@ -330,10 +347,11 @@ pub(crate) fn execute_with_resources( &fresh_subject } }; - let found = host::find( + let (found, position) = host::find_near( program, subject, start, + near, if materialize { CaptureMode::All } else { @@ -344,6 +362,9 @@ pub(crate) fn execute_with_resources( QUANTUM, poll, )?; + if let (Some(reuse), Some(_)) = (reuse, reused_subject) { + reuse.near.set(Some(position)); + } if stateful { let next = found.as_ref().map_or(0, |m| m.full.end()); caught(|| { @@ -362,6 +383,8 @@ pub(crate) fn execute_with_resources( subject, program, &found, + // From the search that just ran over this same binding. + Some(position), has_indices, budget, poll, diff --git a/crates/perry-runtime/src/regex/perex_match_search.rs b/crates/perry-runtime/src/regex/perex_match_search.rs index 0ef7af35ef..c09a371025 100644 --- a/crates/perry-runtime/src/regex/perex_match_search.rs +++ b/crates/perry-runtime/src/regex/perex_match_search.rs @@ -223,9 +223,11 @@ fn matches( }; let string = match result { dispatch::ExecResult::Builtin(found) => api::caught(|| { - super::perex_strings::copy_span( + super::perex_strings::copy_span_near( &subject, found.full, + // `reuse` binds this same `subject`. + reuse.near(), budget, api::OUTPUT_BYTES, api::QUANTUM, diff --git a/crates/perry-runtime/src/regex/perex_results.rs b/crates/perry-runtime/src/regex/perex_results.rs index 142aceecbc..4590b95318 100644 --- a/crates/perry-runtime/src/regex/perex_results.rs +++ b/crates/perry-runtime/src/regex/perex_results.rs @@ -3,7 +3,7 @@ use super::perex_api::{OUTPUT_BYTES, QUANTUM}; use super::perex_owner::{GcProgram, HeapSubject}; use super::perex_runtime::{self as host, EngineError, Match}; -use super::perex_strings::{copy_name, copy_span}; +use super::perex_strings::{copy_name, copy_span_near}; use crate::array::ArrayHeader; use crate::gc::{RuntimeHandle, RuntimeHandleScope}; use crate::object::ObjectHeader; @@ -21,6 +21,7 @@ pub(super) fn materialize( subject: &BoundSubject>, program: &BoundProgram>, found: &Match<'_>, + near: Option, has_indices: bool, budget: &mut Budget, poll: &mut impl FnMut() -> Result<(), EngineError>, @@ -34,7 +35,7 @@ pub(super) fn materialize( }); for (index, capture) in captures.iter().enumerate() { let value = if let Some(span) = capture { - let text = copy_span(subject, *span, budget, OUTPUT_BYTES, QUANTUM, poll)?; + let text = copy_span_near(subject, *span, near, budget, OUTPUT_BYTES, QUANTUM, poll)?; crate::value::js_nanbox_string(text as i64).to_bits() } else { crate::value::TAG_UNDEFINED diff --git a/crates/perry-runtime/src/regex/perex_runtime.rs b/crates/perry-runtime/src/regex/perex_runtime.rs index cb71897144..802b2c98b8 100644 --- a/crates/perry-runtime/src/regex/perex_runtime.rs +++ b/crates/perry-runtime/src/regex/perex_runtime.rs @@ -14,6 +14,7 @@ use perex::executor::{ ExecError, Frame, Progress, Scratch, ScratchOwner, ScratchRequirements, Search, SearchError, Undo, }; +use perex::input::Position; use perex::span::Span; use perex::Budget; @@ -164,6 +165,31 @@ pub(crate) fn find<'mem, S: ImmutableSubject>( quantum: usize, poll: &mut impl FnMut() -> Result<(), EngineError>, ) -> Result>, EngineError> { + find_near( + program, subject, start, None, mode, budget, memory, quantum, poll, + ) + .map(|(found, _)| found) +} + +/// `find`, seeking to `start` from `near` when that is closer than either end +/// of the subject, and returning where the search stood: the match's end, or +/// the start of its last attempt (#10164). On non-ASCII storage a search from +/// an end costs up to half the subject, so a loop of them is quadratic. +/// +/// `near` must come from a search or reader over this same binding. Another +/// string with an identical layout cannot be detected and would give wrong +/// answers, so callers keep a position only as long as the binding it came from. +pub(crate) fn find_near<'mem, S: ImmutableSubject>( + program: &BoundProgram>, + subject: &BoundSubject, + start: usize, + near: Option, + mode: CaptureMode, + budget: &mut Budget, + memory: &'mem MemoryBudget, + quantum: usize, + poll: &mut impl FnMut() -> Result<(), EngineError>, +) -> Result<(Option>, Position), EngineError> { if quantum == 0 { return Err(EngineError::InvalidQuantum); } @@ -178,14 +204,18 @@ pub(crate) fn find<'mem, S: ImmutableSubject>( }; poll()?; let buffers = MatchBuffers::new(memory, size)?; - let mut search = Search::new(&resources, start, buffers, *budget).map_err(search_error)?; + let mut search = match near { + Some(near) => Search::new_near(&resources, start, near, buffers, *budget), + None => Search::new(&resources, start, buffers, *budget), + } + .map_err(search_error)?; loop { let result = search.advance(quantum); // Preserve consumed work even when the following poll cancels/throws, // allocation fails, or a scratch replacement cannot fit the cap. *budget = Budget::new(search.remaining_work()); match result { - Ok(Progress::NoMatch) => return Ok(None), + Ok(Progress::NoMatch) => return Ok((None, search.position())), Ok(Progress::Matched) => { let full = search .capture(0) @@ -202,7 +232,7 @@ pub(crate) fn find<'mem, S: ImmutableSubject>( Some(output) } }; - return Ok(Some(Match { full, captures })); + return Ok((Some(Match { full, captures }), search.position())); } Ok(Progress::Pending) => poll()?, Err(SearchError::Execution(ExecError::Frames | ExecError::Undo)) => { diff --git a/crates/perry-runtime/src/regex/perex_split.rs b/crates/perry-runtime/src/regex/perex_split.rs index 56dc16542a..7dfd71645e 100644 --- a/crates/perry-runtime/src/regex/perex_split.rs +++ b/crates/perry-runtime/src/regex/perex_split.rs @@ -13,6 +13,7 @@ use super::perex_strings::SpanCopies; use crate::gc::{RuntimeHandle, RuntimeHandleScope}; use crate::value::{js_nanbox_pointer, js_nanbox_string, TAG_NULL, TAG_UNDEFINED}; use perex::binding::{BoundProgram, BoundSubject, SubjectError}; +use perex::input::Position; use perex::Budget; /// Literal String operations also accept Perry's raw Buffer/FFI payloads. @@ -57,10 +58,12 @@ fn advance( Ok(index + 1) } -// Counts forward splits taken, so tests can tell which path ran. +// Counts forward splits taken, and the work the last one charged, so tests can +// tell which path ran and how its cost scales. #[cfg(test)] thread_local! { pub(crate) static FORWARD_SPLITS: std::cell::Cell = const { std::cell::Cell::new(0) }; + pub(crate) static LAST_FORWARD_WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// The program for split's forward search, when it is admissible (#10165). @@ -190,19 +193,29 @@ pub(crate) fn regexp(receiver: f64, argument: f64, limit_value: f64) -> Result = None; while q < size { let local = RuntimeHandleScope::new(); - let Some(found) = host::find( + let (found, position) = host::find_near( &forward, &bound, q, + near, CaptureMode::All, &mut budget, &memory, api::QUANTUM, &mut host::poll, - )? - else { + )?; + near = Some(position); + let Some(found) = found else { break; }; let start = found.full.start(); @@ -220,6 +233,7 @@ pub(crate) fn regexp(receiver: f64, argument: f64, limit_value: f64) -> Result Result Result Result Result<(), EngineError>, ) -> Result<*mut StringHeader, EngineError> { - let mut readers = [ - BoundSpan::new(subject, span).map_err(|e| read_error(e, |never| match never {}))?, - BoundSpan::new(subject, span).map_err(|e| read_error(e, |never| match never {}))?, - ]; + copy_span_near(subject, span, None, budget, max_output_bytes, quantum, poll) +} + +/// `copy_span`, with both reader passes seeking to the span from `near` when +/// that is closer than either end. Materializing a match's captures from its +/// search's position seeks back by at most the match length (#10164). `near` +/// has the same same-binding requirement as `perex_runtime::find_near`. +pub(crate) fn copy_span_near( + subject: &BoundSubject>, + span: Span, + near: Option, + budget: &mut Budget, + max_output_bytes: usize, + quantum: usize, + poll: &mut impl FnMut() -> Result<(), EngineError>, +) -> Result<*mut StringHeader, EngineError> { + let reader = || { + match near { + Some(near) => BoundSpan::new_near(subject, span, near), + None => BoundSpan::new(subject, span), + } + .map_err(|e| read_error(e, |never| match never {})) + }; + let mut readers = [reader()?, reader()?]; copy_units( Some(span.len()), budget, diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index fe58ee1b1a..c7498b1d77 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -823,6 +823,12 @@ "verdict": "test_only", "why": "#10165: #[cfg(test)] Cell counter of how many splits took the forward-search path, so tests can tell which path ran. It stores only a count and is absent from shipped binaries." }, + { + "file": "crates/perry-runtime/src/regex/perex_split.rs", + "name": "LAST_FORWARD_WORK", + "verdict": "test_only", + "why": "#10164: #[cfg(test)] Cell holding the Perex work units the last forward split charged, so tests can assert its cost scales linearly. A quantity, never an address, and absent from shipped binaries." + }, { "file": "crates/perry-runtime/src/regex/site_test.rs", "name": "DIRECT_G",