In the blog post investigating Omoikane’s CI wait times, we wrote about avoiding heavy verification for changes that only affect documentation and about revisiting the build cache. Separate from that, the tests that actually needed to be run were also taking a considerable amount of time. (source)
Omoikane uses Boa to execute JavaScript. Each time a JavaScript realm is created for a page or an iframe, a large script that sets up the DOM API is run. This processing accounted for a substantial portion of the test runtime. In Issue #1154, instead of reducing the number of tests or realms, we investigated this initialization itself.
To start with the findings, on the macOS test profile the bootstrap processing when creating a realm dropped from about 0.58 seconds to about 0.13 seconds. However, this is not the result of measuring page load times in a release build. Below, we’ll record what was measured and what changes were made, step by step.
What happened each time a realm was created
A realm is an execution unit that has its own built‑in JavaScript objects and global environment, and Omoikane needs one for each page or iframe. Details about realms themselves were covered in a previous article. (source)
When a new realm is created, Omoikane loads a bootstrap that makes the DOM available to JavaScript. The concatenated source consisting of dom_bootstrap.js, xpath.js, font_loading.js, and find_in_page.js is about 1.1 MB. evaluate_dom_bootstrap parses that source as a module and proceeds through load, link, and evaluate. It also checks during this initialization whether any internal host bindings are being exposed to the page’s global object.
Create a Realm
↓
bindings.module_source() ≈ 1 ms
↓
Module::parse ≈ 335 ms
↓
module.load + link ≈ 240 ms
↓
module.evaluate ≈ 10–25 ms
This shows the approximate breakdown from a single run before the change. It takes about 0.58 seconds in total. In particular, parse and load/link were the heaviest. Although the bootstrap source is almost identical each time, the parsing and compilation are repeated for every realm.
When we inserted temporary measurement code and ran all of the library’s unit tests, the bootstrap was called a total of 1,414 times across the 1,069 tests. Multiplying by 0.58 seconds yields roughly 820 seconds. The CPU time for the library tests was about 1,160 seconds, so roughly 70 % of that corresponds to this initialization. In contrast, the wall‑clock time was 160–222 seconds. Because the tests run in parallel, CPU time and wall‑clock time do not match.
In fact, a test that creates 25 realms took 16.1 seconds on its own, while a test that creates 13 realms took 7.8 seconds. Even if you eliminate just the slowest test, the underlying cost remains. So we considered both “avoid processing the bootstrap repeatedly” and “if it must be processed, make the processing itself faster.”
The first idea was to cache the parsed module
The initial sub‑issue we opened, #1155, proposed caching the already‑parsed bootstrap module within the process. Since the same source is handed to Module::parse repeatedly, reusing the AST that was built once would be straightforward. (issue #1155)
However, Boa’s parsing results cannot be shared directly between threads. The AST’s Scope includes Rc and RefCell, and the AST’s symbols also depend on the interners for each Context. The libtest runs each test in a separate thread. Of the 1,414 measured invocations, only 345 involved creating a realm a second time within the same thread. For the remaining cases, a cache limited to a single thread would have no effect.
Before moving forward with a “just cache it” implementation, we took a profile and discovered another issue. Higher up in the parsing and compilation pipeline, there was a JsString comparison. The caller was performing name lookups for bindings during Boa’s scope analysis and bytecode generation.
Repeated linear searches over large scopes
When resolving JavaScript variable or function names, the parser and compiler examine the scope’s bindings. In Boa, those bindings are stored in a Vec ordered by declaration, and each name lookup starts comparing from the beginning of the list. This is sufficient for small function scopes, but the top‑level of the DOM bootstrap module contains a huge number of bindings.
Each additional name reference forces another scan of the long list. If the number of bindings is B and the number of references is R, the work for this part grows roughly as R × B. Processing the roughly 1.1 MB source for each realm incurs that cost every time.
In PR #1160, we retained the declaration‑ordered Vec and added a FxHashMap that maps a name to its position. However, we didn’t give every scope a hash table from the start. When a scope has 16 or fewer bindings, we continue to use a linear search; we only build an index when the 17th binding is added. This avoids the extra cost of maintaining an index for ordinary small scopes.
Bindings are only added; they are never removed or renamed. Therefore, the positions stored in the index (Vec) never become invalid later. The declaration order, binding index, references from outer scopes, redeclarations, and handling of escapes are left unchanged. We also tested that these aspects remain consistent across the threshold.
The result was not limited to merely shortening parse. Because name lookups are used not only during scope analysis but also during bytecode generation, load and link also became significantly faster.
| Process / Measurement | Before | After scope indexing |
|---|---|---|
Bootstrap Module::parse | ~335 ms | ~115 ms |
Bootstrap load + link | ~240 ms | ~25 ms |
| CPU time for lib unit tests | 1160 seconds | 427 seconds |
| Wall‑clock time for lib unit tests | 160–222 seconds | 63 seconds |
These measurements were taken on an 8‑core macOS test profile. Because the pre‑change wall‑clock times for the lib tests vary, it’s best not to interpret the ratios as exact multipliers for a single condition. Still, the fact that we improved not only the parsing side—which would have been left unchanged by a pure‑parsing‑cache approach—but also the compilation side is a major gain.
In the next profiling, GC appeared as the main culprit
Even after indexing the scopes, creating a realm still took about 150 ms each time. When we observed the test node_lifetime, which creates many realms up front, on an macOS sample, the GC trace and ephemeron registrations stood out as large contributors. Accordingly, we opened sub‑issue #1163 to investigate GC‑related overhead. (issue #1163)
However, this test deliberately triggers the garbage collector to verify lifetime management, making it a confusing metric for measuring the pure cost of realm creation. When we switched to a harness that repeatedly runs only JsRuntime::new(), GC accounted for about 2.6 % and hashbrown about 2.1 %. Applying the initial observation that “GC is large” directly to ordinary realm creation was therefore incorrect.
We stopped the linker from merging identical functions to ensure symbols appeared correctly, and reran the test with frame pointers enabled; the remaining overhead turned out to be elsewhere.
- Even ASCII identifiers were using the ICU Unicode character‑property tables.
- The lexical analyzer’s
UTF8Inputwas processing each byte viaio::Bytesusingread. - Each time a character was consumed from the four‑element look‑ahead array, a generic
slice::rotate_leftroutine was invoked. var_scoped_declarationsduplicated the body of a function declaration and returned it, causing repeated work during scope analysis and bytecode generation.
While it’s a minor issue for a single pass, repeatedly processing a 1.1 MB JavaScript source accumulates overhead. In PR #1165 we changed the handling so that ASCII characters are checked directly, while non‑ASCII characters continue to use the traditional Unicode detection. UTF8Input is given an 8 KiB buffer, and the look‑ahead array copies its elements directly. We added an internal API that returns a reference for collecting var declarations, while preserving the existing public API. (PR #1165)
We tested that identifier detection matches the traditional Unicode checks for all ASCII characters and a representative set of non‑ASCII characters. The reading logic has also been verified for cases where multibyte characters cross a read boundary, for Interrupted, and for readers that return small chunks. This verification ensures that the speed improvements do not alter the JavaScript parsing results.
Measurements are presented in stages
The measurements up to the latest change are shown side by side using the same macOS 8‑core test profile.
| Item | Initial | After scope indexing | After lexical‑analysis and other improvements |
|---|---|---|---|
| Bootstrap processing at Realm creation | ~0.58 s | ~0.15 s | ~0.13 s |
| CPU time for lib unit tests | ~1160 s | 427 s | 360 s |
| Real time for lib unit tests | 160–222 s | 63 s | 53 s |
| Total execution time of all test binaries | ~323 s | ~161 s | ~136 s |
The ~0.13 s in the final column comes from repeated measurements of JsRuntime::new(), which yielded roughly 126–128 ms, rounded to that value. The initial ~0.58 s is an estimate that adds up the components of the bootstrap processing, so a row in the table should not be treated as a strict three‑point comparison using the same harness. CPU time, real time, and the total execution time of the test binaries are all different metrics. In particular, this does not mean that the compilation time for cargo test has dropped to 136 seconds.
In the final PR, cargo test --locked reported 3,297 passed, 0 failed, and 28 ignored, and the Boa‑side tests for the AST, parser, garbage collector, and engine, as well as the baseline JIT tests, all passed. This speedup was not achieved by sacrificing Realm isolation or the private nature of host bindings.
Some ideas remain
In addition to caching the parsed AST, we also explored sharing pre‑compiled bytecode in child Issue #1156. Boa’s CodeBlock is managed under the garbage collector, with heap and root registrations being thread‑local, and even constants contain Scope as well as Rc/RefCell. It cannot be shared directly between tests running on different threads. There is some technical feasibility when the same thread and the same Context are used, but a module record is required per Realm, and we must also design how to handle inline caches and JIT state. After scope indexing, load and link take about 25 ms, so even if we captured all 345 instances that could be shared within a test, the maximum possible reduction was roughly nine seconds. We did not implement this this time. (issue #1156)
We also considered the idea of not loading unused Web APIs initially, which was examined in child Issue #1157. However, the bootstrap is a large immediately‑invoked function, and many declarations share private variables. Splitting it would require rewriting dependencies. If we defer loading with a JavaScript getter, the behavior visible from outside would change—for example, before the first access, Object.getOwnPropertyDescriptor would return an accessor rather than a data property. Comparing the expected reduction in runtime with the scale of the changes, we decided not to implement this either. (issue #1157)
This improvement was not achieved by adding a mechanism to cache the massive bootstrap. Instead, we set aside the initial proposal, re‑measured Boa’s “how to look up names in a large scope” and “how to read a single character during lexical analysis,” and made changes based on those results. In particular, it was important to be able to retake the first profile—where garbage collection was heavy—by isolating it from the test‑specific conditions.
Since the same initialization occurs when creating pages or iframes, this could also affect real‑world usage. However, we have not performed a before‑and‑after comparison of the time to create a new page and a new iframe in a release build. The original issue had that as a completion criterion, but we closed it without measuring this time. What can be said here is that, in the test profile, Realm creation and test execution have become faster, and the existing verification still passes.