The Svelte Compiler Got 55% Faster. The Fix Was 3 Files.

Collapse
X
 
  • Time
  • Show
Clear All
new posts
  • MyrinNew
    Senior Member
    • Feb 2024
    • 5168

    #1

    The Svelte Compiler Got 55% Faster. The Fix Was 3 Files.

    A developer from Oslo opened a GitHub issue three days before this PR. His question was simple: "What's Svelte's roadmap on improving tool performance?"


    Rich Harris's answer was shorter: no formal roadmap. Profile it. Fix what you find.


    So Mathias Picker did exactly that. He profiled the Svelte compiler, found two algorithmic problems hiding at the intersection of two subsystems, and opened a pull request that touched 3 code files.


    Rich Harris's review: "fantastic!"


    The PR shipped the same day. Svelte's compiler analysis phase got 20-55% faster. But here's what the diff didn't show: one of those files — state.js — is the shared global state imported by 30 files across all three compiler phases. Change it, and 2,036 downstream files are in the blast radius.


    GitHub said 4 files changed. The dependency graph said 2,036.





    The PR

    sveltejs/svelte#17823 — "perf: optimize compiler analysis phase"


    Svelte's compiler works in three phases: parse (text to AST), analyze (extract meaning), and transform (generate JavaScript). The analysis phase is where the compiler figures out scoping, reactivity, and CSS pruning — the work that makes Svelte feel like magic.


    Two problems were hiding inside it.


    Problem 1: CSS pruning walked the stylesheet once per element

    The compiler needs to determine which CSS rules actually apply to which elements — unused rules get pruned. The old code looped over every element and, for each one, walked the entire CSS AST:






    // Before: O(n × m) — n elements, m CSS rules
    for (const node of analysis.elements) {
    prune(analysis.css.ast, node); // walks entire stylesheet each time
    }







    With 50 elements and 100 CSS rules, that's 50 full tree walks over the stylesheet AST. The fix inverts the loop — walk the stylesheet once, match elements inside each selector:






    // After: one walk, elements matched inside
    prune(analysis.css.ast, analysis.elements);







    Problem 2: Deep-cloning a stack on every AST node

    Svelte supports comments that suppress warnings. The compiler tracks these in a stack. The old code called structuredClone(ignore_stack) on every single AST node — even though ignore comments appear 0-5 times per component:






    // Before: deep-clone on every AST node visit
    ignore_map.set(node, structuredClone(ignore_stack));







    A typical component has 500-2,000 AST nodes. That's 500-2,000 deep clones of an array that almost never changes. The fix lives in state.js — cache the snapshot and only rebuild when push_ignore or pop_ignore actually changes the stack:






    export function get_ignore_snapshot() {
    if (cached_ignore_snapshot === null) {
    cached_ignore_snapshot = ignore_stack.map((s) => new Set(s));
    }
    return cached_ignore_snapshot;
    }







    Three files. Two fixes. 500 compilations later:


    80+ selectors, 12 elements 3.405 ms 2.680 ms 21%
    Nested each blocks 2.034 ms 1.575 ms 23%
    100 rules, 50 elements 10.099 ms 4.564 ms 55%





    What the dependency graph shows

    Here's what you can't see in a GitHub diff: where these 3 files sit in Svelte's architecture. (The fourth changed file is a .changeset metadata entry — no code.)





    The three code files live in different layers of the compiler:
    • state.js sits at the compiler root (packages/svelte/src/compiler/state.js). It's the shared global state — warnings, filename, source, ignore stack — imported by 30 files across all three compiler phases. Parse, analyze, transform: they all reach into state.js. This is the blast radius amplifier.
    • index.js is the analysis phase entry point (phases/2-analyze/index.js). It orchestrates the analysis walk — scoping, reactivity, CSS pruning. Every component Svelte compiles passes through this file.
    • css-prune.js does the actual CSS dead-code elimination (phases/2-analyze/css/css-prune.js). It determines which CSS rules apply to which elements and marks the rest for removal.


    When you select state.js in the 3D graph and expand the blast radius, the ripple is immediate. 30 direct importers across all three phases. Those feed into phase entry points. Those feed into the compiler root. The compiler root feeds into every test harness. By the time the ripple stops: 2,036 of 3,301 files are lit up — 62% of the entire Svelte codebase, from a change to 3 files.





    This is the insight a flat diff view can't give you. state.js is the most connected file Mathias touched — it's not in the analysis phase at all, it's below it, shared by everything. That's why these optimizations matter so much: the code paths he fixed run on every AST node of every component, inside a module that the entire compiler depends on. A 3-file fix with a 2,000-file blast radius — because the files are load-bearing.





    The bigger story

    Mathias didn't stop at one PR. Over eight days, he opened five performance pull requests:


    #17811 Parser hot paths 18% faster View in 3D
    #17823 Analysis phase (this PR) 21-55% faster View in 3D
    #17839 Element interactivity caching ~8% faster
    #17844 O(n²) scope name scanning ~10% faster View in 3D
    #17846 CSS selector pruning ~16% faster View in 3D


    All merged by Rich Harris. All shipped within days.


    It started with one issue: "What's Svelte's roadmap on improving tool performance?" The answer turned out to be: one developer with a profiler, eight days, and five pull requests.





    See it yourself

    We ran this PR through CodeLayers Explore — paste any GitHub PR URL and get an interactive 3D dependency graph. Here's sveltejs/svelte#17823:


    Explore the Svelte PR in 3D


    Click any node. Rotate the graph. Select a changed file and watch the blast radius expand through the depth rings. See where the analysis phase sits relative to the parser and transformer.


    If you want this kind of visibility on your own PRs, there's a VS Code extension that shows blast radius inline as you code — tree view, gutter decorations, CodeLens annotations across 11 languages. Runs locally, nothing leaves your machine.


    There's also a GitHub Action that auto-posts a 3D visualization link on every PR. Two-minute setup. Your reviewers see what a diff can't show them.





    This is Blast Radius #1 — a weekly series where we run real open-source PRs through a 3D dependency graph and show what the diff missed. Got a PR you want us to visualize? Find us on Bluesky.




    More...
Working...