From c88a75b60e46ce70d0eae6714958e2d1cffbc863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 02:37:42 +0000 Subject: [PATCH 1/3] perf(regex): decide a per-call search in one engine entry find_near and find_near_lent each built a Search and then advanced it: two view acquisitions per JS regex call, and a Search moved between them, for a search whose first quantum decides it. Search::run acquires the views once and runs that quantum in the same borrow, so a decided search never builds a Search at all. Run::Paused falls into the existing advance loop unchanged, and a pause from a Frames/Undo shortage re-raises on the next advance (perex keeps it in state.blocked) straight into the scratch-growth branch, so both paths behave exactly as Search::new followed by advance did. Instructions per call, control subtracted, one Perry commit and one perex commit differing only by this patch, release build, min of 5 interleaved rounds on a 16-core Linux host: .test() anchored 4,382.2 -> 4,063.7 -7.3% .test() inline literal 5,698.2 -> 5,379.7 -5.6% exec, two groups 7,566.9 -> 7,237.9 -4.3% .test() unanchored miss 2,438.7 -> 2,133.7 -12.5% .test() unanchored hit 5,813.1 -> 5,481.1 -5.7% 200,000-char subject 1,439,713 -> 1,439,003 -0.0% 305 to 332 instructions whatever the pattern, which is the fixed cost of the call; it vanishes against a subject long enough for matching to dominate. Every probe returns an identical answer on both arms and on Node 26.5.1. Wall-clock figures are deliberately omitted: the measuring host carried other sessions' builds throughout, and round-to-round spread reached 68-244% against effects of 0.5-25%, enough to invert the sign of a known-good result. Only instruction counts are quoted. perex 0.1.9 supplies Search::run and Run. Its src/ is byte-identical to the commit the figures above were measured against, and re-measuring the released crate reproduced every row to 0.1 instructions. --- Cargo.lock | 4 +- Cargo.toml | 2 +- .../perry-runtime/src/regex/perex_runtime.rs | 73 ++++++++++++++++--- 3 files changed, 64 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 480657380e..7b0c5752b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5617,9 +5617,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perex" -version = "0.1.7" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc61f41aef38c94e922057977bcb33bf185ab42242188719991ecfdc0fa1fe6b" +checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" diff --git a/Cargo.toml b/Cargo.toml index b4f5891706..203089dde0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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.7" +perex = "0.1.9" hex = "0.4" tempfile = "3" itoa = "1.0" diff --git a/crates/perry-runtime/src/regex/perex_runtime.rs b/crates/perry-runtime/src/regex/perex_runtime.rs index a4853ba871..18524fbf4d 100644 --- a/crates/perry-runtime/src/regex/perex_runtime.rs +++ b/crates/perry-runtime/src/regex/perex_runtime.rs @@ -11,8 +11,8 @@ use perex::binding::{ }; use perex::compiler::{self, CompileError, Node, Range}; use perex::executor::{ - ExecError, Frame, Progress, Scratch, ScratchOwner, ScratchRequirements, Search, SearchError, - Undo, + ExecError, Frame, Progress, Run, Scratch, ScratchOwner, ScratchRequirements, Search, + SearchError, Undo, }; use perex::input::Position; use perex::span::Span; @@ -382,11 +382,37 @@ fn find_near_lent<'mem, S: ImmutableSubject>( undo: &mut cell.undo[..], }; poll_on_stride(poll)?; - let mut search = match near { - Some(near) => Search::new_near(resources, start, near, scratch, *budget), - None => Search::new(resources, start, scratch, *budget), - } - .map_err(search_error)?; + // Both views are acquired once for the quantum that decides nearly + // every per-call search; a `Search` is built and moved only if this + // one pauses or asks for more scratch. + let mut search = match Search::run(resources, start, near, scratch, *budget, quantum) + .map_err(search_error)? + { + Run::Finished(mut finished) => { + *budget = Budget::new(finished.remaining_work()); + let position = finished.position(); + if !finished.matched() { + return Ok(Lent::Done(None, position)); + } + let full = finished + .capture(0) + .map_err(EngineError::Execution)? + .ok_or(EngineError::Execution(ExecError::InvalidProgram))?; + let captures = match mode { + CaptureMode::Full => None, + CaptureMode::All => { + poll()?; + let mut output = Slots::new(memory, finished.capture_count())?; + finished + .copy_captures(&mut output) + .map_err(EngineError::Execution)?; + Some(output) + } + }; + return Ok(Lent::Done(Some(Match { full, captures }), position)); + } + Run::Paused(search) => search, + }; loop { let result = search.advance(quantum); *budget = Budget::new(search.remaining_work()); @@ -482,11 +508,34 @@ pub(crate) fn find_near<'mem, S: ImmutableSubject>( poll()?; let buffers = MatchBuffers::new(memory, size)?; - 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)?; + let mut search = match Search::run(&resources, start, near, buffers, *budget, quantum) + .map_err(search_error)? + { + Run::Finished(mut finished) => { + *budget = Budget::new(finished.remaining_work()); + let position = finished.position(); + if !finished.matched() { + return Ok((None, position)); + } + let full = finished + .capture(0) + .map_err(EngineError::Execution)? + .ok_or(EngineError::Execution(ExecError::InvalidProgram))?; + let captures = match mode { + CaptureMode::Full => None, + CaptureMode::All => { + poll()?; + let mut output = Slots::new(memory, finished.capture_count())?; + finished + .copy_captures(&mut output) + .map_err(EngineError::Execution)?; + Some(output) + } + }; + return Ok((Some(Match { full, captures }), position)); + } + Run::Paused(search) => search, + }; loop { let result = search.advance(quantum); // Preserve consumed work even when the following poll cancels/throws, From b6260f3b20a178f5b699420120c40f1594c54d0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 05:53:16 +0000 Subject: [PATCH 2/3] docs(changelog): fragment for #10580 --- changelog.d/10580-search-run-one-entry.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/10580-search-run-one-entry.md diff --git a/changelog.d/10580-search-run-one-entry.md b/changelog.d/10580-search-run-one-entry.md new file mode 100644 index 0000000000..426a6db087 --- /dev/null +++ b/changelog.d/10580-search-run-one-entry.md @@ -0,0 +1,3 @@ +### Faster + +- Regular expression calls spend 305 to 332 fewer instructions each, 4.3% to 12.5% depending on the pattern. A `test` or `exec` call used to acquire the engine's program and subject views twice — once to learn the shape of the search and again to run it — and move the search between them; it now does both in one entry, and a search that its first quantum decides is never built as a resumable one at all (#10166). From 1abecf1c9ac1397118d0deebabb04cebae1d6b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 05:57:44 +0000 Subject: [PATCH 3/3] docs(regex): note what Search::run drops on a non-capacity error --- .../perry-runtime/src/regex/perex_runtime.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/perry-runtime/src/regex/perex_runtime.rs b/crates/perry-runtime/src/regex/perex_runtime.rs index 18524fbf4d..fccd00fe3d 100644 --- a/crates/perry-runtime/src/regex/perex_runtime.rs +++ b/crates/perry-runtime/src/regex/perex_runtime.rs @@ -385,6 +385,15 @@ fn find_near_lent<'mem, S: ImmutableSubject>( // Both views are acquired once for the quantum that decides nearly // every per-call search; a `Search` is built and moved only if this // one pauses or asks for more scratch. + // On an error that is not a capacity request -- WorkLimit, + // InvalidProgram, ChangedResources, Cancelled -- `run` returns Err and + // drops the scratch and the remaining budget, where `new` + `advance` + // left a Search to read `remaining_work()` from. So `*budget` keeps its + // entry value and under-counts what the failed call spent. WORK is + // usize::MAX on every path here, so nothing observes it today; if a + // finite execution budget is ever reintroduced (see #10164/#10165), + // this stops being free and wants the engine's failure arm to report + // remaining work. let mut search = match Search::run(resources, start, near, scratch, *budget, quantum) .map_err(search_error)? { @@ -508,6 +517,15 @@ pub(crate) fn find_near<'mem, S: ImmutableSubject>( poll()?; let buffers = MatchBuffers::new(memory, size)?; + // On an error that is not a capacity request -- WorkLimit, + // InvalidProgram, ChangedResources, Cancelled -- `run` returns Err and + // drops the scratch and the remaining budget, where `new` + `advance` + // left a Search to read `remaining_work()` from. So `*budget` keeps its + // entry value and under-counts what the failed call spent. WORK is + // usize::MAX on every path here, so nothing observes it today; if a + // finite execution budget is ever reintroduced (see #10164/#10165), + // this stops being free and wants the engine's failure arm to report + // remaining work. let mut search = match Search::run(&resources, start, near, buffers, *budget, quantum) .map_err(search_error)? {