Skip to content

Support splitting transpilation into a worker in tscΒ #54461

Description

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).

Activity

  1. fatcerberus commented on May 30, 2023

    @fatcerberus

    See also #54256

  2. MartinJohns commented on May 30, 2023

    @MartinJohns
    Contributor

    Related: #54256

  3. RyanCavanaugh commented on May 30, 2023

    @RyanCavanaugh
    Member
       🏁
     🐎 |
    🐎  |
    

    fatcerberus by 2 seconds!

  4. dmichon-msft commented on May 30, 2023

    @dmichon-msft
    ContributorAuthor

    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.

  5. andrewbranch commented on May 31, 2023

    @andrewbranch
    Member

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

  6. dmichon-msft commented on May 31, 2023

    @dmichon-msft
    ContributorAuthor

    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.

  7. fatcerberus commented on Jun 1, 2023

    @fatcerberus

    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.

  8. dmichon-msft commented on Jun 1, 2023

    @dmichon-msft
    ContributorAuthor

    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

Metadata

Metadata

Assignees

No one assigned

    Labels

    In DiscussionNot yet reached consensusSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions