{"id":48610,"date":"2023-11-02T10:05:00","date_gmt":"2023-11-02T17:05:00","guid":{"rendered":"https:\/\/devblogs.microsoft.com\/dotnet\/?p=48610"},"modified":"2024-07-22T09:42:42","modified_gmt":"2024-07-22T16:42:42","slug":"a-new-fsharp-compiler-feature-graphbased-typechecking","status":"publish","type":"post","link":"https:\/\/devblogs.microsoft.com\/dotnet\/a-new-fsharp-compiler-feature-graphbased-typechecking\/","title":{"rendered":"A new F# compiler feature: graph-based type-checking"},"content":{"rendered":"<blockquote><p>This is a guest blog post by <strong>Florian Verdonck<\/strong>. Florian is a freelance software craftsman. He does consultancy, training and open-source development. Currently, he is very active in the F# community, working on the compiler and tooling, improving the overall state of the F# ecosystem according to the needs of his customers. Florian is a member of the open-source division at <a href=\"https:\/\/opensource.gresearch.com\/\">G-Research<\/a> and a maintainer of the open-source F# formatter project <a href=\"https:\/\/github.com\/fsprojects\/fantomas\/\">Fantomas<\/a>.<\/p><\/blockquote>\n<p>Currently, when you build a project using <code>dotnet build<\/code> (and, by extension, when you invoke the compiler <code>dotnet fsc.dll &lt;arguments&gt;<\/code> via MSBuild), the compilation of your project will type-check each file sequentially. Type-checking is typically one of the most time consuming phases of the compilation. Introducing some parallelization could significantly speed things up. Accelerating large data processing jobs is something of great interest to the G-Research OSS team.<\/p>\n<h2>Background<\/h2>\n<p>Last year, I revived <a href=\"https:\/\/github.com\/dotnet\/fsharp\/pull\/11152\">an existing experiment<\/a> from <a href=\"https:\/\/github.com\/TIHan\">Will Smith<\/a> to type-check implementation files (backed by a signature file) in parallel. This was a good learning experience on how type-check orchestration works, what types like <code>TcState<\/code> and <code>TcEnv<\/code> are, and other internal compiler details.<\/p>\n<p>At the time I was happy that the <code>--test:ParallelCheckingWithSignatureFilesOn<\/code> landed (see <a href=\"https:\/\/github.com\/dotnet\/fsharp\/pull\/13737\">dotnet\/fsharp#13737<\/a>), but I understood that this was only beneficial under a set of limited circumstances. The biggest drawback was that it only would work when you had <a href=\"https:\/\/learn.microsoft.com\/dotnet\/fsharp\/language-reference\/signature-files\">signature files<\/a>. Files without a matching signature file would still be processed sequentially.<\/p>\n<p>I actually like signature files, I know some people will forever reject them, but for me, well, they grew on me. And I believe they have a role to play in larger F# (enterprise) projects. Signature files allow you to hide the implementation details of a file and are in fact a summary of what the project needs to know about your implementation. On a practical level, you never have to use the <code>private<\/code> keyword anymore in your implementation, as functions will be private by default if they don&#8217;t get listed in the signature file.<\/p>\n<p>I love having a well-documented signature file that covers everything I need to know about a 3,000 line implementation file. This is particularly beneficial in an enterprise where people come and go and the living code serves as documentation. Signature files force you to think more explicitly about the boundaries between your software. If you need to edit your signature file, it will raise immediate awareness that you might be changing your public API surface.<\/p>\n<p>The downside of signature files are that you need to do double bookkeeping. Adding or modifying any public details in an implementation file will require you to change code in two places. There is not much IDE tooling (regardless of what your current editor is) at the time of writing this, and that might be frustrating.<\/p>\n<h2>Basic concept<\/h2>\n<p>The new feature flag <code>--test:GraphBasedChecking<\/code> will try to type-check any file in parallel on multiple threads, regardless if it is backed by a signature file. In order to know which files can be processed in parallel, we first need to discover the relationship between each file in a project. The current relationship is determined by the file order in the project. Right now, each file has the type-checked information of all the files that came before it.<\/p>\n<p>In practice, however, this isn&#8217;t really always a strict requirement to type-check the current file. For example, inside a unit test project, your individual test files are likely independent. Or in other projects, you might have some features that just don&#8217;t depend on each other. So, based on what you do inside your project, there might be some room for parallelization.<\/p>\n<p>To discover the dependency graph between files, we can process the <code>Untyped Abstract Syntax Tree<\/code> (AST) to detect links. Because the information inside the AST isn&#8217;t as rich as the typed tree, we know that our resolution will be a super-set of the actual dependency graph. Using the typed tree, we could construct the true dependency graph, but we would only have that information after everything is type-checked. Which is the very thing we are trying to improve.<\/p>\n<p>To construct our dependency graph, we will process each file twice. Once to create a <a href=\"https:\/\/en.wikipedia.org\/wiki\/Trie\">trie<\/a> structure that maps all the found namespaces and modules in the project. And the second time to detect any potential links to other files in the trie. You can read more details on the algorithm in the <a href=\"https:\/\/github.com\/dotnet\/fsharp\/blob\/main\/src\/Compiler\/Driver\/GraphChecking\/Docs.md\">documentation<\/a>.<\/p>\n<p>This might sound abstract and is slightly easier to grasp by looking at an example. Imagine the following project:<\/p>\n<p>A.fs:<\/p>\n<pre><code class=\"language-fsharp\">module Project.A\r\n\r\nopen System\r\n\r\nlet add x y = x + y<\/code><\/pre>\n<p>B.fs:<\/p>\n<pre><code class=\"language-fsharp\">module Project.B\r\n\r\nopen Project.A\r\n\r\nlet b = add 1 2<\/code><\/pre>\n<p>This will lead to the following trie:<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/devblogs.microsoft.com\/dotnet\/wp-content\/uploads\/sites\/10\/2023\/10\/trie1.png\" alt=\"the resulting trie\" \/><\/p>\n<p>Every trie will always have a root node. We used this node to capture files that will automatically be linked. Think of scenarios like <code>global namespace<\/code> or a top-level module without any prefix <code>[&lt;AutoOpen&gt;] module X<\/code>.<\/p>\n<p>The first child node is the <code>Project<\/code> namespace node. Both files are using this namespace because the module is prefixed by it. However, we make a distinction between files that use a namespace and files that expose types into a namespace. More on that later.<\/p>\n<p>The child nodes <code>A<\/code> and <code>B<\/code> are self-explanatory. A module can only contain one file as a dependency because it can technically only be specified once.<\/p>\n<p>After constructing the trie, we will process files A and B again but this time we will look at the content. As <code>A.fs<\/code> is the first file in a project, we will skip the second processing as it cannot have any dependencies being the first file.<\/p>\n<p>For the file <code>B.fs<\/code> we will look at nodes of interest in the AST and use them to query the trie. If we find a matching node for our query, we will consider adding files in that node as dependency for the current file. In our example, we will process <code>open Project.A<\/code> from the AST and query for <code>[\"Project\".\"A\"]<\/code>. The resulting node will be the module node of &#8216;A&#8217; which contains <code>A.fs<\/code>. And we conclude the following graph:<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/devblogs.microsoft.com\/dotnet\/wp-content\/uploads\/sites\/10\/2023\/10\/trie2.png\" alt=\"the resulting trie\" \/><\/p>\n<p>We need to take a lot of details into account when processing the contents of a file. I won&#8217;t cover all of them in this blog post, but I&#8217;d like to give one more example:<\/p>\n<p>A.fs:<\/p>\n<pre><code class=\"language-fsharp\">namespace Foo\r\n\r\ntype A =\r\n    {\r\n        Age: int\r\n        Name: string\r\n    }<\/code><\/pre>\n<p>B.fs:<\/p>\n<pre><code class=\"language-fsharp\">module Foo.B\r\n\r\nlet x y z = y.Age - z.Age<\/code><\/pre>\n<p>Our trie would look like:<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/devblogs.microsoft.com\/dotnet\/wp-content\/uploads\/sites\/10\/2023\/10\/trie3.png\" alt=\"the resulting trie\" \/><\/p>\n<p>And when we process <code>B.fs<\/code> (again, we don&#8217;t need to process <code>A.fs<\/code>), we need to take <code>module Foo.B<\/code> into account. We query the trie for the prefix <code>[\"Foo\"]<\/code> and will get the <code>Foo<\/code> namespace node back.<\/p>\n<p>Because of the namespace prefix of our module, any types defined in <code>Foo<\/code> are accessible to <code>B.fs<\/code>. So we need to take a dependency on <code>A.fs<\/code> in order for the code to compile. That is why we treat namespaces differently than modules. We don&#8217;t automatically want to link files because they share a namespace and we do want to link files when there are types in a namespace involved. This of course is a trade-off scenario, we could in theory be adding links between files, where in practice they are not required. Better safe than sorry, right?<\/p>\n<h2>Getting started<\/h2>\n<p>To get started with this in your own code base, you will need to download the .NET <code>7.0.400<\/code> SDK. Make sure your <code>global.json<\/code> is configured correctly!<\/p>\n<p>First, we need to tell the F# compiler that we wish to use this feature. We can do this by adding it to the <code>OtherFlags<\/code> property in MSBuild.<\/p>\n<p>In your <code>.fsproj<\/code> (or <code>Directory.Build.props<\/code>) you can extend this property by adding <code>&lt;OtherFlags&gt;$(OtherFlags) --test:GraphBasedChecking&lt;\/OtherFlags&gt;<\/code>. This will append our new test flag to any existing flags that were already set.<\/p>\n<p>From there on, we can trigger a build using <code>dotnet build<\/code> and may already notice a performance difference. This really depends from project to project and does require some tweaking to get the most out of it.<\/p>\n<h2>Seeing the graph<\/h2>\n<p>In order to verify the resolved dependency graph, we can add another flag to export the graph as a <a href=\"https:\/\/mermaid.js.org\/\">Mermaid diagram<\/a>. Add <code>--test:DumpCheckingGraph<\/code> to the <code>OtherFlags<\/code> and rebuild. This will generate a <code>.graph.md<\/code> file next to the output location of the project (the <code>-o<\/code> flag), which is typically in <code>obj\\Debug\\netXYZ<\/code>. This file is a mermaid diagram which we can visually inspect in an IDE (using a plugin) or via the website.<\/p>\n<h2>Seeing the duration<\/h2>\n<p>The resolved graph will be processed in parallel, to a certain degree. We can only start type-checking a file once all its dependencies are available and thus we could have some bottleneck situations. To get a sense of which file took long to type-check we can use the <a href=\"https:\/\/opentelemetry.io\/\">open telemetry<\/a> from the compiler. Adding the <code>--times:report.csv<\/code> flag to the <code>OtherFlags<\/code> will generate a report with the duration of each activity.<\/p>\n<p>When we open this <a href=\"https:\/\/en.wikipedia.org\/wiki\/Comma-separated_values\">CSV<\/a> file, we can filter on <code>CheckDeclarations.CheckOneImplFile<\/code> and sort by <code>Duration(s)<\/code>. This will show us which files took the most time to type-check.<\/p>\n<pre><code class=\"language-csv\">CheckDeclarations.CheckOneImplFile,13-17-29.4512,13-17-29.5127,000.0615,00-c8782254ceb3813ae947bd7647dc0acb-3e0d008dcf461b26-00,00-c8782254ceb3813ae947bd7647dc0acb-5baad57327070a15-00,c8782254ceb3813ae947bd7647dc0acb,C:\\Users\\nojaf\\Projects\\graph-sample\\A.fs,,A,,,,,,,,,,\r\n\r\nCheckDeclarations.CheckOneImplFile,13-17-29.5131,13-17-29.5471,000.0340,00-c8782254ceb3813ae947bd7647dc0acb-101d02a6006605f7-00,00-c8782254ceb3813ae947bd7647dc0acb-5baad57327070a15-00,c8782254ceb3813ae947bd7647dc0acb,C:\\Users\\nojaf\\Projects\\graph-sample\\B.fs,,Foo.B,,,,,,,,,,<\/code><\/pre>\n<h2>Restructure code to improve performance<\/h2>\n<p>As you can visually inspect the graph and the times, you might realize that some files contain too many links and could be good candidates for a split. As this feature is rather new, we are still trying to find some good heuristics or convenient ways to detect what change in your code structure will get you the most benefits from graph-checking.<\/p>\n<p>Here are a few more pointers when a file will always be linked to the ones that came after it:<\/p>\n<ul>\n<li>Having a type in a namespace will link the file to each file that comes after and uses that same namespace.<\/li>\n<\/ul>\n<p>A.fs:<\/p>\n<pre><code class=\"language-fsharp\">namespace Foo\r\n\r\ntype X = int<\/code><\/pre>\n<p>Every file that contains <code>Foo<\/code> in the namespace or module will get a link to <code>A.fs<\/code> because of potential type inference. This isn&#8217;t a bad thing, just keep in mind that using <code>A.fs<\/code> would require a large nested module and it could be a bottleneck while the graph is being processed.<\/p>\n<pre><code class=\"language-fsharp\">namespace Foo\r\n\r\ntype X = int\r\n\r\nmodule Bar =\r\n    \/\/ Imagine a super long module...\r\n    begin end<\/code><\/pre>\n<ul>\n<li>Using a <code>global namespace<\/code> in your file also links it to everything that came after. If you have this, perhaps you want to move this file further down the project to avoid some links.<\/li>\n<li>A non-prefixed <code>[&lt;AutoOpen&gt;]<\/code> module will also link to everything that came after it<\/li>\n<\/ul>\n<pre><code class=\"language-fsharp\">[&lt;AutoOpen&gt;]\r\nmodule Foo\r\n\/\/ ...<\/code><\/pre>\n<h2>Accelerating graph processing with signature files<\/h2>\n<p>Signature files can be very useful to speed up the processing of the graph. When an implementation file is backed by a signature file, all links will point to the signature. The signature contains the same information and will always be a lot smaller to type-check. Thus, it can unblock the processing of dependent files a lot faster. Let&#8217;s look at the example.<\/p>\n<p>A.fsi:<\/p>\n<pre><code class=\"language-fsharp\">module A\r\n\r\nval fn: int -&gt; int<\/code><\/pre>\n<p>A.fs:<\/p>\n<pre><code class=\"language-fsharp\">module A\r\n\r\nlet fn (a:int) : int =\r\n    \/\/ imagine a really long and complicated function to type check\r\n    0<\/code><\/pre>\n<p>B.fs:<\/p>\n<pre><code class=\"language-fsharp\">module B\r\n\r\nlet process x y =\r\n    A.fn x y<\/code><\/pre>\n<p>Because of the signature, our graph will look like:<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/devblogs.microsoft.com\/dotnet\/wp-content\/uploads\/sites\/10\/2023\/10\/trie4.png\" alt=\"the resulting trie\" \/><\/p>\n<p>And <code>A.fs<\/code> and <code>B.fs<\/code> can be type-checked in parallel.<\/p>\n<h2>Generating signature files<\/h2>\n<h3>Using the F# compiler<\/h3>\n<p>If you don&#8217;t have any signature files in your current project, you can create them initially using the <code>--allsigs<\/code> compiler flag. Once you add it to your <code>OtherFlags<\/code>, it will create a matching signature file for each file in the project. Then you can add them accordingly to your project. Be aware though that <code>--allsigs<\/code> is something you typically want to enable once and remove afterwards. It would otherwise run during each build and always override any changes you may have made to your signature files.<\/p>\n<h3>Using Telplin<\/h3>\n<p>Alternatively, you could also create your signature files using the <a href=\"https:\/\/www.nuget.org\/packages\/telplin\"><code>telplin<\/code> .NET tool<\/a>. Telplin has a different approach to generate the signature files and tries to be more faithful to the original source of the implementation file.<\/p>\n<h3>IDE tooling<\/h3>\n<p>Currently, no IDE has perfect tooling for working with signature files. So, I can sympathize if this makes you somewhat reluctant to start using signature files. Both <code>--allsigs<\/code> and <code>Telplin<\/code> only provide you with a starting point and are no solution for incremental changes. However, a lot of F# tooling is open source and your contributions are very welcome!<\/p>\n<h2>Amplifying F#<\/h2>\n<p>Graph-based type-checking was one of the recent topics during <a href=\"https:\/\/amplifying-fsharp.github.io\/\">Amplifying F#<\/a>. <a href=\"https:\/\/github.com\/baronfel\">Chet Husk<\/a> guided us through the setup of the new compiler flag for <a href=\"https:\/\/github.com\/fsharp\/FsAutoComplete\/\">FSAutocomplete<\/a> and it was a very fun session. You can find the recording over <a href=\"https:\/\/amplifying-fsharp.github.io\/sessions\/2023\/04\/28\">the session page here<\/a>.<\/p>\n<h2>Acknowledgements<\/h2>\n<p><a href=\"https:\/\/www.gresearch.com\/\">G-Research<\/a> took the lead on this feature and one can only imagine what F# could be if more companies were involved.<\/p>\n<p>I would particularly like to thank <a href=\"https:\/\/github.com\/safesparrow\">Janusz Wrobel<\/a> (from G-Research) for exploring a first prototype of the idea and for teaming up with me to work on the implementation. Janusz and I spent hours discussing and working on this and it would have never landed without his drive to explore this idea.<\/p>\n<p>Next, I would like to thank the <a href=\"https:\/\/github.com\/orgs\/dotnet\/teams\/fsharp-team-msft\">F# team<\/a> at Microsoft for taking in our contribution into the code base. Just because you have an idea and PR for an open-source project doesn&#8217;t mean that the maintainers necessarily get on board with it. So, I&#8217;m happy the team accepted our implementation and took the time to discuss its functionality in depth. This most likely was one of the hardest challenges we faced in the compiler code base, and I dearly appreciated all the help we got.<\/p>\n<p>Lastly, I would like to thank everyone who tested this feature on their code base ahead of the official .NET SDK release. I am so grateful for the early feedback that gave us a sense of whether this is working for all F# code.<\/p>\n<p>I very much implore you to test out this feature today! One day I hope we can turn on this feature by default, so please test this out on your code. Assert that the feature flag produces exactly the same binary! If this is not the case, open an issue on the <a href=\"https:\/\/github.com\/dotnet\/fsharp\/issues\">F# repository<\/a> with a sample to reproduce what isn&#8217;t working for you.<\/p>\n<p>Thanks again for all those involved!<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Graph-based type-checking is a new F# compiler flag that allows the compiler to type-check files in a project in parallel whenever possible.<\/p>\n","protected":false},"author":133585,"featured_media":48611,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"footnotes":""},"categories":[685,636],"tags":[7759,73,108],"class_list":["post-48610","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-dotnet","category-fsharp","tag-compilers","tag-f","tag-performance"],"acf":[],"blog_post_summary":"<p>Graph-based type-checking is a new F# compiler flag that allows the compiler to type-check files in a project in parallel whenever possible.<\/p>\n","_links":{"self":[{"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/posts\/48610","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/users\/133585"}],"replies":[{"embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/comments?post=48610"}],"version-history":[{"count":0,"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/posts\/48610\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/media\/48611"}],"wp:attachment":[{"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/media?parent=48610"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/categories?post=48610"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/tags?post=48610"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}