perf(bundle-source): cut multi-entry agoric bundling time and add detailed profiling#3099
perf(bundle-source): cut multi-entry agoric bundling time and add detailed profiling#3099turadg wants to merge 22 commits into
Conversation
|
There was a problem hiding this comment.
Pull request overview
This PR introduces significant performance optimizations for @endo/bundle-source targeting multi-entry bundling workloads, achieving a reported ~43% speedup on agoric-sdk bundling tasks. The changes focus on three main areas: profiling infrastructure, caching mechanisms, and algorithmic optimizations.
Changes:
- Adds comprehensive Chrome trace profiling with tooling for aggregation and analysis
- Implements multiple caching layers: process-global read cache, parse result cache, canonical path memoization, and import hook read cache
- Optimizes graph shortest-path computation by computing Dijkstra once from source
- Adds fast-path optimization in evasive-transform to skip parsing when no risky patterns are detected
- Enhances ZIP writing with pre-allocation and profiling instrumentation
Reviewed changes
Copilot reviewed 33 out of 34 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/bundle-source/src/profile.js | New profiling module with Chrome trace output |
| packages/bundle-source/src/zip-base64.js | Adds process-global read cache and profiling integration |
| packages/bundle-source/src/script.js | Integrates profiling into script bundling path |
| packages/bundle-source/src/endo.js | Adds profiling spans to transform and type-erasure steps |
| packages/bundle-source/tools/profile-agoric-bundling.mts | Tool for profiling agoric-sdk bundle workloads |
| packages/bundle-source/tools/trace-merge.js | Merges and summarizes multiple trace files |
| packages/bundle-source/test/profiling.test.js | Basic profiling functionality test |
| packages/compartment-mapper/src/parse-archive-mjs.js | Adds parse result caching with 20k entry limit |
| packages/compartment-mapper/src/node-powers.js | Adds canonical path memoization and concurrent read limiting |
| packages/compartment-mapper/src/import-hook.js | Adds per-operation read cache and suffix expansion optimization |
| packages/compartment-mapper/src/generic-graph.js | New makeShortestPathFromSource for single-source shortest paths |
| packages/compartment-mapper/src/node-modules.js | Uses optimized single-source Dijkstra algorithm |
| packages/compartment-mapper/src/archive-lite.js | Adds profiling and optimizes synchronous write detection |
| packages/evasive-transform/src/index.js | Adds fast-path to skip transform when no risky patterns detected |
| packages/zip/src/writer.js | Pre-allocates buffer and adds profiling support |
| packages/zip/src/format-writer.js | Adds profiling spans and optimizes record building |
| packages/zip/tools/benchmark-writer.mjs | New ZIP writer microbenchmark tool |
Comments suppressed due to low confidence (6)
packages/compartment-mapper/src/import-hook.js:669
- The cached read promise is removed from cache on rejection (line 666), but successful reads are never removed. This cache is scoped to a single mapping run via makeImportHookMaker, so it's bounded by the lifetime of a single bundle operation. However, there's no maximum size limit, so a bundle with thousands of modules could accumulate all successful reads in memory without bound during that operation.
const cachedMaybeRead = location => {
const cached = maybeReadCache.get(location);
if (cached !== undefined) {
return cached;
}
const pending = Promise.resolve(maybeRead(location));
maybeReadCache.set(location, pending);
pending.catch(() => {
maybeReadCache.delete(location);
});
return pending;
};
packages/bundle-source/src/zip-base64.js:62
- The cache eviction in cacheReadValue uses FIFO (first-in-first-out) by removing the first entry from the Map iterator. However, JavaScript Map iteration order is insertion order, not access order. This means frequently accessed entries can be evicted before rarely accessed ones. Consider using an LRU (Least Recently Used) eviction policy for better cache hit rates, or at minimum document this FIFO behavior.
while (cachedReadBytes > readCacheMaxBytes && cachedReads.size > 0) {
const oldestKey = cachedReads.keys().next().value;
if (oldestKey === undefined) {
break;
}
const value = cachedReads.get(oldestKey);
cachedReads.delete(oldestKey);
if (value !== undefined) {
cachedReadBytes -= value.length;
}
}
packages/bundle-source/src/script.js:112
- Inconsistent indentation: lines 111-112 have different indentation from surrounding lines (lines 110 and 113-129). Line 111 should be indented to align with line 110, and line 112 should be indented to align with line 113.
commonDependencies,
profileStartSpan: profiler.startSpan,
packages/compartment-mapper/src/archive-lite.js:102
- The duck-typing check for Promise (lines 97-101) is fragile. It checks for an object with a
thenproperty that's a function, but this will incorrectly treat any thenable (including user-defined thenables or objects that happen to have athenmethod) as a Promise. UsemaybeWrite instanceof PromiseorPromise.resolve(maybeWrite) === maybeWritefor more robust Promise detection, or check formaybeWrite?.constructor?.name === 'Promise'.
if (
maybeWrite &&
typeof maybeWrite === 'object' &&
'then' in maybeWrite &&
typeof maybeWrite.then === 'function'
) {
packages/bundle-source/src/profile.js:10
- The
nextTraceFileIdcounter at module scope will never reset and could theoretically overflow after 2^53 trace files in a long-running process. While extremely unlikely in practice, consider resetting it periodically or using a timestamp-based approach instead. Alternatively, document that this is acceptable given the improbability of reaching Number.MAX_SAFE_INTEGER trace files.
let nextTraceFileId = 0;
packages/zip/src/writer.js:20
- The size estimation assumes ASCII-only filenames (comment on line 18-19 acknowledges this). However, for non-ASCII paths, the BufferWriter may need to grow significantly. Consider using
TextEncoder.encode(file.name).lengthfor more accurate size estimation of UTF-8 encoded paths, especially since the actual encoding uses UTF-8.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
e9cc5a: builder mirrors endojs/endo#3099 (bundle-source perf + profiling) onto master. 9ecbfc: designer authors stacked siblings of designs/gateway-package.md — packaging-CI + AWS deployment (+ optional AWS-attuned variant).
…ts@master as draft PR #355
…ailed profiling Squashed mirror of endojs/endo#3099 (10 commits by Turadg Aleahmad, 2026-02 to 2026-02-19) collapsing the profiling work into one commit for mirror-side rebase tractability. Speed up @endo/bundle-source for multi-entrypoint workloads (notably agoric-sdk) and add richer profiling spans across compartment-mapper, evasive-transform, module-source, and zip. Optimizes node-modules graph finalization, adds cross-bundle parser reuse in archive parsing, improves the archive/zip path, and adds zip microbenchmark tooling. Measured on yarn workspace @endo/bundle-source profile:agoric-bundling: 13180ms baseline to 7538ms latest, a 42.8% reduction. Refs: endojs/endo#3099
Three logical items the panel surfaced on the cleaner head 2586a99, all flowing from type-narrowness gaps the perf instrumentation introduced: 1. packages/bundle-source/src/profile.js: type the disabled-profiler noop's startSpan and flush to the same shape published by BundlingKitOptions.profiler in types.ts; the discriminated noop now accepts Record<string, unknown> args like the live profiler. Closes TS2322 at script.js:93,115 and zip-base64.js:188,204,227; closes TS2345 at script.js:171 and zip-base64.js:264. 2. packages/zip/src/writer.js: replace `Array<ZFile>` with `Array<ArchivedFile>` via a top-level `@import` from types.js, fix the orphaned `/** type {Map<string, ZFile>} */` JSDoc tag (missing leading @) on this.files, and propagate the type through the writeZip declarations. Closes TS2552. Drive-by: widen the write() options.date to `Date | null` and default to null so the assignment to ArchivedFile.date (which is Date?) typechecks; add the missing `type: 'file'` discriminant on the ArchivedFile record. 3. packages/compartment-mapper/src/import-hook.js, packages/compartment-mapper/src/map-parser.js, packages/bundle-source/src/zip-base64.js: seed `moduleBytes`, `language`, `endoZipBase64` with explicit `undefined` defaults at declaration (and widen the type to `T | undefined` where needed) so the `finally`-block reads pass TS flow analysis. Closes TS2454 at all three sites. Gate-driven collateral (no behavior change): - packages/compartment-mapper/src/import-hook.js: lift the inline `import('ses').Harden` JSDoc to a top-level `@import {Harden} from 'ses'` to satisfy no-inline-import-jsdoc. - packages/evasive-transform/src/index.js: lift the inline `import('./parse-ast.js').SourceType` and `import('@babel/traverse').NodePath` JSDoc into the existing top-level `@import` block. - packages/bundle-source/README.md: split the two-sentence physical line "Each bundle call writes one *.trace.json file. Open these in Chrome tracing tools..." per the sentence-per-line markdown style. Refs: endojs/endo#3099
Seven of the eight items the panel surfaced as `summary-fix` on the cleaner head 2586a99. Item 8 (the orphaned `/** type {Map<string, ZFile>} */` JSDoc tag at zip/src/writer.js:37) shipped in the must-fix commit alongside the ZFile -> ArchivedFile rename it interacts with. 1. packages/bundle-source/src/profile.js: when ENDO_BUNDLE_SOURCE_PROFILE_STDERR is truthy, emit the resolved trace path at profiler construction (one line), not only at successful flush. Surfaces an auto-resolved `os.tmpdir()` fallback path early enough for the user to redirect or pre-create the directory when the default is non-writable. 2. packages/bundle-source/src/zip-base64.js: warn to stderr when `ENDO_BUNDLE_SOURCE_READ_CACHE_MAX_BYTES` has a non-numeric tail (e.g., "100mb") that `Number.parseInt` would silently truncate. The README documents the env as a raw byte count; surface the truncation rather than honoring a misleading value. 3. packages/bundle-source/src/zip-base64.js: document the insertion-order (FIFO) eviction discipline of `cachedReads` in a file-header comment. Cache hits do not promote; under a working set larger than `readCacheMaxBytes` the cache degrades gracefully but is not classic LRU. Documenting the choice rather than implementing LRU because the agoric-sdk workload the cache was sized for fits within the default cap. 4. packages/compartment-mapper/src/parse-archive-mjs.js: lift the hardcoded `MAX_PARSE_ARCHIVE_MJS_CACHE_ENTRIES = 20_000` to an env override (`ENDO_PARSE_ARCHIVE_MJS_CACHE_ENTRIES`) and emit a one-time stderr warning on cap-hit so a workload exceeding the default surface the situation rather than silently thrashing the cache to one entry. 5. packages/bundle-source/README.md: document `ENDO_BUNDLE_SOURCE_READ_CACHE_MAX_BYTES` in the Profiling section's env list (default 64 MiB, `0` disables); also tighten the STDERR var description to match the now-construction-time announce semantics. 6. packages/bundle-source/src/profile.js: append a 4-byte random hex suffix to the trace filename so concurrent bundle calls from sibling processes in the same millisecond on the same pid (or different pids landing on the same Date.now() tick) cannot collide in the shared traceDir. 7. packages/evasive-transform/test/evade-censor.test.js: three asserts pinning the cleaner's widened `importLikePattern` (`\bimport\s*(?:\(|\/[/*])`): `import (` (space-paren) in a string literal, `import //` (line comment), `import /*` (block comment). Each embeds the risky token inside a position SES rejects unless the censor has already escaped it; the assert checks the slow path ran by confirming the output differs from the input. Plus the gate-driven `@import` lift of the inline `import('./types.js').ParserImplementation` JSDoc in parse-archive-mjs.js (the file touched by item 4 had a pre-existing inline import the no-inline-import-jsdoc probe surfaced). Refs: endojs/endo#3099
…ailed profiling Squashed mirror of endojs/endo#3099 (10 commits by Turadg Aleahmad, 2026-02 to 2026-02-19) collapsing the profiling work into one commit for mirror-side rebase tractability. Speed up @endo/bundle-source for multi-entrypoint workloads (notably agoric-sdk) and add richer profiling spans across compartment-mapper, evasive-transform, module-source, and zip. Optimizes node-modules graph finalization, adds cross-bundle parser reuse in archive parsing, improves the archive/zip path, and adds zip microbenchmark tooling. Measured on yarn workspace @endo/bundle-source profile:agoric-bundling: 13180ms baseline to 7538ms latest, a 42.8% reduction. Refs: endojs/endo#3099
Three logical items the panel surfaced on the cleaner head 2586a99, all flowing from type-narrowness gaps the perf instrumentation introduced: 1. packages/bundle-source/src/profile.js: type the disabled-profiler noop's startSpan and flush to the same shape published by BundlingKitOptions.profiler in types.ts; the discriminated noop now accepts Record<string, unknown> args like the live profiler. Closes TS2322 at script.js:93,115 and zip-base64.js:188,204,227; closes TS2345 at script.js:171 and zip-base64.js:264. 2. packages/zip/src/writer.js: replace `Array<ZFile>` with `Array<ArchivedFile>` via a top-level `@import` from types.js, fix the orphaned `/** type {Map<string, ZFile>} */` JSDoc tag (missing leading @) on this.files, and propagate the type through the writeZip declarations. Closes TS2552. Drive-by: widen the write() options.date to `Date | null` and default to null so the assignment to ArchivedFile.date (which is Date?) typechecks; add the missing `type: 'file'` discriminant on the ArchivedFile record. 3. packages/compartment-mapper/src/import-hook.js, packages/compartment-mapper/src/map-parser.js, packages/bundle-source/src/zip-base64.js: seed `moduleBytes`, `language`, `endoZipBase64` with explicit `undefined` defaults at declaration (and widen the type to `T | undefined` where needed) so the `finally`-block reads pass TS flow analysis. Closes TS2454 at all three sites. Gate-driven collateral (no behavior change): - packages/compartment-mapper/src/import-hook.js: lift the inline `import('ses').Harden` JSDoc to a top-level `@import {Harden} from 'ses'` to satisfy no-inline-import-jsdoc. - packages/evasive-transform/src/index.js: lift the inline `import('./parse-ast.js').SourceType` and `import('@babel/traverse').NodePath` JSDoc into the existing top-level `@import` block. - packages/bundle-source/README.md: split the two-sentence physical line "Each bundle call writes one *.trace.json file. Open these in Chrome tracing tools..." per the sentence-per-line markdown style. Refs: endojs/endo#3099
Seven of the eight items the panel surfaced as `summary-fix` on the cleaner head 2586a99. Item 8 (the orphaned `/** type {Map<string, ZFile>} */` JSDoc tag at zip/src/writer.js:37) shipped in the must-fix commit alongside the ZFile -> ArchivedFile rename it interacts with. 1. packages/bundle-source/src/profile.js: when ENDO_BUNDLE_SOURCE_PROFILE_STDERR is truthy, emit the resolved trace path at profiler construction (one line), not only at successful flush. Surfaces an auto-resolved `os.tmpdir()` fallback path early enough for the user to redirect or pre-create the directory when the default is non-writable. 2. packages/bundle-source/src/zip-base64.js: warn to stderr when `ENDO_BUNDLE_SOURCE_READ_CACHE_MAX_BYTES` has a non-numeric tail (e.g., "100mb") that `Number.parseInt` would silently truncate. The README documents the env as a raw byte count; surface the truncation rather than honoring a misleading value. 3. packages/bundle-source/src/zip-base64.js: document the insertion-order (FIFO) eviction discipline of `cachedReads` in a file-header comment. Cache hits do not promote; under a working set larger than `readCacheMaxBytes` the cache degrades gracefully but is not classic LRU. Documenting the choice rather than implementing LRU because the agoric-sdk workload the cache was sized for fits within the default cap. 4. packages/compartment-mapper/src/parse-archive-mjs.js: lift the hardcoded `MAX_PARSE_ARCHIVE_MJS_CACHE_ENTRIES = 20_000` to an env override (`ENDO_PARSE_ARCHIVE_MJS_CACHE_ENTRIES`) and emit a one-time stderr warning on cap-hit so a workload exceeding the default surface the situation rather than silently thrashing the cache to one entry. 5. packages/bundle-source/README.md: document `ENDO_BUNDLE_SOURCE_READ_CACHE_MAX_BYTES` in the Profiling section's env list (default 64 MiB, `0` disables); also tighten the STDERR var description to match the now-construction-time announce semantics. 6. packages/bundle-source/src/profile.js: append a 4-byte random hex suffix to the trace filename so concurrent bundle calls from sibling processes in the same millisecond on the same pid (or different pids landing on the same Date.now() tick) cannot collide in the shared traceDir. 7. packages/evasive-transform/test/evade-censor.test.js: three asserts pinning the cleaner's widened `importLikePattern` (`\bimport\s*(?:\(|\/[/*])`): `import (` (space-paren) in a string literal, `import //` (line comment), `import /*` (block comment). Each embeds the risky token inside a position SES rejects unless the censor has already escaped it; the assert checks the slow path ran by confirming the output differs from the input. Plus the gate-driven `@import` lift of the inline `import('./types.js').ParserImplementation` JSDoc in parse-archive-mjs.js (the file touched by item 4 had a pre-existing inline import the no-inline-import-jsdoc probe surfaced). Refs: endojs/endo#3099
The barrister summary-fix bundle for #355 (commit c88cd69) refined the `nominateCandidates` optimization to short-circuit suffix expansion when `leaf.includes('.')` returned true. That condition matches *any* leaf with a literal `.` in its name, which violates the contract that `fixtures-resolve/node_modules/path-with-dot` was added to lock in via master commit `3768a3eaa fix(compartment-mapper): allow specifier to include period and omit extension`. The fixture has: path-with-dot/ module.with.dot.js <- `./module.with.dot` resolves via `.js` dir.with.dot/index.js <- `./dir.with.dot` resolves via `/index.js` The summary-fix early-return blocked both because the leaf contains `.`, and the four `test (22.x|24.x x ubuntu/macos)` jobs failed with eleven `compartment-mapper` failures plus a `bundle-source` `./demo/fortune.js` failure. Narrow the heuristic: skip suffix expansion only when the leaf already ends with one of the configured `searchSuffixes` (`.js`, `.json`, `/index.js`, `/index.json`). Otherwise, expand all suffixes as before. This preserves the performance intent (no redundant candidates when `./foo.js` already names the file) while honoring the `path-with-dot` contract. Regression evidence: with this change reverted, `yarn test` in `packages/compartment-mapper` fails eleven tests in `test/fixtures-resolve.test.js` and the related makeArchive / writeArchive / loadArchive variants; with the change applied, all 887 tests pass and the 6 known failures match the pre-fixer baseline. Refs: endojs/endo#3099
The barrister must-fix-loop cluster (commit cb5e2b7) lifted `NodePath` from an inline `import('@babel/traverse').NodePath` JSDoc form into a top-level `@import {NodePath} from '@babel/traverse'`, to satisfy the `no-inline-import-jsdoc` pre-push probe. The TypeScript .d.ts emitter, however, does not emit a top-level type import for a JSDoc `@import` whose source is an external package; it leaves the type name dangling in the generated declaration. The `viable-release` job's `yarn pack` step then fails when it tries to consume the package's `.d.ts`: ../evasive-transform/src/index.d.ts(85,29): error TS2304: Cannot find name 'NodePath'. Define a local `@typedef BabelNodePath` aliased through an inline `import('@babel/traverse').NodePath` (the `no-inline-import-jsdoc` probe has an explicit `@typedef` carve-out, since `@typedef` is the legitimate place to declare type aliases). Use `BabelNodePath` in the `customVisitor` property type. The generated `.d.ts` now contains a top-level `export type BabelNodePath = any;` plus `customVisitor?: ((path: BabelNodePath) => void)`, which is self-contained and matches the type-loss profile master ships for the same Babel re-export pattern. The remaining `@import` block (covering local-module `TransformedResult`, `SourceMapOption`, `SourceType`) is unaffected; only the `@babel/traverse` external is at issue. Refs: endojs/endo#3099
The `lint` CI job runs `yarn docs` (typedoc) after `yarn lint`. typedoc
type-checks each package and surfaced 24 TS errors on the perf PR's
head, all in PR-added or PR-modified files. Three sites:
1. `bundle-source/tools/trace-merge.js` (new file): `parseArgs`'s
return-type unions `string | string[] | undefined` for every value
regardless of whether `multiple: true` is set. Narrow once at the
site of the destructure to a single-value record so downstream uses
of `outTrace`, `outSummary`, `outMarkdown`, `topRaw` typecheck. The
per-event `copy.ts` / `copy.dur` reads needed an explicit
`Record<string, unknown>` type on `copy` so the property lookups
are not narrowed to the spread's `args`-only type.
2. `compartment-mapper/test/integrity.test.js`: three sites read
`node.content` after `writer.files.get(...)` returns `T | undefined`.
Guard with `if (!node) { t.fail(...); return; }`. The fixture always
contains the file, so the guard never fires in practice; it is purely
a type narrowing.
3. `compartment-mapper/test/node-powers.test.js`: `canonical`'s parameter
type is `` `file://${string}` ``; `pathToFileURL(...).href` is typed
as `string`. Cast the local with a JSDoc `@type` to the template-literal
shape (a file:// URL by construction).
All three changes preserve runtime behavior. After this commit, `yarn
docs` reports 0 errors (down from 24).
Refs: endojs/endo#3099
b03648b to
3a448fc
Compare
286fcae to
a5ef3f0
Compare
Refs: (none)
Description
This PR speeds up
@endo/bundle-sourcefor multi-entrypoint workloads (notablyagoric-sdk) and improves profiling visibility for further tuning.Primary changes:
packages/bundle-source/tools/profile-agoric-bundling.mtspackages/bundle-source/tools/trace-merge.jsfinalizeGraph) and canonical/read behavior:packages/compartment-mapper/src/generic-graph.jspackages/compartment-mapper/src/node-modules.jspackages/compartment-mapper/src/node-powers.jspackages/compartment-mapper/src/import-hook.jspackages/compartment-mapper/src/map-parser.jspackages/compartment-mapper/src/parse-archive-mjs.jspackages/compartment-mapper/src/archive-lite.jspackages/zip/src/writer.jspackages/zip/src/format-writer.jspackages/zip/tools/benchmark-writer.mjspackages/bundle-source/src/zip-base64.jsMeasured result from
yarn workspace @endo/bundle-source profile:agoric-bundling:/var/folders/v2/309w97f90q5d0r47d3np62v40000gn/T/profile-agoric-bundling-2026-02-19T21-21-58-060ZbundleSource.total:13180.267ms/var/folders/v2/309w97f90q5d0r47d3np62v40000gn/T/profile-agoric-bundling-2026-02-19T23-00-54-856ZbundleSource.total:7538.092msTotal win: -42.8% (
-5642.175ms)Optimization contribution checkpoints:
21:21:58->21:33:22):13180.267ms -> 10021.350ms(-23.97%)21:33:22->21:53:12):10021.350ms -> 9884.006ms(-1.37%)21:53:12->23:00:54):9884.006ms -> 7538.092ms(-23.74%)3887hits /357misses (~91.6% hit rate)Security Considerations
No new external runtime dependency is introduced.
Changes are internal performance/instrumentation improvements in bundling and zip writing code paths. New behavior is primarily caching and profiling spans. Cache scope remains process-local and only stores parser output for identical transformed source/module URL combinations.
Scaling Considerations
This PR is intended to reduce CPU and wall-clock time for repeated bundling of overlapping dependency graphs.
Tradeoffs:
parse-archive-mjsis bounded by an entry cap and resets when exceeding the cap.Net effect on profiled workload is significantly lower bundling latency.
Documentation Considerations
No end-user API change.
Operationally useful additions:
@endo/zipbenchmark script:yarn workspace @endo/zip bench:writerNo migration instructions needed.
Testing Considerations
Executed tests include:
yarn workspace @endo/zip testyarn workspace @endo/compartment-mapper test test/map-node-modules.test.jsyarn workspace @endo/compartment-mapper test test/node-powers.test.jsyarn workspace @endo/compartment-mapper test test/parse-archive-mjs.test.jsyarn workspace @endo/compartment-mapper test test/bundle.test.js(known expected failure remains)yarn workspace @endo/bundle-source test test/profiling.test.jsyarn workspace @endo/bundle-source test test/comment.test.jsyarn workspace @endo/bundle-source profile:agoric-bundlingCompatibility Considerations
No intentional breaking changes.
Behavior should remain compatible; changes are performance-oriented and profiling-oriented with conservative cache bypass conditions where side effects may exist.
Upgrade Considerations
No special upgrade action required.
No
*BREAKING*change in commit message and noNEWS.mdentry added, since this is internal perf and tooling behavior.