Skip to content
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

Support splitting transpilation into a worker in tsc #54461

Open
5 tasks done
dmichon-msft opened this issue May 30, 2023 · 8 comments
Open
5 tasks done

Support splitting transpilation into a worker in tsc #54461

dmichon-msft opened this issue May 30, 2023 · 8 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@dmichon-msft
Copy link
Contributor

dmichon-msft commented May 30, 2023

Suggestion

πŸ” Search Terms

isolatedModules, tsc, worker, parallel

βœ… Viability Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

For projects that have both isolatedModules: true and verbatimModuleSyntax: true, it is feasible to save end-to-end compile time by, after identifying changed source files, fork the transpilation part of the compilation into a worker (WebWorker on web, 'node:worker_threads' on NodeJs) so that it can be run in parallel with the type checker on the main thread (which gets declarationOnly: true injected into its config before emit).

I've prototyped this in the RC version of Heft here: microsoft/rushstack#4120
However, it seems like a straightforward enough feature with enough of a performance benefit that it would be useful to have as part of core TypeScript.

πŸ“ƒ Motivating Example

With isolatedModules: true and verbatimModuleSyntax: true, the time calculations are thus:

T_Transpile = Transform_js + Emit_js
T_Declaration = Check + Transform_dts + Emit_dts
T_Worker_Overhead = Parse + Bind
T_Startup = Resolve + Parse + Bind
T_Worker = T_Worker_Overhead + T_Transpile

T_Original = T_Startup + T_Transpile + T_Declaration
T_WithWorker = T_Startup + max(T_Declaration, T_Worker)

Testing on a modestly large local project (816 source files) has
T_Original = 19.7 s
T_Declaration = 10.0 s
T_Worker = 8.1 s
T_Worker_Overhead = 1.9 s

Resulting in a net end-to-end savings of 6.2 s out of 19.7 s = 31% with no change to the output.

πŸ’» Use Cases

Granted, custom build tools can do this, but a lot of developers prefer to use tsc directly, and by making this a core feature, it incentivizes improvements to the duplication that occurs in the implmentation (namely Parse + Bind).

@fatcerberus
Copy link

See also #54256

@MartinJohns
Copy link
Contributor

Related: #54256

@RyanCavanaugh
Copy link
Member

   🏁
 🐎 |
🐎  |

fatcerberus by 2 seconds!

@dmichon-msft
Copy link
Contributor Author

Worth noting that unlike parallelizing of parsing, splitting off the transpilation doesn't need the asynchronicity to penetrate nearly as deeply into the compiler. The transpilation worker can be kicked off and forgotten about until the very end, when the process waits for the result before logging diagnostics. Each resolve/parse/bind/check/emit step is still internally 100% synchronous, the asynchrononicity only gets introduced at the top-level orchestration.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript In Discussion Not yet reached consensus labels May 31, 2023
@andrewbranch
Copy link
Member

Why is this dependent on verbatimModuleSyntax? isolatedModules is supposed to be sufficient to transpile single files without any whole-program info.

@dmichon-msft
Copy link
Contributor Author

dmichon-msft commented May 31, 2023

As to why verbatimModuleSyntax is necessary:

import { SomeInterface } from './foo';
export class Foo implements SomeInterface {};

Normal compilation:

export class Foo {};

Transpilation:

import { SomeInterface } from './foo'; // Invalid runtime code
export class Foo {};

Edit: Above is due to a precise configuration of options I'm using for transpilation. Specifically, preserveValueImports combined with setting hasNoDefaultLib: true on the source file object, which is what actually fully disables the type checker while still producing the correct output.

More detailed example:

import { SomeInterface, SomeBaseClass } from './foo';
export class Bar extends SomeBaseClass implements SomeInterface {};

Expected result:

import { SomeBaseClass } from './foo';
export class Bar extends SomeBaseClass {};

The above works as-is with transpileModule, but CPU profile shows a chunk of time spent in the type checker before emitting. Inspection of the TypeScript compiler source shows that the type checker can only be completely disabled by setting hasNoDefaultLib = true on the ts.SourceFile object.
However, if we only set that flag and leave everything the same, we get:

export class Bar extends SomeBaseClass {};

Note the missing import for SomeBaseClass. It does remove the CPU time chunk for the type checker, however.

To get that import back, we can add preserveValueImports: true on the compiler options, but then we get:

import { SomeInterface, SomeBaseClass } from './foo';
export class Foo extends SomeBaseClass {};

This has an invalid runtime import of SomeInterface.

The final solution is to enable verbatimModuleSyntax: true, at which point the compiler complains if we don't adjust the source to:

import { type SomeInterface, SomeBaseClass } from './foo';
export class Bar extends SomeBaseClass implemenets SomeInterface {};

And this finally yields the expected result (while also not spending time in the type checker in the CPU profile):

import { SomeBaseClass } from './foo';
export class Bar extends SomeBaseClass {};

Edit again: TL;DR, reference counting of imports happens in the type checker, not the binder, so completely disabling the type checker disables reference counting of imports, so identifying used vs. unused imports goes with it.

@fatcerberus
Copy link

FWIW preserveValueImports is de facto deprecated in favor of verbatimModuleSyntax, see #51479

The above works as-is with transpileModule, but CPU profile shows a chunk of time spent in the type checker before emitting.

Yeah, my understanding was that that function doesn’t actually disable type checking, it just suppresses the diagnostics.

@dmichon-msft
Copy link
Contributor Author

FWIW preserveValueImports is de facto deprecated in favor of verbatimModuleSyntax, see #51479

I'm actually still on an older version and so using the importsNotUsedAsValues: "error" field in tsconfig, so hadn't fully registered that verbatimModuleSyntax applies the same effect as preserveValueImports.

My implementation in @rushstack/heft-typescript-plugin doesn't actually use the public transpileModule API; instead it calls createProgram and configures the same compiler host and compiler options that are internally used by transpileModule, but with the full set of source files for the entire compilation. This avoids overhead associated with spinning up the program for each file that would be incurred if I had used transpileModule.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

5 participants