Skip to content

Commit ee168e7

Browse files
authored
Change the internal structure of JsString for performance (#4455)
This is the first in a series of PR to improve the performance of strings in Boa. The first step was to introduce a new type of strings, `SliceString`, which contains a strong pointer to another string and start/end indices. This allows for very fast slicing of strings. This initially came at a performance cost by having an enumeration of kinds of strings. An intermediate experiment was introduced to have the kind be a tag on the internal JsString pointer. This still came as a cost as it required bit operations to figure out which function to call. Finally, I moved to using a `vtable`. This helped with many points: 1. as fast as before. Before this PR, there was still a deref of a pointer when accessing internal fields. 2. we can now introduce many other types (which will come in their separate PRs). 3. this makes the code to clone/drop/as_str (and even construction) more streamline as each function is their own implementation. The biggest drawback is now we are operating with a bit more pointer and unsafe code. I made sure that all code was passing both debug, release and miri tests on boa_string and boa_engine's builtins::string suite. This `vtable` bring slightly better performance (depending on the test, about 90-110%, but overall 2-3% better than the `main` branch), even though we only directly improved string slicing operations. The next suite of PRs are going to cover more string types: 1. since strings are constant and unmutable, include the length in the vtable directly instead of relying on a function call. 2. also because of the above, splitting latin1 and utf-16 string kinds to remove conditionals on a lot of sub functions. 3. have a concat string that makes concatenation instant, similar to how slice improve concatenation Also, there might still be some slicing that creates new strings from existing ones without using SliceString. Those can be improved greatly. Please note the `benches/scripts/strings/slice.js` test is twice as fast with this branch than with `main`. The performance table for this PR: | Test | Main | PR | |---|-----|-----| | Richards | 218 | 233 | | DeltaBlue | 234 | 244 | | Crypto | 184 | 208 | | RayTrace | 502 | 485 | | EarleyBoyer | 595 | 584 | | RegExp | 88.1 | 83 | | Splay | 905 | 837 | | NavierStokes | 413 | 467 | |---| --- | --- | | TOTAL | 313 | 320 |
1 parent 14e5c63 commit ee168e7

File tree

22 files changed

+1626
-1014
lines changed

22 files changed

+1626
-1014
lines changed

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ members = [
1414
"cli",
1515
# UTILS
1616
"utils/*",
17+
# BENCHES
18+
"benches",
1719
]
1820

1921
exclude = [
2022
"tests/fuzz", # Does weird things on Windows tests
21-
"tests/src", # Just a hack to have fuzz inside tests
22-
"tests/wpt", # Should not run WPT by default.
23+
"tests/src", # Just a hack to have fuzz inside tests
24+
"tests/wpt", # Should not run WPT by default.
2325
]
2426

2527
[workspace.package]

benches/Cargo.toml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[package]
2+
name = "boa_benches"
3+
version = "0.1.0"
4+
edition = "2024"
5+
publish = false
6+
7+
[dependencies]
8+
boa_engine.workspace = true
9+
boa_runtime.workspace = true
10+
11+
[dev-dependencies]
12+
criterion.workspace = true
13+
walkdir = "2"
14+
15+
[target.x86_64-unknown-linux-gnu.dev-dependencies]
16+
jemallocator.workspace = true
17+
18+
[[bench]]
19+
name = "scripts"
20+
harness = false

benches/benches/scripts.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#![allow(unused_crate_dependencies, missing_docs)]
2+
use boa_engine::{
3+
Context, JsValue, Source, js_string, optimizer::OptimizerOptions, script::Script,
4+
};
5+
use criterion::{Criterion, criterion_group, criterion_main};
6+
use std::path::Path;
7+
8+
#[cfg(all(target_arch = "x86_64", target_os = "linux", target_env = "gnu"))]
9+
#[global_allocator]
10+
static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc;
11+
12+
fn bench_scripts(c: &mut Criterion) {
13+
let scripts_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts");
14+
15+
let scripts: Vec<_> = walkdir::WalkDir::new(&scripts_dir)
16+
.into_iter()
17+
.filter_map(|e| e.ok())
18+
.filter(|e| e.path().extension().is_some_and(|ext| ext == "js"))
19+
.collect();
20+
21+
for entry in scripts {
22+
let path = entry.path();
23+
let code = std::fs::read_to_string(path).unwrap();
24+
25+
// Create a nice benchmark name from the relative path
26+
let name = path
27+
.strip_prefix(&scripts_dir)
28+
.unwrap()
29+
.with_extension("")
30+
.display()
31+
.to_string();
32+
33+
let context = &mut Context::default();
34+
35+
// Disable optimizations
36+
context.set_optimizer_options(OptimizerOptions::empty());
37+
38+
// Register runtime for console.log support
39+
boa_runtime::register(
40+
boa_runtime::extensions::ConsoleExtension(boa_runtime::NullLogger),
41+
None,
42+
context,
43+
)
44+
.expect("Runtime registration failed");
45+
46+
// Parse and compile once, outside the benchmark loop
47+
let script = Script::parse(Source::from_bytes(&code), None, context).unwrap();
48+
script.codeblock(context).unwrap();
49+
50+
// Evaluate once to define the main function
51+
script.evaluate(context).unwrap();
52+
53+
// Get the main function
54+
let function = context
55+
.global_object()
56+
.get(js_string!("main"), context)
57+
.unwrap_or_else(|_| panic!("No main function defined in script: {}", path.display()))
58+
.as_callable()
59+
.unwrap_or_else(|| panic!("'main' is not a function in script: {}", path.display()))
60+
.clone();
61+
62+
c.bench_function(&format!("{name} (Execution)"), |b| {
63+
b.iter(|| function.call(&JsValue::undefined(), &[], context));
64+
});
65+
}
66+
}
67+
68+
criterion_group!(benches, bench_scripts);
69+
criterion_main!(benches);

benches/scripts/strings/slice.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// This script should take a few seconds to run.
2+
const kIterationCount = 10_000;
3+
const base = "abcdefghijklmnopqrstuvwxyz".repeat(10_000_000);
4+
5+
function main() {
6+
for (let i = 0; i < kIterationCount; i++) {
7+
base.slice(i * 100, i * 100 + 20000);
8+
}
9+
}

benches/scripts/strings/split.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// This script should take a few seconds to run.
2+
const kIterationCount = 10_000;
3+
const base = "abcdefghijklmnopqrstuvwxyz".repeat(1_000);
4+
5+
function main() {
6+
const k = base.split("a").length;
7+
console.log(k);
8+
}

benches/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
#![allow(unused_crate_dependencies)]

core/engine/src/builtins/string/mod.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -784,7 +784,8 @@ impl String {
784784
Ok(js_string!().into())
785785
} else {
786786
// 13. Return the substring of S from from to to.
787-
Ok(js_string!(string.get_expect(from..to)).into())
787+
// SAFETY: We already checked that `from` and `to` are within bounds.
788+
Ok(unsafe { JsString::slice_unchecked(&string, from, to).into() })
788789
}
789790
}
790791

@@ -1887,7 +1888,8 @@ impl String {
18871888
let to = max(final_start, final_end);
18881889

18891890
// 10. Return the substring of S from from to to.
1890-
Ok(js_string!(string.get_expect(from..to)).into())
1891+
// SAFETY: We already checked that `from` and `to` are within bounds.
1892+
Ok(unsafe { JsString::slice_unchecked(&string, from, to).into() })
18911893
}
18921894

18931895
/// `String.prototype.split ( separator, limit )`
@@ -1983,7 +1985,7 @@ impl String {
19831985
while let Some(index) = j {
19841986
// a. Let T be the substring of S from i to j.
19851987
// b. Append T as the last element of substrings.
1986-
substrings.push(this_str.get_expect(i..index).into());
1988+
substrings.push(this_str.slice(i, index));
19871989

19881990
// c. If the number of elements of substrings is lim, return ! CreateArrayFromList(substrings).
19891991
if substrings.len() == lim {
@@ -2002,7 +2004,7 @@ impl String {
20022004

20032005
// 15. Let T be the substring of S from i.
20042006
// 16. Append T to substrings.
2005-
substrings.push(JsString::from(this_str.get_expect(i..)));
2007+
substrings.push(this_str.slice(i, this_str.len()));
20062008

20072009
// 17. Return ! CreateArrayFromList(substrings).
20082010
Ok(

core/engine/src/object/internal_methods/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -564,7 +564,8 @@ pub(crate) fn ordinary_set_prototype_of(
564564
// c. Else,
565565
// i. If p.[[GetPrototypeOf]] is not the ordinary object internal method defined
566566
// in 10.1.1, set done to true.
567-
else if proto.vtable().__get_prototype_of__ as usize != ordinary_get_prototype_of as usize
567+
else if proto.vtable().__get_prototype_of__ as usize
568+
!= ordinary_get_prototype_of as *const () as usize
568569
{
569570
break;
570571
}

core/engine/src/string.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,8 @@ macro_rules! js_string {
5555
$crate::string::JsString::default()
5656
};
5757
($s:literal) => {const {
58-
const LITERAL: &$crate::string::JsStr<'static> = &$crate::js_str!($s);
59-
60-
$crate::string::JsString::from_static_js_str(LITERAL)
58+
const LITERAL: $crate::string::StaticString = $crate::string::StaticString::new($crate::js_str!($s));
59+
$crate::string::JsString::from_static(&LITERAL)
6160
}};
6261
($s:expr) => {
6362
$crate::string::JsString::from($s)

0 commit comments

Comments
 (0)