Skip to content

Batch ts-morph mutations across modular emit phases#4613

Open
JialinHuang803 wants to merge 4 commits into
Azure:mainfrom
JialinHuang803:jialinhuang/perf-sourcefile-batch
Open

Batch ts-morph mutations across modular emit phases#4613
JialinHuang803 wants to merge 4 commits into
Azure:mainfrom
JialinHuang803:jialinhuang/perf-sourcefile-batch

Conversation

@JialinHuang803

Copy link
Copy Markdown
Member

Summary

Generalise the ts-morph batching pattern shipped in autorest.typescript #4004 (which targeted resolveReferences) into a reusable framework/source-file-batch.ts module, and apply it to the modular emit phases that still walked the add* hotpath one statement at a time.

In ts-morph 23, every individual sourceFile.addX(...) call re-parses the entire source file via insertChildText -> doManipulation. Loops that call addExportDeclaration / addInterface / etc. against the same file therefore grow as O(N x file_size). The bulk variants (addExportDeclarations([...]), addInterfaces([...]), ...) collapse N re-parses into 1 per kind.

Ported from Azure/autorest.typescript#4015.

Design

A single module-level reference counter (batchDepth) tracks how many beginSourceFileBatch scopes are open, with one shared Map<SourceFile, StatementStructures[]> collecting pending writes. Each enqueueStatement appends to the queue while batchDepth > 0 and writes through to ts-morph immediately otherwise -- so call sites like addDeclaration can use it unconditionally.

Only the outermost flushSourceFileBatch drains the queue; inner flushes just decrement the counter. That keeps nested begin/flush pairs composable: an inner caller can open its own batch without prematurely flushing what an outer caller queued. On drain, structures are grouped by StructureKind and dispatched through the per-kind bulk APIs (addExportDeclarations etc.), collapsing N re-parses into one per kind per file.

Why the binder is not migrated. The binder already collects imports per file in this.imports and issues a single file.addImportDeclarations(arr) per file at the end of resolveReferences. That is the same end-state the new framework produces (one re-parse per file per kind), so routing it through enqueueStatement would add per-call overhead without changing the flush cost, and would push binder-specific ordering policy (sort by module specifier) into a generic module. The framework targets the other pattern: many independent callers each writing one statement to a shared file, or a single caller that interleaves writes with AST reads.

What changed

  • New packages/typespec-ts/src/framework/source-file-batch.ts

    • beginSourceFileBatch / flushSourceFileBatch -- reference-counted scope; nested begin/flush pairs compose. The flush is wrapped in try/finally so a thrown writer never leaks queue state into the next emit.
    • enqueueStatement(file, structure) -- queues while a batch is open, writes immediately otherwise (single entry point for both modes).
    • getEffectiveExportedNames / getQueuedExportNames -- batch-aware views of the export set, so dedup against still-pending writes stays correct.
    • On flush, structures are grouped by StructureKind and dispatched through the per-kind bulk APIs (works around a ts-morph 23 RangeParentHandler bug in mixed-kind addStatements).
  • framework/declaration.ts -- drops its own ad-hoc batch and routes addDeclaration through enqueueStatement. Single code path for batched/unbatched.

  • modular/emit-models.ts -- wraps emitTypes main loop with beginSourceFileBatch / flushSourceFileBatch (try/finally).

  • modular/build-subpath-index.ts -- wraps buildSubpathIndexFile; partitionAndEmitExports enqueues both the type-only and value exports.

  • modular/build-root-index.ts -- wraps buildRootIndex and buildSubClientIndexFile; all four addExportDeclaration call sites use enqueueStatement. getExistingExports swapped to getEffectiveExportedNames (one-line body change); getExportedDeclarations().keys() dedup sets union with getQueuedExportNames.

All queues in the index-builder call sites are single-kind (ExportDeclaration), so emitted output is byte-identical. The emitTypes queue mixes kinds and groups by kind on flush -- same behaviour shipped in the prior emitTypes batching work.

Performance results

Measured (on the autorest.typescript repo) by tsp compile of the Network and Compute ARM specs, against the post-#4004 baseline.

Network (client.tsp, total onEmit 7:27.367 -> 2:20.474, -69%)

phase before after delta
emit models - emitTypes 4:32.239 1.588s -99.4%
build api subpath index 58.813s 50.054s -15%
build classic subpath index 17.015s 0.452s -97%
buildRootIndex 27.414s 0.372s -98.6%

Compute (client.tsp, total onEmit 1:47.209 -> 0:56.485, -47%)

phase before after delta
emit models - emitTypes (slow) 0.66s huge
build api subpath index 17.482s 13.500s -23%
build classic subpath index 3.856s 0.291s -92%
buildRootIndex 6.204s 0.168s -97%

Generalise the ts-morph batching pattern into a reusable
`framework/source-file-batch.ts` module, and apply it to the modular emit
phases that still walked the `add*` hotpath one statement at a time.

In ts-morph 23, every individual `sourceFile.addX(...)` call re-parses the
entire source file via `insertChildText` -> `doManipulation`. Loops that
call `addExportDeclaration` / `addInterface` / etc. against the same file
therefore grow as O(N x file_size). The bulk variants
(`addExportDeclarations([...])`, `addInterfaces([...])`, ...) collapse N
re-parses into 1 per kind.

Ported from Azure/autorest.typescript#4015.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@microsoft-github-policy-service microsoft-github-policy-service Bot added the emitter:typescript Issues for @azure-tools/typespec-ts emitter label Jun 11, 2026
@azure-sdk

azure-sdk commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

All changed packages have been documented.

  • @azure-tools/typespec-ts
Show changes

@azure-tools/typespec-ts - internal ✏️

Batch ts-morph mutations across modular emit phases (emitTypes, buildSubpathIndexFile, buildRootIndex, buildSubClientIndexFile) via a new reusable framework/source-file-batch.ts. Each individual sourceFile.addX(...) in ts-morph 23 re-parses the entire file; the batch collapses N re-parses into one per StructureKind per file by routing writes through enqueueStatement and dispatching via the per-kind bulk APIs on flush. Significantly reduces tsp compile time on large ARM specs.

@azure-sdk

azure-sdk commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

You can try these changes here

🛝 Playground 🌐 Website

@pkg-pr-new

pkg-pr-new Bot commented Jun 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@azure-tools/typespec-ts@4613

commit: 7f30197

@github-actions

github-actions Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

⚡ Benchmark Results

⚠️ 14 metric(s) regressed above the +5% threshold:

Metric Baseline Current Change
total 🔴 614.2ms 🔴 673.8ms +9.7% 🔴
loader 🟡 208.6ms 🟡 220.1ms +5.5% 🔴
linter 🟢 124.4ms 🟢 169.7ms +36.5% 🔴
 ↳ linter/@azure-tools/typespec-azure-core/byos 🟢 5.3ms 🟢 7.2ms +37.0% 🔴
 ↳ linter/@azure-tools/typespec-azure-core/no-header-explode 🟡 17.3ms 🔴 24.5ms +41.8% 🔴
 ↳ linter/@azure-tools/typespec-azure-core/no-query-explode 🟡 17.8ms 🔴 24.6ms +38.1% 🔴
 ↳ linter/@azure-tools/typespec-azure-core/no-response-body 🔴 22.0ms 🔴 33.8ms +53.7% 🔴
 ↳ linter/@azure-tools/typespec-azure-core/no-route-parameter-name-mismatch 🟢 4.8ms 🟢 6.7ms +39.8% 🔴
 ↳ linter/@azure-tools/typespec-azure-core/response-schema-problem 🔴 20.8ms 🔴 27.9ms +34.0% 🔴
 ↳ linter/@azure-tools/typespec-azure-core/use-standard-names 🟢 5.0ms 🟢 7.7ms +55.3% 🔴
 ↳ emit/@azure-tools/typespec-autorest 🟢 151.8ms 🟢 160.5ms +5.8% 🔴
 ↳ emit/@typespec/openapi3 🟢 137.3ms 🟢 149.1ms +8.6% 🔴
 ↳ emit/@typespec/openapi3/compute 🟢 120.8ms 🟢 130.4ms +8.0% 🔴
 ↳ emit/@typespec/openapi3/write 🟢 17.0ms 🟢 18.4ms +8.5% 🔴
Full details – comparing 38323cc vs baseline 5f81336
Metric Baseline Current Change
total 🔴 614.2ms 🔴 673.8ms +9.7% 🔴
loader 🟡 208.6ms 🟡 220.1ms +5.5% 🔴
resolver 🟢 17.9ms 🟢 17.7ms -1.0%
checker 🟢 178.9ms 🟢 179.0ms +0.0%
validation 🟢 41.3ms 🟢 40.9ms -1.2%
 ↳ validation/@azure-tools/typespec-azure-core 🟢 6.1ms 🟢 5.9ms -2.6%
 ↳ validation/@typespec/http 🟢 5.9ms 🟢 5.8ms -1.0%
 ↳ validation/@typespec/rest 🟢 0.5ms 🟢 0.6ms +13.0%
 ↳ validation/@typespec/versioning 🔴 27.1ms 🔴 26.5ms -2.2%
 ↳ validation/compiler 🟢 1.4ms 🟢 1.5ms +3.2%
linter 🟢 124.4ms 🟢 169.7ms +36.5% 🔴
 ↳ linter/@azure-tools/typespec-azure-core/auth-required 🟢 0.0ms 🟢 0.0ms -3.2%
 ↳ linter/@azure-tools/typespec-azure-core/bad-record-type 🟢 0.2ms 🟢 0.2ms -10.3%
 ↳ linter/@azure-tools/typespec-azure-core/byos 🟢 5.3ms 🟢 7.2ms +37.0% 🔴
 ↳ linter/@azure-tools/typespec-azure-core/casing-style 🟢 0.6ms 🟢 0.6ms +4.4%
 ↳ linter/@azure-tools/typespec-azure-core/composition-over-inheritance 🟢 0.1ms 🟢 0.1ms -13.5%
 ↳ linter/@azure-tools/typespec-azure-core/documentation-required 🟢 0.8ms 🟢 0.9ms +18.3%
 ↳ linter/@azure-tools/typespec-azure-core/friendly-name 🟢 0.6ms 🟢 0.6ms +0.0%
 ↳ linter/@azure-tools/typespec-azure-core/key-visibility-required 🟢 0.2ms 🟢 0.2ms -0.5%
 ↳ linter/@azure-tools/typespec-azure-core/known-encoding 🟢 0.3ms 🟢 0.3ms +3.9%
 ↳ linter/@azure-tools/typespec-azure-core/long-running-polling-operation-required 🟢 0.3ms 🟢 0.3ms +5.6%
 ↳ linter/@azure-tools/typespec-azure-core/no-case-mismatch 🟢 0.2ms 🟢 0.4ms +60.8%
 ↳ linter/@azure-tools/typespec-azure-core/no-closed-literal-union 🟢 0.2ms 🟢 0.2ms +3.5%
 ↳ linter/@azure-tools/typespec-azure-core/no-enum 🟢 0.0ms 🟢 0.0ms -1.9%
 ↳ linter/@azure-tools/typespec-azure-core/no-error-status-codes 🟢 0.1ms 🟢 0.1ms +1.1%
 ↳ linter/@azure-tools/typespec-azure-core/no-explicit-routes-resource-ops 🟢 0.1ms 🟢 0.1ms +5.4%
 ↳ linter/@azure-tools/typespec-azure-core/no-format 🟢 0.6ms 🟢 0.7ms +26.2%
 ↳ linter/@azure-tools/typespec-azure-core/no-generic-numeric 🟢 0.4ms 🟢 0.4ms +2.3%
 ↳ linter/@azure-tools/typespec-azure-core/no-header-explode 🟡 17.3ms 🔴 24.5ms +41.8% 🔴
 ↳ linter/@azure-tools/typespec-azure-core/no-legacy-usage 🟢 1.1ms 🟢 1.1ms +2.6%
 ↳ linter/@azure-tools/typespec-azure-core/no-multiple-discriminator 🟢 0.1ms 🟢 0.1ms -1.0%
 ↳ linter/@azure-tools/typespec-azure-core/no-nullable 🟢 0.2ms 🟢 0.2ms +7.5%
 ↳ linter/@azure-tools/typespec-azure-core/no-offsetdatetime 🟢 1.1ms 🟢 1.3ms +14.9%
 ↳ linter/@azure-tools/typespec-azure-core/no-openapi 🟢 1.9ms 🟢 2.6ms +34.3%
 ↳ linter/@azure-tools/typespec-azure-core/no-private-usage 🟢 1.8ms 🟢 2.0ms +11.3%
 ↳ linter/@azure-tools/typespec-azure-core/no-query-explode 🟡 17.8ms 🔴 24.6ms +38.1% 🔴
 ↳ linter/@azure-tools/typespec-azure-core/no-response-body 🔴 22.0ms 🔴 33.8ms +53.7% 🔴
 ↳ linter/@azure-tools/typespec-azure-core/no-rest-library-interfaces 🟢 0.0ms 🟢 0.0ms +13.9%
 ↳ linter/@azure-tools/typespec-azure-core/no-route-parameter-name-mismatch 🟢 4.8ms 🟢 6.7ms +39.8% 🔴
 ↳ linter/@azure-tools/typespec-azure-core/no-rpc-path-params 🟢 0.2ms 🟢 0.2ms +11.6%
 ↳ linter/@azure-tools/typespec-azure-core/no-string-discriminator 🟢 0.0ms 🟢 0.0ms +6.3%
 ↳ linter/@azure-tools/typespec-azure-core/no-unknown 🟢 0.2ms 🟢 0.2ms +8.6%
 ↳ linter/@azure-tools/typespec-azure-core/no-unnamed-union 🟢 0.3ms 🟢 0.3ms +5.6%
 ↳ linter/@azure-tools/typespec-azure-core/operation-missing-api-version 🟢 0.2ms 🟢 0.2ms +10.1%
 ↳ linter/@azure-tools/typespec-azure-core/request-body-problem 🟢 0.3ms 🟢 0.3ms +3.7%
 ↳ linter/@azure-tools/typespec-azure-core/require-versioned 🟢 0.0ms 🟢 0.0ms +2.9%
 ↳ linter/@azure-tools/typespec-azure-core/response-schema-problem 🔴 20.8ms 🔴 27.9ms +34.0% 🔴
 ↳ linter/@azure-tools/typespec-azure-core/rpc-operation-request-body 🟢 0.3ms 🟢 0.3ms -0.6%
 ↳ linter/@azure-tools/typespec-azure-core/spread-discriminated-model 🟢 0.3ms 🟢 0.3ms +0.9%
 ↳ linter/@azure-tools/typespec-azure-core/use-standard-names 🟢 5.0ms 🟢 7.7ms +55.3% 🔴
 ↳ linter/@azure-tools/typespec-azure-core/use-standard-operations 🟢 0.1ms 🟢 0.1ms +15.8%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/arm-common-types-version 🟢 3.6ms 🟢 3.7ms +1.5%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/arm-custom-resource-no-key 🟢 0.1ms 🟢 0.1ms -2.6%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/arm-custom-resource-usage-discourage 🟢 0.1ms 🟢 0.1ms +1.4%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/arm-delete-operation-response-codes 🟢 4.4ms 🟢 4.6ms +4.5%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/arm-no-path-casing-conflicts 🟢 4.0ms 🟢 3.8ms -3.4%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/arm-no-record 🟢 0.3ms 🟢 0.3ms +1.1%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/arm-post-operation-response-codes 🟢 0.4ms 🟢 0.4ms +0.2%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/arm-put-operation-response-codes 🟢 0.0ms 🟢 0.0ms +15.0%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/arm-resource-action-no-segment 🟢 0.2ms 🟢 0.2ms +6.8%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/arm-resource-duplicate-property 🟢 0.1ms 🟢 0.1ms +3.8%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/arm-resource-interface-requires-decorator 🟢 0.0ms 🟢 0.0ms +7.2%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/arm-resource-invalid-action-verb 🟢 0.1ms 🟢 0.1ms +13.1%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/arm-resource-invalid-envelope-property 🟢 0.1ms 🟢 0.1ms -6.9%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/arm-resource-invalid-version-format 🟢 0.0ms 🟢 0.0ms -1.5%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/arm-resource-key-invalid-chars 🟢 0.2ms 🟢 0.2ms -0.0%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/arm-resource-name-pattern 🟢 0.0ms 🟢 0.0ms +7.5%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/arm-resource-operation 🟢 0.2ms 🟢 0.2ms +0.8%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/arm-resource-operation-response 🟢 4.2ms 🟢 4.3ms +2.0%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/arm-resource-patch 🟢 0.3ms 🟢 0.3ms -1.3%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/arm-resource-path-segment-invalid-chars 🟢 0.2ms 🟢 0.2ms +1.1%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/arm-resource-provisioning-state 🟢 0.1ms 🟢 0.1ms -1.7%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/beyond-nesting-levels 🟢 0.1ms 🟢 0.1ms -10.1%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/empty-updateable-properties 🟢 0.1ms 🟢 0.1ms -9.7%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/improper-subscription-list-operation 🟢 0.0ms 🟢 0.0ms +4.0%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/lro-location-header 🟡 12.4ms 🟡 11.7ms -6.0%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/missing-operations-endpoint 🟢 0.0ms 🟢 0.0ms +2.7%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/missing-x-ms-identifiers 🟢 0.3ms 🟢 0.3ms -4.5%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/no-empty-model 🟢 0.1ms 🟢 0.1ms -2.0%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/no-override-props 🟢 0.1ms 🟢 0.1ms +2.9%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/no-resource-delete-operation 🟢 0.2ms 🟢 0.2ms -3.9%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/no-response-body 🟡 19.2ms 🟡 18.8ms -2.2%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/patch-envelope 🟢 0.1ms 🟢 0.1ms -0.6%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/resource-name 🟢 0.1ms 🟢 0.1ms -7.1%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/secret-prop 🟢 2.1ms 🟢 2.2ms +3.3%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/unsupported-type 🟢 0.4ms 🟢 0.3ms -3.9%
 ↳ linter/@azure-tools/typespec-azure-resource-manager/version-progression 🟢 0.0ms 🟢 0.0ms -3.8%
 ↳ linter/@azure-tools/typespec-client-generator-core/property-name-conflict 🟢 1.0ms 🟢 1.0ms +5.4%
 ↳ linter/@azure-tools/typespec-client-generator-core/require-client-suffix 🟢 0.2ms 🟢 0.2ms +1.7%
emit 🔴 5.53s 🔴 5.52s -0.2%
 ↳ emit/@azure-tools/typespec-autorest 🟢 151.8ms 🟢 160.5ms +5.8% 🔴
 ↳ emit/@azure-tools/typespec-python 🔴 4.03s 🔴 4.05s +0.7%
 ↳ emit/@typespec/http-client-js 🔴 1.19s 🔴 1.09s -8.2% 🟢
 ↳ emit/@typespec/openapi3 🟢 137.3ms 🟢 149.1ms +8.6% 🔴
 ↳ emit/@typespec/openapi3/compute 🟢 120.8ms 🟢 130.4ms +8.0% 🔴
 ↳ emit/@typespec/openapi3/write 🟢 17.0ms 🟢 18.4ms +8.5% 🔴

Averaged across 3 specs (azure-arm-resource-manager, azure-core-dataplane, azure-full).
Threshold: changes > ±5% are highlighted.
🟢 Fast · 🟡 Moderate (stages >200ms, rules >10ms) · 🔴 Slow (stages >400ms, rules >20ms)

JialinHuang803 and others added 3 commits June 12, 2026 13:53
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…urcefile-batch

# Conflicts:
#	packages/typespec-ts/src/modular/build-root-index.ts
#	packages/typespec-ts/src/modular/build-subpath-index.ts
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
throw new Error(`Unsupported pending statement kind ${(s as any).kind}`);
}
}
if (imports.length > 0) {

@JialinHuang803 JialinHuang803 Jun 12, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

All modifications in the test files are just changes in the order, as statements of the same type are generated in batches now in the sequence of interface, functions, enums...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

emitter:typescript Issues for @azure-tools/typespec-ts emitter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants