Skip to content

Commit e760262

Browse files
author
Ralph Küpper
committed
perf(regex): hold the site table's programs weakly, and stop the added counters moving the dump
Two corrections from the I6 cc arm, both found by reading the instrument back rather than by argument. **1. The site table must never be the reason a program stays alive.** Measured on one 3300-char reply: settled footprint 478/474 MB -> 500/527 MB and idle CPU 2.37 -> 2.68 s against main. 1,024 entries at ~19 KB per compiled program is that order, and the campaign's directive is both metrics together — a CPU win bought with resident memory does not land. The entry now holds `Weak<Regex>` / `Weak<fancy_regex::Regex>` / `Weak<RepeatMatcherRegex>`; strong references stay where they belong, in the `(pattern, flags)` program caches and in every live header that installed them with `Arc::into_raw`. An entry whose programs have expired reports "not built yet" and the next construction re-picks them up from the content cache — the same path the site's first construction takes, so the lane self-heals. The upgrade is ALL-OR-NOTHING. #9801 fixed an incoherent triple — a standard program memoized beside a missing fancy fallback — which does not error, it silently never matches; three independent `Arc` lifetimes reintroduce exactly that shape unless one dead reference invalidates the whole entry. Pinned by a test that drops ONLY the fancy program and asserts the entry reports unbuilt, which the natural per-field upgrade fails. **2. An added counter moved the instrument's own sampling.** `regex_with` counts every call as an event and dumps every `TICK_EVERY` events after a second has passed, so a second probe on an already-instrumented path doubles that path's event rate and moves the snapshot a SIGKILLed process leaves behind. On the I6 pair that showed up as `new / t` 206 k/s vs 173 k/s between two arms whose per-call ratios agree to 0.13 %, i.e. the two files describe different windows of the same workload. `regex_counters` accumulates without ticking the dump clock, and the three counters that ride along on already instrumented paths (barrier gate outcome, side-table inserts, site-verify bytes) now use it.
1 parent 61b4c21 commit e760262

4 files changed

Lines changed: 206 additions & 11 deletions

File tree

crates/perry-runtime/src/hot_diag.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,33 @@ crate::perry_thread_local! {
187187
static REGEX_DIAG: RefCell<RegexDiag> = RefCell::new(RegexDiag::default());
188188
}
189189

190+
/// Accumulate into the thread's regex counters WITHOUT ticking the dump clock.
191+
///
192+
/// `regex_with` counts every call as an "event" and dumps every `TICK_EVERY`
193+
/// events once a second has passed, so the snapshot a `SIGKILL`ed process
194+
/// leaves behind lands wherever the event stream happened to be. Adding a
195+
/// second probe to a path that already had one therefore does not just add a
196+
/// counter — it **doubles that path's event rate and moves the last snapshot**,
197+
/// which makes two arms' absolute counts describe different windows of the
198+
/// same workload.
199+
///
200+
/// Measured, on the I6 cc arm: the extra per-construction probes took
201+
/// `new / t` from 206 k/s to 173 k/s between two arms whose per-call ratios
202+
/// agree to 0.13 %. Counters that ride along on an already-instrumented path
203+
/// use this entry point so the cadence stays the pre-change one and the
204+
/// windows stay comparable.
205+
#[inline]
206+
pub fn regex_counters(f: impl FnOnce(&mut RegexDiag)) {
207+
REGEX_DIAG.with(|d| {
208+
let mut d = d.borrow_mut();
209+
if d.started.is_none() {
210+
d.started = Some(Instant::now());
211+
d.last_dump = None;
212+
}
213+
f(&mut d);
214+
});
215+
}
216+
190217
/// Run `f` against the thread's regex counters, then maybe dump.
191218
#[inline]
192219
pub fn regex_with(f: impl FnOnce(&mut RegexDiag)) {

crates/perry-runtime/src/regex.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1473,7 +1473,7 @@ fn js_regexp_new_impl(
14731473
let needs_barrier = !newborn_barrier_gate_enabled()
14741474
|| crate::gc::newborn_parent_needs_barrier(regexp_parent_addr);
14751475
if crate::hot_diag::regex_on() {
1476-
crate::hot_diag::regex_with(|d| {
1476+
crate::hot_diag::regex_counters(|d| {
14771477
if needs_barrier {
14781478
d.new_barrier_taken += 1;
14791479
} else {
@@ -1544,7 +1544,7 @@ fn js_regexp_new_impl(
15441544
// hashbrown insert, mirrored by two removals at death and two
15451545
// rekeys per evacuation. Counted so the pair is a number rather
15461546
// than a reading of the profile.
1547-
crate::hot_diag::regex_with(|d| d.new_side_table_inserts += 2);
1547+
crate::hot_diag::regex_counters(|d| d.new_side_table_inserts += 2);
15481548
}
15491549

15501550
// Issue #637: side-table owned copies of pattern + flags so

crates/perry-runtime/src/regex/site_cache.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ pub(super) fn lookup(pattern: &str, flags: &str) -> Option<Hit> {
148148
// `install_programs` verify too and are not counted here.
149149
if crate::hot_diag::regex_on() {
150150
let n = pattern.len() as u64;
151-
crate::hot_diag::regex_with(|d| d.new_site_verify_bytes += n);
151+
crate::hot_diag::regex_counters(|d| d.new_site_verify_bytes += n);
152152
}
153153
return Some(Hit {
154154
pattern: entry.pattern.clone(),

crates/perry-runtime/src/regex/site_key.rs

Lines changed: 176 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,59 @@
4646
//! exactly as before this existed).
4747
4848
use std::cell::RefCell;
49-
use std::sync::Arc;
49+
use std::sync::{Arc, Weak};
5050

5151
use super::site_cache::Programs;
5252

53+
/// The site entry's view of a pattern's compiled programs: **weak**, so the
54+
/// table can hand them out but can never be the reason they stay alive.
55+
///
56+
/// Measured cost of holding them strongly (cc, one 3300-char reply): settled
57+
/// footprint 478/474 MB → 500/527 MB and idle CPU 2.37 → 2.68 s. The site
58+
/// table is 1,024 entries and a compiled program is ~19 KB, so a table that
59+
/// outlives the content cache's own eviction retains programs nothing else
60+
/// wants. The campaign's directive is both metrics together, and a CPU win
61+
/// bought with resident memory does not land.
62+
///
63+
/// Strong references remain where they belong: the `(pattern, flags)` program
64+
/// caches, and every live header that installed them via `Arc::into_raw`. A
65+
/// site entry whose programs have been dropped simply reports "not built
66+
/// yet", and the next construction re-picks them up from the content cache —
67+
/// the same path the site's very first construction takes.
68+
struct WeakPrograms {
69+
std: Weak<::regex::Regex>,
70+
fancy: Option<Weak<::fancy_regex::Regex>>,
71+
repeat: Option<Weak<super::repeat_matcher::RepeatMatcherRegex>>,
72+
}
73+
74+
impl WeakPrograms {
75+
fn downgrade(programs: &Programs) -> Self {
76+
Self {
77+
std: Arc::downgrade(&programs.std),
78+
fancy: programs.fancy.as_ref().map(Arc::downgrade),
79+
repeat: programs.repeat.as_ref().map(Arc::downgrade),
80+
}
81+
}
82+
83+
/// ALL-OR-NOTHING. A header must carry **every** program its pattern needs
84+
/// — that is #9801's coherence rule, and a partial upgrade is exactly the
85+
/// incoherent triple it fixed: a standard program installed beside a
86+
/// missing fancy fallback silently never-matches instead of falling back.
87+
/// So a single dead reference makes the whole entry report unbuilt.
88+
fn upgrade(&self) -> Option<Programs> {
89+
let std = self.std.upgrade()?;
90+
let fancy = match &self.fancy {
91+
None => None,
92+
Some(weak) => Some(weak.upgrade()?),
93+
};
94+
let repeat = match &self.repeat {
95+
None => None,
96+
Some(weak) => Some(weak.upgrade()?),
97+
};
98+
Some(Programs { std, fancy, repeat })
99+
}
100+
}
101+
53102
/// The flag bits `js_regexp_new` derives from the canonical flags text. They
54103
/// are a pure function of the site's flags literal, so a hit reads them
55104
/// instead of re-scanning the string seven times.
@@ -79,7 +128,7 @@ struct Entry {
79128
/// of the site: the author wrote `/x/gi` or `/x/ig` once.
80129
flags_are_canonical: bool,
81130
bits: FlagBits,
82-
programs: Option<Programs>,
131+
programs: Option<WeakPrograms>,
83132
}
84133

85134
/// What a construction gets back on a site hit.
@@ -136,7 +185,7 @@ pub(super) fn lookup(key: usize, raw_flags: &str) -> Option<SiteHit> {
136185
flags: entry.flags.clone(),
137186
flags_are_canonical: entry.flags_are_canonical,
138187
bits: entry.bits,
139-
programs: entry.programs.clone(),
188+
programs: entry.programs.as_ref().and_then(WeakPrograms::upgrade),
140189
});
141190
}
142191
}
@@ -169,8 +218,19 @@ pub(super) fn record(
169218
for s in [slot, slot ^ 1] {
170219
if let Some(entry) = &mut table[s] {
171220
if entry.key == key && entry.raw_flags == raw_flags {
172-
if entry.programs.is_none() {
173-
entry.programs = programs;
221+
// Refresh a reference whose programs have been dropped,
222+
// rather than only filling an empty one: a dead weak and
223+
// an absent entry mean the same thing here, and the
224+
// former must be able to heal.
225+
if let Some(programs) = &programs {
226+
if entry
227+
.programs
228+
.as_ref()
229+
.and_then(WeakPrograms::upgrade)
230+
.is_none()
231+
{
232+
entry.programs = Some(WeakPrograms::downgrade(programs));
233+
}
174234
}
175235
return;
176236
}
@@ -193,7 +253,7 @@ pub(super) fn record(
193253
flags,
194254
flags_are_canonical,
195255
bits,
196-
programs,
256+
programs: programs.as_ref().map(WeakPrograms::downgrade),
197257
});
198258
});
199259
}
@@ -212,8 +272,14 @@ pub(super) fn install_programs(key: usize, programs: Programs) {
212272
}
213273
for s in [slot, slot ^ 1] {
214274
if let Some(entry) = &mut table[s] {
215-
if entry.key == key && entry.programs.is_none() {
216-
entry.programs = Some(programs);
275+
if entry.key == key
276+
&& entry
277+
.programs
278+
.as_ref()
279+
.and_then(WeakPrograms::upgrade)
280+
.is_none()
281+
{
282+
entry.programs = Some(WeakPrograms::downgrade(&programs));
217283
return;
218284
}
219285
}
@@ -247,3 +313,105 @@ pub(super) fn test_recorded_pattern(key: i64, raw_flags: &str) -> Option<String>
247313
pub(super) fn test_occupied_slots() -> usize {
248314
SITE_KEY_TABLE.with(|table| table.borrow().iter().filter(|e| e.is_some()).count())
249315
}
316+
317+
#[cfg(test)]
318+
mod tests {
319+
use super::*;
320+
321+
/// **The all-or-nothing rule, made able to fail.**
322+
///
323+
/// #9801 fixed an incoherent triple — a standard program memoized beside a
324+
/// missing fancy fallback — which does not error: it silently never
325+
/// matches. Holding the site entry's programs weakly reintroduces exactly
326+
/// that shape unless a dead reference invalidates the WHOLE entry, because
327+
/// the three `Arc`s have independent lifetimes and the fancy fallback is
328+
/// the one a pattern the linear engine refused depends on.
329+
///
330+
/// A sabotage that upgrades each field independently — the natural way to
331+
/// write it — returns `Some(Programs { std, fancy: None, .. })` here and
332+
/// fails on the second assertion.
333+
#[test]
334+
fn one_dead_reference_invalidates_the_whole_entry() {
335+
let std_program = Arc::new(::regex::Regex::new("a(b)c").expect("linear program"));
336+
let fancy_program = Arc::new(::fancy_regex::Regex::new("a(?=b)c").expect("fancy program"));
337+
let programs = Programs {
338+
std: std_program.clone(),
339+
fancy: Some(fancy_program.clone()),
340+
repeat: None,
341+
};
342+
let weak = WeakPrograms::downgrade(&programs);
343+
drop(programs);
344+
345+
let upgraded = weak
346+
.upgrade()
347+
.expect("both strong references are still held here");
348+
assert!(
349+
upgraded.fancy.is_some(),
350+
"the fancy fallback must survive the round trip while its Arc is alive"
351+
);
352+
drop(upgraded);
353+
354+
// Only the FANCY program dies. The standard one is still strongly held.
355+
drop(fancy_program);
356+
assert!(
357+
weak.upgrade().is_none(),
358+
"one dead reference must invalidate the whole entry — handing back a header with a \
359+
standard program and no fancy fallback is #9801's incoherent triple, which never \
360+
matches instead of failing"
361+
);
362+
drop(std_program);
363+
assert!(weak.upgrade().is_none());
364+
}
365+
366+
/// The table must not be the reason a program stays alive: once nothing
367+
/// else holds it, a recorded entry reports "not built yet" and the next
368+
/// construction re-picks it up from the content cache.
369+
#[test]
370+
fn the_site_table_does_not_keep_a_program_alive() {
371+
test_reset();
372+
let key = 0x5171_E000_usize;
373+
let std_program = Arc::new(::regex::Regex::new("keepalive").expect("linear program"));
374+
let programs = Programs {
375+
std: std_program.clone(),
376+
fancy: None,
377+
repeat: None,
378+
};
379+
record(
380+
key,
381+
Arc::from("g"),
382+
Arc::from("keepalive"),
383+
Arc::from("g"),
384+
true,
385+
FlagBits {
386+
case_insensitive: false,
387+
global: true,
388+
multiline: false,
389+
sticky: false,
390+
dot_all: false,
391+
unicode: false,
392+
has_indices: false,
393+
},
394+
Some(programs),
395+
);
396+
assert!(
397+
lookup(key, "g")
398+
.expect("the entry was just recorded")
399+
.programs
400+
.is_some(),
401+
"precondition: the entry answers with its programs while they are alive"
402+
);
403+
404+
drop(std_program);
405+
let hit = lookup(key, "g").expect("the entry itself survives");
406+
assert!(
407+
hit.programs.is_none(),
408+
"the site table holds programs WEAKLY: with every other reference gone the entry must \
409+
report unbuilt rather than keeping ~19 KB per slot alive on its own"
410+
);
411+
assert_eq!(
412+
&*hit.pattern, "keepalive",
413+
"the entry's identity is unaffected — only its programs expire"
414+
);
415+
test_reset();
416+
}
417+
}

0 commit comments

Comments
 (0)