Skip to content

feat: Fickl performance experiments - #5135

Draft
shayanhabibi wants to merge 52 commits into
fable-compiler:mainfrom
shayanhabibi:main
Draft

shayanhabibi wants to merge 52 commits into
fable-compiler:mainfrom
shayanhabibi:main

Conversation

@shayanhabibi

@shayanhabibi shayanhabibi commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Staging the full tree of changes for cherry picking.

Requires ncave/fsharp#20

Full performance exploration and rundown https://github.com/shayanhabibi/Fable/blob/9d10f9e4eb38b7db2bbf3ad5aa054b5d5f0e84bc/notes/perf-frontiers.md

Key areas of improvement:

  • Drop netstandard 2.0 in the compilers toolchain
  • Using transparent compiler and incremental graph
  • Cache compiled packages
  • Cut-off type checking dependent files if no visible changes (types, members, literals, inlines etc)
  • Cutting down repetitive array/list building through members

Other fixes:

  • Date/timezone semantics are preserved
  • Kills msbuild processes on exit
  • Prevents name collisions in some edge cases

Warning

This contains unmerged PRs such as InlineInterface.

fickl-0 5 3

shayanhabibi and others added 30 commits October 15, 2026 13:10
…hat share a tag

A TypeScriptTaggedUnion case is matched only by its tag value, so two cases
cannot share one. TypeScript unions often do: arms with the same tag are told
apart by a second literal, by `in`, or by `typeof`.

[<CaseTest("<expr>")>] on a case adds a test, written in Emit syntax with $0 as
the tested value, ANDed with the tag test. Cases without it compile as before.
When the tested value could have side effects it is bound to a temporary, so it
is evaluated once. Being an attribute, it also works for bindings consumed from
a DLL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CaseTest only takes effect on TypeScriptTaggedUnion cases. On any other union
it was silently ignored; now testing such a case emits a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The standalone compiler compiles against the committed metadata
assembly, which did not yet contain CaseTestAttribute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since fable-compiler#3844, a TypeScriptTaggedUnion case whose single field is an
interface compiles to `{ tag, Item }`, so TypeScript code reads
`command.Item.fromIndex` rather than the documented `Take & { type: "take" }`.

[<InlineInterface>] on a case, or on the union type for every case with
fields, compiles the case as its interface with the tag added:

- the case type is `Take & { type: "take" }`;
- reading the field returns the union value;
- construction copies the value with its prototype and sets the tag on
  the copy, so the caller's object is unchanged and class members keep
  working; the value is evaluated once;
- matching and `IsTake` are unchanged.

A case with InlineInterface must have exactly one field, whose type is an
interface; otherwise construction and field reads report an error.

Fable.AST: `UnionCase` gains `Attributes`, so Fable2Babel can read a
case-level attribute when building the type annotation.

Refs fable-compiler#5078: this is the compiler half; the docs page still needs updating.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LySb2Kjdc8qoY9Qoikr2u5
The standalone compiler compiles against the committed metadata
assembly, which did not yet contain InlineInterfaceAttribute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LySb2Kjdc8qoY9Qoikr2u5
Adds InlineInterface (fable-compiler#5113) next to CaseTest. The
fable-metadata Fable.Core.dll is rebuilt from the merged Fable.Core so it
carries both attributes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LySb2Kjdc8qoY9Qoikr2u5
Centralise Fickle package identity in src/Fickle.Branding.targets, imported
from src/Directory.Build.targets, so upstream project files stay untouched:
Fable.Cli -> fickle-tool (command: fickle), Fable.Compiler -> Fickle.Compiler,
Fable.AST -> Fickle.AST, Fable.PublishUtils -> Fickle.PublishUtils.
Namespaces and assembly names remain Fable.* to keep upstream merges cheap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fable.Core stays identical to upstream so a project mixing Fickle code with
existing Fable bindings resolves a single Fable.Core.dll. The fork-only
attributes now live in the new Fickle.Core package (namespace Fickle.Core),
which depends on Fable.Core and ships its sources under fable/ for Fable.

The compiler accepts both Fable.Core.* and Fickle.Core.* names for these
attributes, so they keep working if upstream adopts them in Fable.Core.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reports project cracking, FCS parse + type check, Fable per-file work
(summed across threads, with average and max), total wall time, allocations
and GC counts after each compilation. Lives in src/Fable.Cli/Timings.fs with
single-line hooks in Main.fs and Entry.fs to keep upstream merges cheap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
src/Fickle.Build declares upstream's build commands (fable-library, test,
quicktest, standalone, worker-js, compiler-js, package) as typed Partas.Build
commands with generated help, and runs each through Fable.Build's own
Build.Main.main in-process, so src/Fable.Build stays untouched. Release
commands that push Fable's package names are deliberately not exposed.

Adds the Fickle `timings` command, the fickle-build.sh/.cmd launchers, and
the first performance baseline in notes/perf-baseline.md. The Partas.Build
feed is scoped to src/Fickle.Build/NuGet.config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run the build CLI directly with `dotnet run --project src/Fickle.Build -- <command>`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Builds the FCS fork (shayanhabibi/fickle-fsharp, branch fickle_slim)
and copies its binaries into lib/fcs, cloning it next to the repo when
missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lib/fcs now holds FSharp.Compiler.Service built for net10.0 from
shayanhabibi/fickle-fsharp@da62eba (fickle_slim), with FSharp.Core
built from the same source. Fable.Transforms moves from netstandard2.0
to net10.0 to consume it; all of its consumers already target net10.

Same test results as before (compiler 133/133, JS 3159/3161 with the
two known timezone failures). Wall time is unchanged within noise;
allocations on tests/Js/Main drop from ~16 GB to ~11 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rebuild lib/fcs from fickle-fsharp 7687bf609. Allocation on tests/Js/Main
drops from ~11.0 GB to ~8.7 GB and gen1 collections from ~44 to ~17;
generated output is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rebuild lib/fcs from fickle-fsharp ac0a7b8c5, which checks a cold project
through the graph-based checker and keeps per-file states for watch mode.
tests/Js/Main compiles in 3.0-4.1 s instead of 7.4 s, with identical output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rebuild lib/fcs from fickle-fsharp 7d83faa66. Compiling tests/Js/Main allocates
7.9 GB instead of 9.3 GB, with identical output. Also record that project
cracking is already cached when --noCache is not passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rebuild lib/fcs from fickle-fsharp fff58bb81. In a tests/Js/Main watch session,
an edit to Util.fs rechecks in 1.3 s instead of 3.6 s, with the same output as
the sequential recheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
src/Fickle.Build is the build entry point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build.sh and build.bat are gone. Upstream's release commands are not
exposed by Fickle.Build, so the ShipIt workflow calls Fable.Build directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dateToStringWithOffset shifted the date by its offset into a plain Date, so day, month, year and hour then read the machine's local timezone and the D/d/F/f/G/g/M/Y and custom formats showed the wrong date when the shift crossed midnight locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rename Fickle.Build, Fickle.Core and the branding targets to Fickl, and
publish the CLI as the Fickl tool package (command `fickl`) at 0.1.0.
The startup line now reports the Fickl version alongside the Fable base
version; `--version` still prints the Fable version for tools that read it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GetOrAdd runs its factory outside the dictionary's lock, and the factory
reads every target dir chosen so far. Two files from different source
dirs with the same name, compiled at once, could both choose the same
dir and write the same output file ("being used by another process").
tests/Js/Main hits it with DllRef/Lib2.fs, about two runs in three with
DOTNET_TieredCompilation=0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With FICKL_FCS_ACTIVITIES=<file.csv>, --timings also listens to FCS's
"fsc" activity source and writes one row per activity (parse, check,
import) with its file, start, duration and thread, as `fsc --times`
does for the command-line compiler. It shows which files form the
type-checking critical path inside Fable, including package sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fable type-checks the F# sources that packages ship on every cold
compile, and on real projects that is often most of the type-checking
time (Feliz alone: 3.9 s summed in the Fantomas online tools). A package
version's sources never change, so after a successful compile Fickl
precompiles them once, as `fable precompile` does (an assembly with
their signatures, their inline bodies and their JS), into a per-user
cache keyed by the compiler, options, referenced assemblies and the
package sources' contents. Later compiles reference the assembly instead
of checking the sources and copy the package JS into their own
fable_modules, with identical output.

On by default for JavaScript and TypeScript; --noPackageCache turns it
off and FICKL_PACKAGE_CACHE moves it. Package sets FCS cannot emit as one
assembly are recorded and skipped.

Fantomas online tools, cold compile phase: 3.1-3.9 s -> 1.0-1.25 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
shayanhabibi and others added 22 commits October 21, 2026 10:15
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Updates lib/fcs to fickle-fsharp 594a55f8b. Large binding files check much
faster: Glutinum's TypeScript.fs 3.2-3.5 s -> 1.9 s, Ionide's compile
3.0-3.5 s -> 2.5 s. Output and warnings are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sembly per package

Sutil's packages declare both a module and a type Feliz.Util, which FCS
type-checks but cannot emit as one assembly. When the single-assembly
compile fails, compile each package as its own assembly referencing the
ones before it, list the extra assemblies in groups.txt, and merge their
precompiled info. Entity lookups for precompiled inline bodies search all
of them, and Fable.Precompiled.<n> assemblies count as precompiled.

Sutil compile phase on a cache hit: 4.8 s -> 2.1 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Python imports of cached files resolve through their out path in this
project, as for JS. A Python library's __init__.py files are written on a
hit, and the output type joins the key since Python emits __all__ for
libraries. BEAM names modules by their path in the package, so it needs no
import rewriting; its module-name clash check now covers cached sources.

Package .fsi files no longer stop an entry from being saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both stay comparable when other work loads the machine, unlike wall-clock times.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dotnet pack now builds one tool package per runtime, each a composite ReadyToRun image of
the compiler's assemblies, plus a portable IL package and the pointer package the .NET 10
SDK resolves. With TieredPGO off, hot R2R methods go straight to the optimised tier.
Compile phase: JS suite 4.2 -> 3.2 s, Fantomas tools 3.7 -> 2.8 s, cache hits roughly halve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es sources

The TargetFramework query now runs alongside the design-time build (Ionide
cracking 7.6s -> 3.9s). Sources under paket-files are cached for JS/TS, and
cache hits unify anonymous record types across the project and Fickl.Packages
assemblies via the FCS fork hook (fickle-fsharp c0e7f8143).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upstream merged CaseTest (fable-compiler#5076) with the attribute in Fable.Core. The
fork already carried the compiler side with the attribute in Fickl.Core,
so both attribute names stay recognised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
References were cracked after the main design-time build, level by level,
one dotnet msbuild process each. The references a project file states
directly now start their design-time builds alongside the main one. A
result is used when the referencing build asks for the same
TargetFramework and restore rewrote none of the restore outputs it read;
otherwise the reference is cracked as before. FICKL_CRACK_SPECULATION=0
turns it off.

JS suite cracking 13.4 s -> 8.2 s (median of 5, interleaved), output
identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nature

The FCS fork now keeps a changed file's earlier check result in the state
its dependents see when the file's signature fingerprint is unchanged, so
the dependents are reused. JS suite, body edit in Util.Testing.fs: 57 files
rechecked -> 0, compile 1089 -> 20 ms.

Also fixes stale diagnostics and output in watch mode when a cycle checked
part of the project and then all of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A dotnet msbuild process started by project cracking kept running when the
compiler was stopped while it ran, and one that never finished stayed
forever. On Windows each one now joins a job object that kills it when the
compiler's handle to the job closes, however the compiler exits; the
processes it starts break away from the job, so MSBuild's reusable nodes
keep their usual lifetime. Elsewhere they are killed when the compiler
exits or is interrupted.

Killing the compiler during Ionide's cracking left both msbuild children
running with 0.5.0 and none with this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sses

Each dotnet msbuild started while cracking, and the dotnet build of an excluded
project, sent CLI telemetry on exit and waited for it. On a slow route that was
about 9 s per process: the JS test suite took 27 s to crack, 1.9 s without it.
DOTNET_CLI_TELEMETRY_OPTOUT=1 is now set for those processes unless the user set
the variable; the user's --run commands are not affected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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