Fable 3 brought nice speed improvements to the compiler. Fable has also, since the beginning, put a lot of focus in watch compilations so you can see your changes reflected on screen almost immediately, as it happens in normal JS development workflows. However, I've been recently working in big projects where the times for the first compilation started to get too long: both in local development because bootstrapping a development server could take over a minute, and in CI, where compiling multiple Fable apps could take forever.
To improve the situation, we've been exploring all the optimization opportunities and we're now proud to present you Fable 3.7 with many new features to improve your build pipelines. Let's list them here:
Caching .fsproj parsing results
There's a part of Fable compilation that takes a sizeable portion of the total time but we haven't given enough attention until now: parsing
.fsproj files (main project and references). Simple parsing is not enough most of the times, under the cover Fable has to invoke MSBuild to get the correct F# compiler options out of the
.fsproj which can take several seconds. Seconds we can save by caching the project options and reuse them if the
.fsproj file hasn't changed, and this is what Fable 3.7 does.
F#/Fable compilation inter-threading
On a high-level perspective there are three main steps in a Fable compilation: 1) Parsing the F# source files, 2) Type checking the parsed files, 3) Converting the checked files into JS. Phases 1 and 3 can be parallelized at the file level but 2, which tends to take most of the time, cannot be due to F# type-inference (remember that file order is important in F#?). So far, Fable was waiting until type checking was finished and during this time in multi-core machines, much of the CPU power was left unused. Since Fable 3.7 we've changed this so the F# compiler will report each type checked individually letting Fable start right away and take advantage of the unused CPU. In most cases, because the Fable compilation was faster than F# type checking, this means Fable-only compilation time (around 20-40% of the total) disappears!
--runFast is an option that has been present since Fable 3 release. It's used to trigger a development server right away instead of waiting until Fable compilation is finished. This is useful in watch mode because most of the times you already have generated JS files from a previous compilation, so the bundler/dev server can start working without waiting for Fable to finish. However we haven't really promoted this option because it can cause issues (e.g. when there are no JS files or when Fable overwrite them after the development server has started), so it has remained a "hidden" option that almost nobody uses.
In v3.7 Fable will automatically decide when to "run fast" the development server. Thanks to the stored cached info, Fable can decide if the JS files in disk were generated with the same compiler options and in that case fire up the dev server (like Webpack or Vite) and run a "silent" compilation. While you test the app in the browser, Fable will finish a first compilation in the background without overwriting the files so it keeps the results in memory and can react quickly when you make any change (as it was already happening in previous Fable versions).
Your mileage may, and will, vary, but with these optimizations combined we've seen compilation times (from the second time on, when the parsed
.fsproj options are cached) improved by around 40% in some projects. And in watch compilations it will look as if compilation is instantaneous (it will actually be running in the background). If this is not enough for you, Fable 3.7 also includes a non-automatic feature that can give you a bigger boost. Keep reading.
As seen above, Fable needs to compile every source file including packages and references every time. There are several reasons for this:
Unlike .NET which guarantees backwards compilation for
.dlls, JS code generated by Fable is usually not be compatible with code generated by another compiler version, even for patch updates. We constantly change the JS representation of F# structures to fix bugs or improve performance.
Also unlike .NET, where packages are normally distributed in Release compilation mode. Fable packages can include extra code in Debug mode for analysis, etc, as it's normal to do in JS packages. Not only this, many packages use the
FABLE_COMPILERconstant to compile code differently depending on the platform (.NET or JS) but this is not included in the
.dll. In most cases, signatures remain the same but in a few libraries like Fable.React they don't, which breaks contracts.
To compile calls to inlined functions, it's not enough to have the generated JS code, Fable needs access to the original F# code (in its type-checked AST form).
Problems 1) and 2) still remain, which makes not possible to distribute Fable packages in compiled form. But after a big refactoring we've managed to solve 3) by serializing the inlined expressions. This has opened the possibility to precompile code locally, when we use the same compiler version and options. You can try it as follows (note we always need to specify an output directory in this case):
dotnet fable precompile path/to/MyLib.fsproj -o build/myLib
After the "normal" compilation, Fable will generate a
.dll assembly and also serialize inline expressions and other information. Now when compiling your app you can tell Fable it can reuse those files. You do this with the
--precompiledLib argument which should point to the build directory of the precompiled library.
dotnet fable path/to/MyApp.fsproj --precompiledLib build/myLib
MyApp.fsproj will reference
MyLib.fsproj. Fable will analyze which files can be reused and skip compilation for those. The performance improvement will be more or less proportional to the number of files you can precompile, so if you can precompile half of the files (including sources from packages) you should get around 50% faster compilations. In order to make sure the precompiled files are up-to-date, it's a good idea to always run the
precompile command beforehand, Fable will automatically skip precompilation if files haven't changed. So your build script will look like:
dotnet fable precompile path/to/MyLib.fsproj -o build/myLib dotnet fable path/to/MyApp.fsproj --precompiledLib build/myLib
Why not do this always automatically, at least for packages? Unfortunately there are also some drawbacks to precompilation:
- First compilation (or whenever there's a change in precompiled files) will take longer because of the
.dllgeneration (hopefully this will improve soon) and inline expression serialization
- Precompiled files won't be watched
- Some Fable packages will produce compiler errors when generating a
Because of this, for now we've decided to make this feature an opt-in and gather feedback from users. It's recommended to use precompilation when you have a big project with an internal library that changes less often, or when you have multiple apps that reference a common library.
At the time of writing Fable 3.7 is still released as beta. This is in case we still need to make some small adjustment to the precompilation feature after getting feedback, but the release has already been tested with projects in production and there won't be big changes after this point. So I encourage you to to try it out, enjoy the benefits of faster compilations and report any glitch you encounter or suggestions you may have. Just remember to specify the version range when updating:
dotnet tool update fable --version 3.7.0-beta-*
And on another note, v3.7 will be the last minor release for the Fable 3 cycle. From now on we will focus on Beyond: the codename for the next Fable major release which will take F# not only beyond .NET, but also beyond JS and into the realms of Python, Rust and more. 2022 is bound to be an exciting year for F# and Fable developers, Happy New Year!