Skip to content

perf: skip unused transform options in strip-only mode - #484

Merged
marco-ippolito merged 2 commits into
nodejs:mainfrom
GroophyLifefor:perf/strip-only-skip-transform
Oct 19, 2026
Merged

marco-ippolito merged 2 commits into
nodejs:mainfrom
GroophyLifefor:perf/strip-only-skip-transform

Conversation

@GroophyLifefor

Copy link
Copy Markdown
Contributor

swc only reads options.transform in "transform" mode, but transformSync sends it on every call and swc deserializes it each time. In "strip-only" mode (the default, used by Node.js) this change leaves it out. "transform" mode and caller-provided options are unchanged.

JS ↔ wasm calls per transformSync call: 389 -> 214. Output is identical (all TypeScript fixtures in Node.js test/fixtures, including error cases). npm test passes.

Benchmark

Node.js benchmark/ts/strip-typescript.js with compare.js, 30 runs per binary. Same Node.js build before and after; only the bundled amaro differs.

amaro in Node.js Fixture Before After Improvement 95% confidence interval
main (1.2.1) .ts 5,175 imports/s (193.2 µs) 5,542 imports/s (180.5 µs) +7.07% +5.64% … +8.51%
main (1.2.1) .js (control) 13,061 imports/s 13,037 imports/s −0.19% −1.45% … +1.07% (no change)
1.1.11 (bundled today) .ts 5,094 imports/s (196.3 µs) 5,539 imports/s (180.5 µs) +8.74% +6.87% … +10.61%
1.1.11 (bundled today) .js (control) 13,121 imports/s 13,098 imports/s −0.17% −1.35% … +1.00% (no change)
Environment
  • CPU: AMD Ryzen AI 7 350 (8 cores / 16 threads), benchmark pinned to one core with taskset -c 0
  • RAM: 24 Gb
  • OS: Ubuntu 26.04.1 LTS, Linux 7.0.0-38-generic, power profile balanced, governor powersave
  • Node.js: main at 269cab6320 (v27.0.0-pre), built with ninja
  • amaro main: dist/index.js and dist/internal.js bundled into one file with esbuild so Node.js can embed it, the same way before and after

`transform` is only read by swc in "transform" mode, but it was sent on
every call and deserialized through serde's flatten path. Leaving it out
in "strip-only" mode cuts the JS/wasm boundary calls per transformSync
call from 389 to 214 with identical output.

Signed-off-by: Murat Kirazkaya <groophylifefor@gmail.com>
Assisted-by: Claude Opus 5.5
@marco-ippolito

Copy link
Copy Markdown
Member

it makes sense but I think we can refactor the whole function in a cleaner way, give there are only 2 possible options

@GroophyLifefor

Copy link
Copy Markdown
Contributor Author

Hey @marco-ippolito, What's the idea you have in mind? I can work on it.

@marco-ippolito marco-ippolito left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the object signature we could default options to type stripping default options. Then in the body if mode is transform, merge default transform object with options.

@GroophyLifefor

Copy link
Copy Markdown
Contributor Author

Is it something like this?

const DEFAULT_OPTIONS = {
	mode: "strip-only",
	deprecatedTsModuleAsError: true,
} as Options;

const DEFAULT_TRANSFORM_OPTIONS = {
	verbatimModuleSyntax: true,
	nativeClassProperties: true,
	noEmptyExport: true,
	importNotUsedAsValues: "preserve",
} as Options["transform"];

export function transformSync(
	source: string,
	options: Options = DEFAULT_OPTIONS,
): TransformOutput {
	const input = `${source ?? ""}`;
	const opts = { ...DEFAULT_OPTIONS, ...options };
	if (opts.mode === "transform") {
		opts.transform = { ...DEFAULT_TRANSFORM_OPTIONS, ...opts.transform };
	}
	return swc.transformSync(input, opts);
}

Two things to check:

  • transformSync(src, { filename }) (what Node.js does) doesn't use the default parameter, so deprecatedTsModuleAsError: true would be lost. That's why I still merge with DEFAULT_OPTIONS inside.
  • With { mode: "transform", transform: { noEmptyExport: false } }, today only { noEmptyExport: false } is sent. With this code the other three defaults are filled in. Is that change OK, or should a user's transform keep replacing the defaults like today?

@marco-ippolito

Copy link
Copy Markdown
Member

I was thinking something simple like:

const DEFAULT_OPTIONS = {
	mode: "strip-only",
	deprecatedTsModuleAsError: true,
} satisfies Options;

// swc only reads `transform` in "transform" mode, so these defaults are kept
// out of strip-only calls to avoid deserializing them on every call.
const DEFAULT_TRANSFORM_OPTIONS = {
	mode: "transform",
	deprecatedTsModuleAsError: true,
	transform: {
		verbatimModuleSyntax: true,
		nativeClassProperties: true,
		noEmptyExport: true,
		importNotUsedAsValues: "preserve",
	},
} satisfies Options;

export function transformSync(
	source: string,
	options?: Options,
): TransformOutput {
	// Ensure that the source code is a string
	const input = `${source ?? ""}`;
	const defaults =
		options?.mode === "transform" ? DEFAULT_TRANSFORM_OPTIONS : DEFAULT_OPTIONS;
	return swc.transformSync(input, { ...defaults, ...options });
}

@GroophyLifefor

Copy link
Copy Markdown
Contributor Author

Pretty clear, let me implement in this PR.

Keep the default options for "strip-only" and "transform" mode in two separate objects and pick one based on the requested mode, as suggested in review. The options sent to swc are unchanged.

Signed-off-by: Murat Kirazkaya <groophylifefor@gmail.com>
Assisted-by: Claude Sonnet 5.5
@marco-ippolito
marco-ippolito merged commit 2ae5622 into nodejs:main Oct 19, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants