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
9 changes: 9 additions & 0 deletions changelog.d/11543-regex-split-replace-ascii.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### Faster

- `String.prototype.split` with a regular expression and string-template `String.prototype.replace` build their pieces with far less host work on ASCII strings (#10165, #10518):
- A split piece or a replace capture is now one byte copy. It used to take two unit-by-unit passes and two GC safepoint polls.
- A replacement's output is now one allocation plus a copy per piece. It used to decode and re-encode every unit twice.
- The split loop polls once per 512 units rather than once per piece.
- A split with no capture groups no longer builds a capture array for every piece.
- A split's private output list appends without a catch frame while it has spare capacity.
- Measured on qb2 in instructions: `encodeURI(s).split(/%..|./)` −43%, a short global replace −15%. validator's `sanitize` and `batch` package workloads are −4.2% and −3.3%. Peak RSS is unchanged within 1%.
164 changes: 163 additions & 1 deletion crates/perry-runtime/src/regex/perex_replace_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,36 @@ impl<'a> List<'a> {
}
Ok(())
}

/// `push` for a list no code outside this operation has seen yet, such as
/// a `split` result still being built. Nothing can have frozen, sealed or
/// wrapped it, so an append that fits its capacity stores in place and
/// cannot throw; it skips the catch frame `push` sets up, which was about
/// a tenth of a short `split`. An append that must grow takes `push`.
pub(super) fn push_unseen(
&mut self,
value: f64,
budget: &mut Budget,
) -> Result<(), EngineError> {
let fits = self
.root
.with_const_ptr::<crate::array::ArrayHeader, _>(|array| unsafe {
(*array).length < (*array).capacity
});
if !fits {
return self.push(value, budget);
}
host::charge(budget, 1)?;
let array = self
.root
.with_mut_ptr(|array| crate::array::js_array_push_f64(array, value));
self.root.set_raw_mut_ptr(array);
self.count += 1;
if self.count % api::QUANTUM == 0 {
host::poll()?;
}
Ok(())
}
}

pub(super) fn call(
Expand Down Expand Up @@ -229,7 +259,7 @@ pub(super) fn call_native(
/// This value is therefore a measured trade rather than a bound inherited from
/// elsewhere: small enough to keep the collector's openings, large enough that
/// a piece of two or three units no longer buys a poll of its own.
const POLL_UNITS: usize = 512;
pub(super) const POLL_UNITS: usize = 512;

/// A reusable original-input reader. A read retains only Perex offsets across
/// collection, and adjacent reads do not repeat the initial Unicode seek.
Expand Down Expand Up @@ -531,12 +561,144 @@ impl<'a> Pieces<'a> {
}
Ok(())
}
/// `finish` for native pieces over an ASCII subject and template: every
/// piece is then a byte span already in the output's encoding, so the
/// output is one allocation and a copy per piece, instead of two passes
/// that decode and re-encode every unit through a cursor. `None` for any
/// other `Pieces`, which `finish` builds as before.
fn finish_ascii(
&self,
original: &RuntimeHandle<'_>,
template: Option<&RuntimeHandle<'_>>,
budget: &mut Budget,
) -> Result<Option<*mut StringHeader>, EngineError> {
let Some(native) = self.native.as_ref() else {
return Ok(None);
};
if self.list.len() != 0 {
return Err(EngineError::InvalidSpan);
}
let original_subject = subject(*original)?;
let template_subject = template.map(|t| subject(*t)).transpose()?;
let ascii_length = |bound: &BoundSubject<HeapSubject<'_>>| {
bound
.with_view(|input| input.ascii_bytes().map(<[u8]>::len))
.map_err(EngineError::Subject)
};
let Some(original_length) = ascii_length(&original_subject)? else {
return Ok(None);
};
let template_length = match template_subject.as_ref() {
Some(bound) => match ascii_length(bound)? {
Some(length) => length,
None => return Ok(None),
},
None => 0,
};
let mut total = 0usize;
for &(source, start, end) in &native.records {
let length = match source {
Source::Original => original_length,
Source::Template if template_subject.is_some() => template_length,
Source::Template => return Err(EngineError::InvalidSpan),
};
if start > end || end as usize > length {
return Err(EngineError::InvalidSpan);
}
total = total
.checked_add((end - start) as usize)
.ok_or(StorageError::Limit)?;
}
if total != self.units {
return Err(EngineError::InvalidSpan);
}
let limit = api::OUTPUT_BYTES.min(
u32::MAX as usize - crate::gc::GC_HEADER_SIZE - std::mem::size_of::<StringHeader>() - 7,
);
if total > limit || total > crate::string::MAX_STRING_LENGTH {
return Err(StorageError::Limit.into());
}
// The charge the two unit-by-unit passes made.
host::charge(budget, total.saturating_mul(2))?;
let scope = RuntimeHandleScope::new();
let output = api::caught(|| {
let (p, _) = crate::string::string_storage_alloc(total as u32);
unsafe {
crate::string::init_string_header(p, 0, 0, total as u32, 0, 0);
}
p
})?;
let output = scope.root_string_ptr(output);
// Nothing below allocates or collects: both subject views and the
// output's data pointer are reacquired inside one scope.
output.with_mut_ptr::<StringHeader, _>(|header| {
let data = crate::string::string_data(header).cast_mut();
let mut written = 0usize;
original_subject
.with_view(|original| {
let original = original.ascii_bytes().ok_or(EngineError::InvalidSpan)?;
let mut copy = |bytes: &[u8]| {
// Bounded by the total measured above.
unsafe {
std::ptr::copy_nonoverlapping(
bytes.as_ptr(),
data.add(written),
bytes.len(),
);
}
written += bytes.len();
};
match template_subject.as_ref() {
Some(template) => template
.with_view(|template| {
let template =
template.ascii_bytes().ok_or(EngineError::InvalidSpan)?;
for &(source, start, end) in &native.records {
let bytes = match source {
Source::Original => original,
Source::Template => template,
};
copy(&bytes[start as usize..end as usize]);
}
Ok::<(), EngineError>(())
})
.map_err(EngineError::Subject)?,
None => {
for &(_, start, end) in &native.records {
copy(&original[start as usize..end as usize]);
}
Ok(())
}
}
})
.map_err(EngineError::Subject)??;
if written != total {
return Err(EngineError::InvalidSpan);
}
unsafe {
crate::string::init_string_header(
header,
total as u32,
total as u32,
total as u32,
0,
0,
);
}
Ok::<(), EngineError>(())
})?;
Ok(Some(output.with_mut_ptr(|output| output)))
}

pub(super) fn finish(
&self,
original: &RuntimeHandle<'_>,
template: Option<&RuntimeHandle<'_>>,
budget: &mut Budget,
) -> Result<*mut StringHeader, EngineError> {
if let Some(output) = self.finish_ascii(original, template, budget)? {
return Ok(output);
}
let limit = api::OUTPUT_BYTES.min(
u32::MAX as usize - crate::gc::GC_HEADER_SIZE - std::mem::size_of::<StringHeader>() - 7,
);
Expand Down
37 changes: 37 additions & 0 deletions crates/perry-runtime/src/regex/perex_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,43 @@ impl From<StorageError> for EngineError {
}
}

/// The safepoint poll for a loop that emits many small pieces: once per
/// `POLL_UNITS` units of output, counting each piece as one more unit so a
/// run of empty or one-unit pieces still reaches it. A poll per piece runs the
/// budgeted trigger ladder for a handful of units each, which on a short
/// `split` was a third of the call; `perex_replace_storage::POLL_UNITS`
/// records why 512 keeps the collector's openings without a peak-RSS cost.
pub(crate) struct PieceStride {
unpolled: usize,
}

impl PieceStride {
pub(crate) const fn new() -> Self {
Self { unpolled: 0 }
}

/// Whether the piece of `units` just emitted brings the poll due, which it
/// then resets. The caller runs the poll.
pub(crate) fn due(&mut self, units: usize) -> bool {
self.unpolled = self.unpolled.saturating_add(units).saturating_add(1);
if self.unpolled >= super::perex_replace_storage::POLL_UNITS {
self.unpolled = 0;
true
} else {
false
}
}

/// Account for a piece and poll when that brings the poll due.
pub(crate) fn tick(&mut self, units: usize) -> Result<(), EngineError> {
if self.due(units) {
poll()
} else {
Ok(())
}
}
}

/// Normal runtime poll. Test/embedding callers may supply an alternative poll
/// that requests cancellation or forces actual collection. No input/program
/// view or scratch slice is live when any poll is invoked.
Expand Down
23 changes: 19 additions & 4 deletions crates/perry-runtime/src/regex/perex_split.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,29 @@ fn forward_split(
// Each search starts where the previous one stood, so on non-ASCII
// storage it does not seek from an end of the subject (#10164).
let mut near: Option<Position> = None;
// A piece is usually a few units; the loop polls once per POLL_UNITS of
// them rather than once per piece.
let mut stride = host::PieceStride::new();
// Without capture groups a piece needs only the match itself; asking for
// every capture built, filled and copied a slot array per piece to learn
// that there were none.
let mode = if forward
.with_view(|program| program.capture_count())
.map_err(EngineError::Program)?
> 1
{
CaptureMode::All
} else {
CaptureMode::Full
};
while q < size {
let local = RuntimeHandleScope::new();
let (found, position) = host::find_near(
forward,
bound,
q,
near,
CaptureMode::All,
mode,
budget,
memory,
api::QUANTUM,
Expand All @@ -160,7 +175,7 @@ fn forward_split(
// Only an empty match at `p` itself: step past it, as the
// sticky loop does.
q = advance(&mut units, start, size, unicode, budget)?;
host::poll()?;
stride.tick(0)?;
continue;
}
push_span(&mut output, &mut copies, p, start, budget)?;
Expand Down Expand Up @@ -196,8 +211,8 @@ fn forward_split(
}
}
}
stride.tick(p - q)?;
q = p;
host::poll()?;
}
push_span(&mut output, &mut copies, p, size, budget)?;
charged(budget);
Expand All @@ -212,7 +227,7 @@ fn push_span(
budget: &mut Budget,
) -> Result<(), EngineError> {
let result = copies.copy(start, end, budget)?;
output.push(js_nanbox_string(result as i64), budget)
output.push_unseen(js_nanbox_string(result as i64), budget)
}

/// Split by an untouched RegExp without its protocol Gets (#10518).
Expand Down
31 changes: 31 additions & 0 deletions crates/perry-runtime/src/regex/perex_strings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,16 +244,26 @@ fn copy_ascii_span(
/// repeatedly from the beginning of a non-ASCII input. Only offsets survive GC.
pub(super) struct SpanCopies<'a, 's> {
readers: [BoundSpan<'a, HeapSubject<'s>>; 2],
/// The subject, when it is ASCII: a piece is then one byte copy, as
/// `copy_ascii_span` makes for a capture, instead of two unit-by-unit
/// passes and two polls.
ascii: Option<&'a BoundSubject<HeapSubject<'s>>>,
stride: super::perex_runtime::PieceStride,
}

impl<'a, 's> SpanCopies<'a, 's> {
pub(super) fn new(subject: &'a BoundSubject<HeapSubject<'s>>) -> Result<Self, EngineError> {
let empty = Span::new(0, 0).unwrap();
let ascii = subject
.with_view(|input| input.ascii_bytes().is_some())
.map_err(EngineError::Subject)?;
Ok(Self {
readers: [
BoundSpan::new(subject, empty).map_err(|e| read_error(e, |n| match n {}))?,
BoundSpan::new(subject, empty).map_err(|e| read_error(e, |n| match n {}))?,
],
ascii: ascii.then_some(subject),
stride: super::perex_runtime::PieceStride::new(),
})
}

Expand All @@ -264,6 +274,27 @@ impl<'a, 's> SpanCopies<'a, 's> {
budget: &mut Budget,
) -> Result<*mut StringHeader, EngineError> {
let span = Span::new(start, end).ok_or(EngineError::InvalidSpan)?;
if let Some(subject) = self.ascii {
// One poll per `POLL_UNITS` units of pieces rather than two per
// piece; the copy itself allocates through the ordinary path.
let due = self.stride.due(span.len());
let mut poll = || {
if due {
super::perex_runtime::poll()
} else {
Ok(())
}
};
if let Some(output) = copy_ascii_span(
subject,
span,
budget,
super::perex_api::OUTPUT_BYTES,
&mut poll,
)? {
return Ok(output);
}
}
for reader in &mut self.readers {
reader
.retarget(span)
Expand Down
Loading
Loading