Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/babel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,24 @@ export default {
}
```

### `parallel`

- **Type:** `boolean | number`
- **Default:** `false`

Transform files in worker threads. Set `true` to use one worker per CPU core, with a maximum of 4. Set a number to choose the worker count.

Workers take time to start, so this option helps large projects most.

All Babel options must be able to go to a worker thread. Refer to plugins and presets by module name, not by function or object. The `rolldown` part of a [Rolldown Babel Preset](#rolldown-babel-presets) stays in the main thread, so its filters and hooks can be functions.

```js
babel({
parallel: true,
plugins: [['@babel/plugin-proposal-decorators', { version: '2023-11' }]],
})
```

### Other Babel options

The following [Babel options](https://babeljs.io/docs/options) are forwarded directly:
Expand Down
5 changes: 5 additions & 0 deletions packages/babel/benchmark/.gitignore
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/
39 changes: 39 additions & 0 deletions packages/babel/benchmark/bench/babel.bench.ts
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 },
)
})
})
21 changes: 21 additions & 0 deletions packages/babel/benchmark/bench/babel.test.ts
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)
},
)
})
4 changes: 4 additions & 0 deletions packages/babel/benchmark/configs/parallel.ts
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))
4 changes: 4 additions & 0 deletions packages/babel/benchmark/configs/serial.ts
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))
18 changes: 18 additions & 0 deletions packages/babel/benchmark/configs/shared.ts
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' }]],
}),
],
}
}
21 changes: 21 additions & 0 deletions packages/babel/benchmark/package.json
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:"
}
}
114 changes: 114 additions & 0 deletions packages/babel/benchmark/scripts/generate-app.ts
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()
7 changes: 7 additions & 0 deletions packages/babel/benchmark/vitest.config.ts
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',
},
})
1 change: 1 addition & 0 deletions packages/babel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"prepublishOnly": "pnpm run build"
},
"dependencies": {
"artichokie": "^0.4.5",
"picomatch": "^4.0.7"
},
"devDependencies": {
Expand Down
45 changes: 45 additions & 0 deletions packages/babel/src/cloneable.ts

Copy link
Copy Markdown

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

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}]`

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.

Could we use structuredClone? It has the overhead of actually cloning the vaules, but I think that can be solved by running the check after sending the message was errored.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in d7fb6dc. isCloneable is now a structuredClone call in a try block.

The check runs only after a worker message fails with a DataCloneError. The error then names the option, for example "overrides[0].presets[0]" cannot be sent to a worker thread.

(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
}
}
11 changes: 11 additions & 0 deletions packages/babel/src/fixtures/identifier-replace-plugin.mjs
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))
}
},
},
}
}
5 changes: 5 additions & 0 deletions packages/babel/src/fixtures/identifier-replace-preset.mjs
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]] }
}
Loading