Skip to content

Commit

Permalink
Memory improvements to static workers (#47823)
Browse files Browse the repository at this point in the history
Continue the work to improve #32314.

This PR splits `webpack-build` into an `index` module and an `impl`
module. The `index` module invokes `impl` based on that if it should use
workers or in the same process.

Another notable change is that I modified the number of static workers,
to make it also based on remaining memory. I did some benchmarks and
found that using the number of CPUs might not be ideal. After the build
process (if `webpackBuildWorker` **isn't** enabled), the memory usage
was already very high. While the memory usage of the main process didn't
increase in the static optimization step, the overall number went high
again because we created 10 static workers:


![perf_ori](https://user-images.githubusercontent.com/3676859/229365432-ad2f3460-122d-4ded-8e02-b15d76d7687b.png)

As of today, a MacBook Pro has 10 CPU cores and 16 GB memory. The idle
memory of the system might be 8 GB, and the remaining memory when
starting the static workers might be 4~6 GB only. Here's a benchmark of
`next build` on `vercel-site`:


![CleanShot-2023-04-02-fmj2CXdK@2x](https://user-images.githubusercontent.com/3676859/229365784-20e646b3-2293-4bfc-82d9-94f33ce2461b.png)

The memory dropped there quickly because it has `webpackBuildWorker`
enabled, but then it increased again with static workers (note that we
already have `cpus: Math.round(require('node:os').cpus().length / 2) ||
1` configured in that repo). It makes more sense to limit that inside
Next.js natively.
  • Loading branch information
shuding committed Apr 3, 2023
1 parent 43b0043 commit da37c01
Show file tree
Hide file tree
Showing 3 changed files with 151 additions and 125 deletions.
18 changes: 17 additions & 1 deletion packages/next/src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import chalk from 'next/dist/compiled/chalk'
import crypto from 'crypto'
import { isMatch, makeRe } from 'next/dist/compiled/micromatch'
import { promises, writeFileSync } from 'fs'
import os from 'os'
import { Worker as JestWorker } from 'next/dist/compiled/jest-worker'
import { Worker } from '../lib/worker'
import { defaultConfig } from '../server/config-shared'
import devalue from 'next/dist/compiled/devalue'
import { escapeStringRegexp } from '../shared/lib/escape-regexp'
import findUp from 'next/dist/compiled/find-up'
Expand Down Expand Up @@ -1239,6 +1241,20 @@ export default async function build(

process.env.NEXT_PHASE = PHASE_PRODUCTION_BUILD

// We limit the number of workers used based on the number of CPUs and
// the current available memory. This is to prevent the system from
// running out of memory as well as maximize speed. We assume that
// each worker will consume ~1GB of memory in a production build.
// For example, if the system has 10 CPU cores and 8GB of remaining memory
// we will use 8 workers.
const numWorkers =
config.experimental.cpus !== defaultConfig.experimental!.cpus
? config.experimental.cpus
: Math.min(
config.experimental.cpus || 1,
Math.floor(os.freemem() / 1e9)
)

const staticWorkers = new Worker(staticWorker, {
timeout: timeout * 1000,
onRestart: (method, [arg], attempts) => {
Expand Down Expand Up @@ -1270,7 +1286,7 @@ export default async function build(
infoPrinted = true
}
},
numWorkers: config.experimental.cpus,
numWorkers,
enableWorkerThreads: config.experimental.workerThreads,
computeWorkerKey(method, ...args) {
if (method === 'exportPage') {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,37 +1,36 @@
import type { webpack } from 'next/dist/compiled/webpack/webpack'
import chalk from 'next/dist/compiled/chalk'
import formatWebpackMessages from '../client/dev/error-overlay/format-webpack-messages'
import { nonNullable } from '../lib/non-nullable'
import formatWebpackMessages from '../../client/dev/error-overlay/format-webpack-messages'
import { nonNullable } from '../../lib/non-nullable'
import {
COMPILER_NAMES,
CLIENT_STATIC_FILES_RUNTIME_MAIN_APP,
APP_CLIENT_INTERNALS,
PHASE_PRODUCTION_BUILD,
COMPILER_INDEXES,
} from '../shared/lib/constants'
import { runCompiler } from './compiler'
import * as Log from './output/log'
import getBaseWebpackConfig, { loadProjectInfo } from './webpack-config'
import { NextError } from '../lib/is-error'
import { TelemetryPlugin } from './webpack/plugins/telemetry-plugin'
} from '../../shared/lib/constants'
import { runCompiler } from '../compiler'
import * as Log from '../output/log'
import getBaseWebpackConfig, { loadProjectInfo } from '../webpack-config'
import { NextError } from '../../lib/is-error'
import { TelemetryPlugin } from '../webpack/plugins/telemetry-plugin'
import {
NextBuildContext,
resumePluginState,
getPluginState,
} from './build-context'
import { createEntrypoints } from './entries'
import loadConfig from '../server/config'
import { trace } from '../trace'
import { WEBPACK_LAYERS } from '../lib/constants'
} from '../build-context'
import { createEntrypoints } from '../entries'
import loadConfig from '../../server/config'
import { trace } from '../../trace'
import { WEBPACK_LAYERS } from '../../lib/constants'
import {
TraceEntryPointsPlugin,
TurbotraceContext,
} from './webpack/plugins/next-trace-entrypoints-plugin'
import { UnwrapPromise } from '../lib/coalesced-function'
import * as pagesPluginModule from './webpack/plugins/pages-manifest-plugin'
import { Worker } from 'next/dist/compiled/jest-worker'
} from '../webpack/plugins/next-trace-entrypoints-plugin'
import { UnwrapPromise } from '../../lib/coalesced-function'
import * as pagesPluginModule from '../webpack/plugins/pages-manifest-plugin'

import origDebug from 'next/dist/compiled/debug'
import { ChildProcess } from 'child_process'

const debug = origDebug('next:build:webpack-build')

Expand All @@ -57,7 +56,7 @@ function isTraceEntryPointsPlugin(
return plugin instanceof TraceEntryPointsPlugin
}

async function webpackBuildImpl(
export async function webpackBuildImpl(
compilerName?: keyof typeof COMPILER_INDEXES
): Promise<{
duration: number
Expand Down Expand Up @@ -367,108 +366,3 @@ export async function workerMain(workerData: {
}
return result
}

async function webpackBuildWithWorker() {
const {
config,
telemetryPlugin,
buildSpinner,
nextBuildSpan,
...prunedBuildContext
} = NextBuildContext

const getWorker = (compilerName: string) => {
const _worker = new Worker(__filename, {
exposedMethods: ['workerMain'],
numWorkers: 1,
maxRetries: 0,
forkOptions: {
env: {
...process.env,
NEXT_PRIVATE_BUILD_WORKER: '1',
},
},
}) as Worker & { workerMain: typeof workerMain }
_worker.getStderr().pipe(process.stderr)
_worker.getStdout().pipe(process.stdout)

for (const worker of ((_worker as any)._workerPool?._workers || []) as {
_child: ChildProcess
}[]) {
worker._child.on('exit', (code, signal) => {
if (code || signal) {
console.error(
`Compiler ${compilerName} unexpectedly exited with code: ${code} and signal: ${signal}`
)
}
})
}

return _worker
}

const combinedResult = {
duration: 0,
turbotraceContext: {} as TurbotraceContext,
}
// order matters here
const ORDERED_COMPILER_NAMES = [
'server',
'edge-server',
'client',
] as (keyof typeof COMPILER_INDEXES)[]

for (const compilerName of ORDERED_COMPILER_NAMES) {
const worker = getWorker(compilerName)

const curResult = await worker.workerMain({
buildContext: prunedBuildContext,
compilerName,
})
// destroy worker so it's not sticking around using memory
await worker.end()

// Update plugin state
prunedBuildContext.pluginState = curResult.pluginState

prunedBuildContext.serializedPagesManifestEntries = {
edgeServerAppPaths:
curResult.serializedPagesManifestEntries?.edgeServerAppPaths,
edgeServerPages:
curResult.serializedPagesManifestEntries?.edgeServerPages,
nodeServerAppPaths:
curResult.serializedPagesManifestEntries?.nodeServerAppPaths,
nodeServerPages:
curResult.serializedPagesManifestEntries?.nodeServerPages,
}

combinedResult.duration += curResult.duration

if (curResult.turbotraceContext?.entriesTrace) {
combinedResult.turbotraceContext = curResult.turbotraceContext

const { entryNameMap } = combinedResult.turbotraceContext.entriesTrace!
if (entryNameMap) {
combinedResult.turbotraceContext.entriesTrace!.entryNameMap = new Map(
entryNameMap
)
}
}
}
buildSpinner?.stopAndPersist()
Log.info('Compiled successfully')

return combinedResult
}

export async function webpackBuild() {
const config = NextBuildContext.config!

if (config.experimental.webpackBuildWorker) {
debug('using separate compiler workers')
return await webpackBuildWithWorker()
} else {
debug('building all compilers in same process')
return await webpackBuildImpl()
}
}
116 changes: 116 additions & 0 deletions packages/next/src/build/webpack-build/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { COMPILER_INDEXES } from '../../shared/lib/constants'
import * as Log from '../output/log'
import { NextBuildContext } from '../build-context'
import type { TurbotraceContext } from '../webpack/plugins/next-trace-entrypoints-plugin'
import { Worker } from 'next/dist/compiled/jest-worker'
import origDebug from 'next/dist/compiled/debug'
import { ChildProcess } from 'child_process'
import path from 'path'

const debug = origDebug('next:build:webpack-build')

async function webpackBuildWithWorker() {
const {
config,
telemetryPlugin,
buildSpinner,
nextBuildSpan,
...prunedBuildContext
} = NextBuildContext

const getWorker = (compilerName: string) => {
const _worker = new Worker(path.join(__dirname, 'impl.js'), {
exposedMethods: ['workerMain'],
numWorkers: 1,
maxRetries: 0,
forkOptions: {
env: {
...process.env,
NEXT_PRIVATE_BUILD_WORKER: '1',
},
},
}) as Worker & typeof import('./impl')
_worker.getStderr().pipe(process.stderr)
_worker.getStdout().pipe(process.stdout)

for (const worker of ((_worker as any)._workerPool?._workers || []) as {
_child: ChildProcess
}[]) {
worker._child.on('exit', (code, signal) => {
if (code || signal) {
console.error(
`Compiler ${compilerName} unexpectedly exited with code: ${code} and signal: ${signal}`
)
}
})
}

return _worker
}

const combinedResult = {
duration: 0,
turbotraceContext: {} as TurbotraceContext,
}
// order matters here
const ORDERED_COMPILER_NAMES = [
'server',
'edge-server',
'client',
] as (keyof typeof COMPILER_INDEXES)[]

for (const compilerName of ORDERED_COMPILER_NAMES) {
const worker = getWorker(compilerName)

const curResult = await worker.workerMain({
buildContext: prunedBuildContext,
compilerName,
})
// destroy worker so it's not sticking around using memory
await worker.end()

// Update plugin state
prunedBuildContext.pluginState = curResult.pluginState

prunedBuildContext.serializedPagesManifestEntries = {
edgeServerAppPaths:
curResult.serializedPagesManifestEntries?.edgeServerAppPaths,
edgeServerPages:
curResult.serializedPagesManifestEntries?.edgeServerPages,
nodeServerAppPaths:
curResult.serializedPagesManifestEntries?.nodeServerAppPaths,
nodeServerPages:
curResult.serializedPagesManifestEntries?.nodeServerPages,
}

combinedResult.duration += curResult.duration

if (curResult.turbotraceContext?.entriesTrace) {
combinedResult.turbotraceContext = curResult.turbotraceContext

const { entryNameMap } = combinedResult.turbotraceContext.entriesTrace!
if (entryNameMap) {
combinedResult.turbotraceContext.entriesTrace!.entryNameMap = new Map(
entryNameMap
)
}
}
}
buildSpinner?.stopAndPersist()
Log.info('Compiled successfully')

return combinedResult
}

export async function webpackBuild() {
const config = NextBuildContext.config!

if (config.experimental.webpackBuildWorker) {
debug('using separate compiler workers')
return await webpackBuildWithWorker()
} else {
debug('building all compilers in same process')
const webpackBuildImpl = require('./impl').webpackBuildImpl
return await webpackBuildImpl()
}
}

0 comments on commit da37c01

Please sign in to comment.