Repository navigation
feat(babel): add parallel option to transform in worker threads
#139
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
626262c
619c1e5
d7fb6dc
3eff276
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| # Build outputs | ||
| dist/ | ||
|
|
||
| # Generated app (regenerated with pnpm generate) | ||
| shared-app/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import { describe, test } from 'vitest' | ||
| import { execSync } from 'node:child_process' | ||
| import { existsSync, readdirSync, rmSync } from 'node:fs' | ||
| import { resolve } from 'node:path' | ||
| import { runBuild } from '@rolldown/benchmark-utils/run-build' | ||
|
|
||
| const baseDir = resolve(import.meta.dirname, '..') | ||
| const distBase = resolve(baseDir, 'dist') | ||
| const modulesDir = resolve(baseDir, 'shared-app/src/modules') | ||
| const expectedModules = 100 | ||
|
|
||
| const currentModules = existsSync(modulesDir) | ||
| ? readdirSync(modulesDir).filter((f) => f.endsWith('.js')).length | ||
| : 0 | ||
| if (currentModules !== expectedModules) { | ||
| execSync('pnpm generate', { cwd: baseDir, stdio: 'inherit' }) | ||
| } | ||
|
|
||
| function cleanDist(name: string) { | ||
| const dir = resolve(distBase, name) | ||
| if (existsSync(dir)) { | ||
| rmSync(dir, { recursive: true }) | ||
| } | ||
| } | ||
|
|
||
| describe('Babel Parallel Benchmark', () => { | ||
| // Each build takes seconds, so the default of 64 samples is too slow. | ||
| test('comparison', { timeout: 300_000 }, async ({ bench }) => { | ||
| await bench.compare( | ||
| bench('parallel: false', { afterAll: () => cleanDist('serial') }, () => { | ||
| runBuild('serial', baseDir) | ||
| }), | ||
| bench('parallel: true', { afterAll: () => cleanDist('parallel') }, () => { | ||
| runBuild('parallel', baseDir) | ||
| }), | ||
| { iterations: 10, warmupIterations: 1 }, | ||
| ) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { describe, test } from 'vitest' | ||
| import { execSync } from 'node:child_process' | ||
| import { resolve } from 'node:path' | ||
| import { runBuild } from '@rolldown/benchmark-utils/run-build' | ||
|
|
||
| const baseDir = resolve(import.meta.dirname, '..') | ||
|
|
||
| execSync('pnpm generate -- --total=10 --silent', { | ||
| cwd: baseDir, | ||
| stdio: 'inherit', | ||
| }) | ||
|
|
||
| describe('Babel build', () => { | ||
| test.for(['serial', 'parallel'] as const)( | ||
| 'build:%s exits with code 0', | ||
| { timeout: 30_000 }, | ||
| (name) => { | ||
| runBuild(name, baseDir) | ||
| }, | ||
| ) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| import { defineConfig } from 'rolldown' | ||
| import { createConfig } from './shared.ts' | ||
|
|
||
| export default defineConfig(createConfig('parallel', true)) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| import { defineConfig } from 'rolldown' | ||
| import { createConfig } from './shared.ts' | ||
|
|
||
| export default defineConfig(createConfig('serial', false)) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { resolve } from 'node:path' | ||
| import type { RolldownOptions } from 'rolldown' | ||
| import babel from '@rolldown/plugin-babel' | ||
|
|
||
| export function createConfig(name: string, parallel: boolean): RolldownOptions { | ||
| return { | ||
| input: resolve(import.meta.dirname, '../shared-app/src/index.js'), | ||
| output: { | ||
| dir: resolve(import.meta.dirname, '../dist', name), | ||
| }, | ||
| plugins: [ | ||
| babel({ | ||
| parallel, | ||
| plugins: [['@babel/plugin-proposal-decorators', { version: '2023-11' }]], | ||
| }), | ||
| ], | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| { | ||
| "name": "@rolldown/benchmark-babel", | ||
| "version": "0.0.0", | ||
| "private": true, | ||
| "type": "module", | ||
| "scripts": { | ||
| "generate": "oxnode scripts/generate-app.ts", | ||
| "bench": "vitest bench --run", | ||
| "build:serial": "rolldown -c configs/serial.ts", | ||
| "build:parallel": "rolldown -c configs/parallel.ts" | ||
| }, | ||
| "devDependencies": { | ||
| "@babel/core": "^8.0.6", | ||
| "@babel/plugin-proposal-decorators": "^8.0.2", | ||
| "@oxc-node/cli": "catalog:", | ||
| "@rolldown/benchmark-utils": "workspace:*", | ||
| "@rolldown/plugin-babel": "workspace:*", | ||
| "@types/node": "^24.19.1", | ||
| "rolldown": "catalog:" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| /** | ||
| * App generator for the babel `parallel` benchmark. | ||
| * Generates ~100 JS files with classes that use decorators. | ||
| * Uses seeded random (seed=42) for deterministic generation. | ||
| */ | ||
|
|
||
| import { writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs' | ||
| import { join } from 'node:path' | ||
| import { SeededRandom } from '@rolldown/benchmark-utils/seeded-random' | ||
|
|
||
| const rng = new SeededRandom(42) | ||
|
|
||
| const DECORATORS = `export function logged(value, context) { | ||
| if (context.kind !== 'method') return | ||
| return function (...args) { | ||
| return value.apply(this, args) | ||
| } | ||
| } | ||
|
|
||
| export function tracked(value, context) { | ||
| if (context.kind !== 'accessor') return | ||
| return { | ||
| get() { | ||
| return value.get.call(this) | ||
| }, | ||
| set(next) { | ||
| value.set.call(this, next) | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| export function registered(value, context) { | ||
| context.addInitializer(function () { | ||
| this.registeredName = context.name | ||
| }) | ||
| return value | ||
| } | ||
| ` | ||
|
|
||
| const FIELD_NAMES = ['count', 'label', 'items', 'status', 'owner', 'total', 'offset', 'limit'] | ||
| const METHOD_NAMES = ['load', 'save', 'reset', 'render', 'update', 'remove', 'select', 'toggle'] | ||
|
|
||
| function generateClass(name: string): string { | ||
| const fields = rng.pickN(FIELD_NAMES, rng.nextInt(4) + 3) | ||
| const methods = rng.pickN(METHOD_NAMES, rng.nextInt(4) + 3) | ||
|
|
||
| const lines = [`@registered\nclass ${name} {`] | ||
| for (const field of fields) { | ||
| lines.push(` @tracked accessor ${field} = ${rng.nextInt(100)}`) | ||
| } | ||
| for (const method of methods) { | ||
| const field = rng.pick(fields) | ||
| lines.push( | ||
| ` @logged\n ${method}(input) {`, | ||
| ` if (input === undefined) return this.${field}`, | ||
| ` this.${field} = input + ${rng.nextInt(100)}`, | ||
| ` return this.${field}`, | ||
| ` }`, | ||
| ) | ||
| } | ||
| lines.push(`}`) | ||
| return lines.join('\n') | ||
| } | ||
|
|
||
| function generateModule(index: number, classesPerModule: number): string { | ||
| const names: string[] = [] | ||
| const classes: string[] = [] | ||
| for (let i = 0; i < classesPerModule; i++) { | ||
| const name = `Model${index}_${i}` | ||
| names.push(name) | ||
| classes.push(generateClass(name)) | ||
| } | ||
| return [ | ||
| `import { logged, tracked, registered } from '../decorators.js'`, | ||
| ...classes, | ||
| `export { ${names.join(', ')} }`, | ||
| '', | ||
| ].join('\n\n') | ||
| } | ||
|
|
||
| function readNumberArg(name: string, fallback: number): number { | ||
| const prefix = `--${name}=` | ||
| const arg = process.argv.find((a) => a.startsWith(prefix)) | ||
| return arg ? Number.parseInt(arg.slice(prefix.length), 10) : fallback | ||
| } | ||
|
|
||
| function main() { | ||
| const appDir = join(import.meta.dirname, '../shared-app/src') | ||
| if (existsSync(appDir)) rmSync(appDir, { recursive: true }) | ||
| mkdirSync(join(appDir, 'modules'), { recursive: true }) | ||
|
|
||
| const TOTAL = readNumberArg('total', 100) | ||
| const CLASSES_PER_MODULE = readNumberArg('classes', 10) | ||
| const silent = process.argv.includes('--silent') | ||
|
|
||
| writeFileSync(join(appDir, 'decorators.js'), DECORATORS) | ||
|
|
||
| const imports: string[] = [] | ||
| for (let i = 0; i < TOTAL; i++) { | ||
| const filename = `module${i + 1}.js` | ||
| writeFileSync(join(appDir, 'modules', filename), generateModule(i + 1, CLASSES_PER_MODULE)) | ||
| imports.push( | ||
| `import * as module${i + 1} from './modules/${filename}'\nconsole.log(module${i + 1})`, | ||
| ) | ||
| } | ||
| writeFileSync(join(appDir, 'index.js'), imports.join('\n') + '\n') | ||
|
|
||
| if (!silent) { | ||
| console.log(`Generated ${TOTAL} modules in ${appDir}/modules/`) | ||
| console.log(` Classes per module: ${CLASSES_PER_MODULE}`) | ||
| } | ||
| } | ||
|
|
||
| main() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { defineConfig } from 'vitest/config' | ||
|
|
||
| export default defineConfig({ | ||
| test: { | ||
| name: 'benchmark-babel', | ||
| }, | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import type { PluginOptions } from './options.ts' | ||
| import type { RolldownBabelPresetItem } from './rolldownPreset.ts' | ||
|
|
||
| function isCloneable(value: unknown): boolean { | ||
| try { | ||
| structuredClone(value) | ||
| return true | ||
| } catch { | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| function findUncloneablePreset( | ||
| presets: RolldownBabelPresetItem[] | undefined, | ||
| path: string, | ||
| ): string | undefined { | ||
| if (!presets) return | ||
| for (let i = 0; i < presets.length; i++) { | ||
| const preset = presets[i] | ||
| // The `rolldown` part of a preset (filters and hooks) is only used in the main thread. | ||
| const babelPreset = typeof preset === 'object' && 'rolldown' in preset ? preset.preset : preset | ||
| if (!isCloneable(babelPreset)) return `${path}[${i}]` | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we use
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in d7fb6dc. The check runs only after a worker message fails with a (AI reply: Claude Code for @NullVoxPopuli) |
||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Returns the path of the first option that cannot be sent to a worker. | ||
| */ | ||
| export function findUncloneableOption(options: PluginOptions): string | undefined { | ||
| const { presets, overrides, ...rest } = options | ||
| for (const [key, value] of Object.entries(rest)) { | ||
| if (!isCloneable(value)) return key | ||
| } | ||
| const presetPath = findUncloneablePreset(presets, 'presets') | ||
| if (presetPath) return presetPath | ||
| if (!overrides) return | ||
| for (let i = 0; i < overrides.length; i++) { | ||
| const { presets: overridePresets, ...overrideRest } = overrides[i] | ||
| for (const [key, value] of Object.entries(overrideRest)) { | ||
| if (!isCloneable(value)) return `overrides[${i}].${key}` | ||
| } | ||
| const overridePresetPath = findUncloneablePreset(overridePresets, `overrides[${i}].presets`) | ||
| if (overridePresetPath) return overridePresetPath | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| export default function identifierReplacePlugin({ types: t }, { name, value }) { | ||
| return { | ||
| visitor: { | ||
| Identifier(p) { | ||
| if (p.node.name === name) { | ||
| p.replaceWith(t.booleanLiteral(value)) | ||
| } | ||
| }, | ||
| }, | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import identifierReplacePlugin from './identifier-replace-plugin.mjs' | ||
|
|
||
| export default function identifierReplacePreset(_api, options) { | ||
| return { plugins: [[identifierReplacePlugin, options]] } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
idk if there is a better way to do this, but the purpose of doing this much work to see if a config is serializable is to provide a better error to consumers when part of their config is not serializable