Skip to content

perf: make the incremental pipeline actually incremental - #49

Merged
kamronbatman merged 2 commits into
mainfrom
kb/incremental_pipeline
Aug 22, 2026
Merged

perf: make the incremental pipeline actually incremental#49
kamronbatman merged 2 commits into
mainfrom
kb/incremental_pipeline

Conversation

@kamronbatman

Copy link
Copy Markdown
Member

Summary

The pipeline previously combined the full Compilation into every output node and cached on symbol-bearing records that never compare equal — so every keystroke re-ran everything: full codegen for every class and a re-parse of every migration JSON (#47's baseline measured the warm re-run as more expensive than a cold run). This PR restructures the generator around a fully resolved, value-equatable SerializationModel so the expensive stages cache for real.

Design

  • Transform (ForAttributeWithMetadataNameBuildSerializationModel): all symbol/compilation-dependent work — attribute processing, the rules engine, every validation diagnostic, MarkDirty/timer/after-deserialization resolution, nesting shells — resolves into an equatable model of strings, enums, and EquatableArrays.
  • Generation is symbol-free: it consumes only the model, so the source-output stage caches on model equality. Editing anything that doesn't change a class's serialization surface — method bodies, comments, unrelated members — produces an equal model and regenerates nothing.
  • Migration files parse once per content change in their own tracked node; a cheap compilation-combined augment resolves the one value-type fact content structs need; an equality-aware map attaches each class's migrations (SG3011/SG3012/SG3013 ride along as equatable DiagnosticInfo).
  • Location is deliberately excluded from model equality so same-file edits don't defeat caching; a cached warning's position can lag until the surface changes.

Proof

  • Incrementality tests (step tracking, red on main → green): editing one class leaves the rest cached; editing an unrelated file, or non-serialization code inside a serializable class, regenerates nothing; code edits never re-parse migration JSONs; a migration edit re-runs only its class. A 150-class probe pins it at scale: after an inert edit, SourceOutput: executed=0, cached=150.
  • Output-identical: all 17 snapshots byte-identical; DiffTool manifest over the 3,786-source real corpus byte-identical to main.
  • Benchmarks (150 classes with migrations; baseline re-measured on main after fixing the benchmark's missing analyzer-private Humanizer dependency — the earlier baseline was unknowingly measuring crash paths):
Scenario main this PR
Single-edit warm re-run 61.2 ms / 16.4 MB / 150 outputs re-run 47.9 ms / 10.4 MB / ≤1 output re-run
No-change re-run ~69 µs ~63 µs
Cold full run 20.7 ms / 11.3 MB 29.9 ms / 10.1 MB

The per-edit wall-time and allocation wins understate the IDE impact: the workspace no longer re-adds and re-parses every generated document on every keystroke. The cold-run regression is the transform fronting resolution work — a deliberate trade against the continuous per-edit cost.

Breaking (rule API, for the major bump)

GenerateDeserializationMethod / GenerateMigrationProperty no longer take a Compilation; IPostDeserializeMethod receives the model; timer-method resolution and its SG3008 check moved to model building.

🤖 Generated with Claude Code

kamronbatman and others added 2 commits August 22, 2026 14:29
Four tests assert cached-not-executed behavior via
trackIncrementalGeneratorSteps: editing one class must not regenerate
others, an unrelated edit must regenerate nothing, code edits must not
re-parse migration files, and a migration edit must only re-run its class.
All four fail against the current pipeline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restructures the generator around a fully resolved, value-equatable
SerializationModel built in a ForAttributeWithMetadataName transform.
Symbol- and compilation-dependent resolution (attribute processing, rules
engine, validation, MarkDirty/timer/after-deserialization lookup, type
shells) happens in the transform; generation is symbol-free and consumes
only the model, so the output stage caches on model equality. Migration
JSON files parse once per content change in their own tracked node; a
cheap compilation-combined augment step resolves the one value-type fact
content structs need; a value-equatable map attaches each class's
migrations. Diagnostics travel as equatable DiagnosticInfo and materialize
at output time. Location is excluded from model equality so edits
elsewhere in a file do not defeat caching.

The step-tracked incrementality tests flip green: editing one class leaves
every other output cached, editing a non-serializable file or a method
body inside a serializable class regenerates nothing, migration files are
not re-parsed on code edits, and a migration edit re-runs only its class.
A 150-class probe pins the behavior at scale (SourceOutput executed=0
after an inert edit).

Correctness: all 17 snapshots byte-identical, and the DiffTool manifest
over a 3,786-source corpus is byte-identical to main.

Benchmarks (150 classes, migrations included; baseline re-measured on main
after fixing the benchmark's missing analyzer-private Humanizer
dependency, which had the earlier runs measuring crash paths):
- single-edit warm re-run: 61.2ms/16.4MB -> 47.9ms/10.4MB, with output
  re-executions dropping from 150 to at most 1 (the IDE no longer churns
  every generated document per keystroke)
- no-change re-run: ~65us both (the old pipeline only cached when the
  compilation was reference-identical, which no real edit ever is)
- cold full run: 20.7ms -> 29.9ms; the transform now fronts the resolution
  work, a deliberate trade against the per-edit wins

Breaking (rule API): GenerateDeserializationMethod and
GenerateMigrationProperty no longer take a Compilation;
IPostDeserializeMethod takes the model; value-type structs must not
declare Deserialize (SG3009).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kamronbatman
kamronbatman merged commit 542f4f8 into main Aug 22, 2026
2 checks passed
kamronbatman added a commit that referenced this pull request Aug 22, 2026
…te JSON work (#51)

GetTypeByMetadataName walks assemblies on every call, and the transforms
resolved the same handful of names per field per class per compilation -
tens of thousands of walks per keystroke at corpus scale. A per-compilation
ConditionalWeakTable + ConcurrentDictionary cache collapses every call site
(53) to a dictionary hit, including the migration augment stage's arbitrary
schema type names.

The augment stage also skips resolution entirely for properties without a
save flag, since the value-type fact only affects the nullable suffix on
save-flagged content struct fields.

Migration JSON parsing and schema writing use a source-generated
JsonSerializerContext, removing reflection-based metadata construction from
the analyzer process.

Benchmarks (150 classes): cold full run 29.9ms -> 6.5ms (3.2x faster than
even the pre-#49 pipeline's 20.7ms), single-edit warm re-run 47.9ms ->
19.6ms, no-change re-run unchanged at ~64us. Output proven neutral: all
snapshots byte-identical and the 3,786-source corpus manifest is unchanged.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
kamronbatman added a commit that referenced this pull request Aug 22, 2026
* chore: bump Generator, Annotations, and SchemaGenerator to 3.0.0

Major per the rule-API break in #49; Annotations carries the new
[AnchoredDateTime]; the schema tool picks up .slnx support and the fixed
generator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: restore csproj line endings; keep the bump minimal

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant