Skip to content

Commit cb9bbca

Browse files
clydinalan-agius4
authored andcommitted
refactor(@angular-devkit/build-angular): add initial support for parallel TS/NG compilation
By default Angular compilations will now use a Node.js Worker thread to load and execute the TypeScript and Angular compilers when using esbuild-based builders (`application`/`browser-esbuild`). This allows for longer synchronous actions such as semantic and template diagnostics to be calculated in parallel to the other aspects of the application bundling process. The worker thread also has a separate memory pool which significantly reduces the need for adjusting the main Node.js CLI process memory settings with large application code sizes. This can be disabled via the `NG_BUILD_PARALLEL_TS` environment variable currently to support performance benchmarking. However, this is an unsupported environment variable option and may be removed in a future version. (cherry picked from commit a5962ac)
1 parent b9505ed commit cb9bbca

File tree

8 files changed

+329
-27
lines changed

8 files changed

+329
-27
lines changed

packages/angular_devkit/build_angular/src/tools/esbuild/angular/compilation/angular-compilation.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type ng from '@angular/compiler-cli';
1010
import type { PartialMessage } from 'esbuild';
1111
import ts from 'typescript';
1212
import { loadEsmModule } from '../../../../utils/load-esm';
13-
import { profileSync } from '../../profiling';
13+
import { profileAsync, profileSync } from '../../profiling';
1414
import type { AngularHostOptions } from '../angular-host';
1515
import { convertTypeScriptDiagnostic } from '../diagnostics';
1616

@@ -26,9 +26,8 @@ export abstract class AngularCompilation {
2626
static async loadCompilerCli(): Promise<typeof ng> {
2727
// This uses a wrapped dynamic import to load `@angular/compiler-cli` which is ESM.
2828
// Once TypeScript provides support for retaining dynamic imports this workaround can be dropped.
29-
AngularCompilation.#angularCompilerCliModule ??= await loadEsmModule<typeof ng>(
30-
'@angular/compiler-cli',
31-
);
29+
AngularCompilation.#angularCompilerCliModule ??=
30+
await loadEsmModule<typeof ng>('@angular/compiler-cli');
3231

3332
return AngularCompilation.#angularCompilerCliModule;
3433
}
@@ -63,15 +62,17 @@ export abstract class AngularCompilation {
6362
referencedFiles: readonly string[];
6463
}>;
6564

66-
abstract emitAffectedFiles(): Iterable<EmitFileResult>;
65+
abstract emitAffectedFiles(): Iterable<EmitFileResult> | Promise<Iterable<EmitFileResult>>;
6766

68-
protected abstract collectDiagnostics(): Iterable<ts.Diagnostic>;
67+
protected abstract collectDiagnostics():
68+
| Iterable<ts.Diagnostic>
69+
| Promise<Iterable<ts.Diagnostic>>;
6970

7071
async diagnoseFiles(): Promise<{ errors?: PartialMessage[]; warnings?: PartialMessage[] }> {
7172
const result: { errors?: PartialMessage[]; warnings?: PartialMessage[] } = {};
7273

73-
profileSync('NG_DIAGNOSTICS_TOTAL', () => {
74-
for (const diagnostic of this.collectDiagnostics()) {
74+
await profileAsync('NG_DIAGNOSTICS_TOTAL', async () => {
75+
for (const diagnostic of await this.collectDiagnostics()) {
7576
const message = convertTypeScriptDiagnostic(diagnostic);
7677
if (diagnostic.category === ts.DiagnosticCategory.Error) {
7778
(result.errors ??= []).push(message);
@@ -83,4 +84,8 @@ export abstract class AngularCompilation {
8384

8485
return result;
8586
}
87+
88+
update?(files: Set<string>): Promise<void>;
89+
90+
close?(): Promise<void>;
8691
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import { useParallelTs } from '../../../../utils/environment-options';
10+
import type { AngularCompilation } from './angular-compilation';
11+
12+
/**
13+
* Creates an Angular compilation object that can be used to perform Angular application
14+
* compilation either for AOT or JIT mode. By default a parallel compilation is created
15+
* that uses a Node.js worker thread.
16+
* @param jit True, for Angular JIT compilation; False, for Angular AOT compilation.
17+
* @returns An instance of an Angular compilation object.
18+
*/
19+
export async function createAngularCompilation(jit: boolean): Promise<AngularCompilation> {
20+
if (useParallelTs) {
21+
const { ParallelCompilation } = await import('./parallel-compilation');
22+
23+
return new ParallelCompilation(jit);
24+
}
25+
26+
if (jit) {
27+
const { JitCompilation } = await import('./jit-compilation');
28+
29+
return new JitCompilation();
30+
} else {
31+
const { AotCompilation } = await import('./aot-compilation');
32+
33+
return new AotCompilation();
34+
}
35+
}

packages/angular_devkit/build_angular/src/tools/esbuild/angular/compilation/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,5 @@
77
*/
88

99
export { AngularCompilation } from './angular-compilation';
10-
export { AotCompilation } from './aot-compilation';
11-
export { JitCompilation } from './jit-compilation';
10+
export { createAngularCompilation } from './factory';
1211
export { NoopCompilation } from './noop-compilation';
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import type { CompilerOptions } from '@angular/compiler-cli';
10+
import type { PartialMessage } from 'esbuild';
11+
import { createRequire } from 'node:module';
12+
import { MessageChannel } from 'node:worker_threads';
13+
import Piscina from 'piscina';
14+
import type { SourceFile } from 'typescript';
15+
import type { AngularHostOptions } from '../angular-host';
16+
import { AngularCompilation, EmitFileResult } from './angular-compilation';
17+
18+
/**
19+
* An Angular compilation which uses a Node.js Worker thread to load and execute
20+
* the TypeScript and Angular compilers. This allows for longer synchronous actions
21+
* such as semantic and template diagnostics to be calculated in parallel to the
22+
* other aspects of the application bundling process. The worker thread also has
23+
* a separate memory pool which significantly reduces the need for adjusting the
24+
* main Node.js CLI process memory settings with large application code sizes.
25+
*/
26+
export class ParallelCompilation extends AngularCompilation {
27+
readonly #worker: Piscina;
28+
29+
constructor(readonly jit: boolean) {
30+
super();
31+
32+
// TODO: Convert to import.meta usage during ESM transition
33+
const localRequire = createRequire(__filename);
34+
35+
this.#worker = new Piscina({
36+
minThreads: 1,
37+
maxThreads: 1,
38+
idleTimeout: Infinity,
39+
filename: localRequire.resolve('./parallel-worker'),
40+
});
41+
}
42+
43+
override initialize(
44+
tsconfig: string,
45+
hostOptions: AngularHostOptions,
46+
compilerOptionsTransformer?:
47+
| ((compilerOptions: CompilerOptions) => CompilerOptions)
48+
| undefined,
49+
): Promise<{
50+
affectedFiles: ReadonlySet<SourceFile>;
51+
compilerOptions: CompilerOptions;
52+
referencedFiles: readonly string[];
53+
}> {
54+
const stylesheetChannel = new MessageChannel();
55+
// The request identifier is required because Angular can issue multiple concurrent requests
56+
stylesheetChannel.port1.on('message', ({ requestId, data, containingFile, stylesheetFile }) => {
57+
hostOptions
58+
.transformStylesheet(data, containingFile, stylesheetFile)
59+
.then((value) => stylesheetChannel.port1.postMessage({ requestId, value }))
60+
.catch((error) => stylesheetChannel.port1.postMessage({ requestId, error }));
61+
});
62+
63+
// The web worker processing is a synchronous operation and uses shared memory combined with
64+
// the Atomics API to block execution here until a response is received.
65+
const webWorkerChannel = new MessageChannel();
66+
const webWorkerSignal = new Int32Array(new SharedArrayBuffer(4));
67+
webWorkerChannel.port1.on('message', ({ workerFile, containingFile }) => {
68+
try {
69+
const workerCodeFile = hostOptions.processWebWorker(workerFile, containingFile);
70+
webWorkerChannel.port1.postMessage({ workerCodeFile });
71+
} catch (error) {
72+
webWorkerChannel.port1.postMessage({ error });
73+
} finally {
74+
Atomics.store(webWorkerSignal, 0, 1);
75+
Atomics.notify(webWorkerSignal, 0);
76+
}
77+
});
78+
79+
// The compiler options transformation is a synchronous operation and uses shared memory combined
80+
// with the Atomics API to block execution here until a response is received.
81+
const optionsChannel = new MessageChannel();
82+
const optionsSignal = new Int32Array(new SharedArrayBuffer(4));
83+
optionsChannel.port1.on('message', (compilerOptions) => {
84+
try {
85+
const transformedOptions = compilerOptionsTransformer?.(compilerOptions) ?? compilerOptions;
86+
optionsChannel.port1.postMessage({ transformedOptions });
87+
} catch (error) {
88+
webWorkerChannel.port1.postMessage({ error });
89+
} finally {
90+
Atomics.store(optionsSignal, 0, 1);
91+
Atomics.notify(optionsSignal, 0);
92+
}
93+
});
94+
95+
// Execute the initialize function in the worker thread
96+
return this.#worker.run(
97+
{
98+
fileReplacements: hostOptions.fileReplacements,
99+
tsconfig,
100+
jit: this.jit,
101+
stylesheetPort: stylesheetChannel.port2,
102+
optionsPort: optionsChannel.port2,
103+
optionsSignal,
104+
webWorkerPort: webWorkerChannel.port2,
105+
webWorkerSignal,
106+
},
107+
{
108+
name: 'initialize',
109+
transferList: [stylesheetChannel.port2, optionsChannel.port2, webWorkerChannel.port2],
110+
},
111+
);
112+
}
113+
114+
/**
115+
* This is not needed with this compilation type since the worker will already send a response
116+
* with the serializable esbuild compatible diagnostics.
117+
*/
118+
protected override collectDiagnostics(): never {
119+
throw new Error('Not implemented in ParallelCompilation.');
120+
}
121+
122+
override diagnoseFiles(): Promise<{ errors?: PartialMessage[]; warnings?: PartialMessage[] }> {
123+
return this.#worker.run(undefined, { name: 'diagnose' });
124+
}
125+
126+
override emitAffectedFiles(): Promise<Iterable<EmitFileResult>> {
127+
return this.#worker.run(undefined, { name: 'emit' });
128+
}
129+
130+
override update(files: Set<string>): Promise<void> {
131+
return this.#worker.run(files, { name: 'update' });
132+
}
133+
134+
override close() {
135+
// Workaround piscina bug where a worker thread will be recreated after destroy to meet the minimum.
136+
this.#worker.options.minThreads = 0;
137+
138+
return this.#worker.destroy();
139+
}
140+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import assert from 'node:assert';
10+
import { randomUUID } from 'node:crypto';
11+
import { type MessagePort, receiveMessageOnPort } from 'node:worker_threads';
12+
import { SourceFileCache } from '../source-file-cache';
13+
import type { AngularCompilation } from './angular-compilation';
14+
import { AotCompilation } from './aot-compilation';
15+
import { JitCompilation } from './jit-compilation';
16+
17+
export interface InitRequest {
18+
jit: boolean;
19+
tsconfig: string;
20+
fileReplacements?: Record<string, string>;
21+
stylesheetPort: MessagePort;
22+
optionsPort: MessagePort;
23+
optionsSignal: Int32Array;
24+
webWorkerPort: MessagePort;
25+
webWorkerSignal: Int32Array;
26+
}
27+
28+
let compilation: AngularCompilation | undefined;
29+
30+
const sourceFileCache = new SourceFileCache();
31+
32+
export async function initialize(request: InitRequest) {
33+
compilation ??= request.jit ? new JitCompilation() : new AotCompilation();
34+
35+
const stylesheetRequests = new Map<string, [(value: string) => void, (reason: Error) => void]>();
36+
request.stylesheetPort.on('message', ({ requestId, value, error }) => {
37+
if (error) {
38+
stylesheetRequests.get(requestId)?.[1](error);
39+
} else {
40+
stylesheetRequests.get(requestId)?.[0](value);
41+
}
42+
});
43+
44+
const { compilerOptions, referencedFiles } = await compilation.initialize(
45+
request.tsconfig,
46+
{
47+
fileReplacements: request.fileReplacements,
48+
sourceFileCache,
49+
modifiedFiles: sourceFileCache.modifiedFiles,
50+
transformStylesheet(data, containingFile, stylesheetFile) {
51+
const requestId = randomUUID();
52+
const resultPromise = new Promise<string>((resolve, reject) =>
53+
stylesheetRequests.set(requestId, [resolve, reject]),
54+
);
55+
56+
request.stylesheetPort.postMessage({
57+
requestId,
58+
data,
59+
containingFile,
60+
stylesheetFile,
61+
});
62+
63+
return resultPromise;
64+
},
65+
processWebWorker(workerFile, containingFile) {
66+
Atomics.store(request.webWorkerSignal, 0, 0);
67+
request.webWorkerPort.postMessage({ workerFile, containingFile });
68+
69+
Atomics.wait(request.webWorkerSignal, 0, 0);
70+
const result = receiveMessageOnPort(request.webWorkerPort)?.message;
71+
72+
if (result?.error) {
73+
throw result.error;
74+
}
75+
76+
return result?.workerCodeFile ?? workerFile;
77+
},
78+
},
79+
(compilerOptions) => {
80+
Atomics.store(request.optionsSignal, 0, 0);
81+
request.optionsPort.postMessage(compilerOptions);
82+
83+
Atomics.wait(request.optionsSignal, 0, 0);
84+
const result = receiveMessageOnPort(request.optionsPort)?.message;
85+
86+
if (result?.error) {
87+
throw result.error;
88+
}
89+
90+
return result?.transformedOptions ?? compilerOptions;
91+
},
92+
);
93+
94+
return {
95+
referencedFiles,
96+
// TODO: Expand? `allowJs` is the only field needed currently.
97+
compilerOptions: { allowJs: compilerOptions.allowJs },
98+
};
99+
}
100+
101+
export async function diagnose() {
102+
assert(compilation);
103+
104+
const diagnostics = await compilation.diagnoseFiles();
105+
106+
return diagnostics;
107+
}
108+
109+
export async function emit() {
110+
assert(compilation);
111+
112+
const files = await compilation.emitAffectedFiles();
113+
114+
return [...files];
115+
}
116+
117+
export function update(files: Set<string>): void {
118+
sourceFileCache.invalidate(files);
119+
}

0 commit comments

Comments
 (0)