Evaluate computation expression-based HTML for better rendering performance
See original GitHub issueTL;DR: F# 6 makes it possible to use computation expressions for HTML and have much better performance than the current list-based syntax. It could look like this:
div {
text "Welcome to "
a {
attr.href "https://fsbolero.io"
attr.id "link"
text "Bolero"
}
text "!"
}
Background: Blazor rendering
Blazor uses a diffing render engine, like React; but unlike React, it doesn’t diff between two tree representations of the DOM. Instead, a DOM fragment is represented by a linear sequence of instructions. For example, the following Razor file:
<div>Welcome to <a href="https://fsbolero.io" id="link">Bolero</a>!</div>
is compiled into approximately the following C#:
void Render(RenderTreeBuilder builder)
{
builder.OpenElement(0, "div");
builder.AddContent(1, "Welcome to ");
builder.OpenElement(2, "a");
builder.AddAttribute(3, "href", "https://fsbolero.io");
builder.AddAttribute(4, "id", "link");
builder.AddContent(5, "Bolero");
builder.CloseElement();
builder.AddContent(6, "!");
builder.CloseElement();
}
Notice the sequence numbers in most of these calls. They are used to efficiently diff things like conditionals and loops. They are generated at compile time, and they are the main reason why generating such code as a library, rather than a compile-time tool such as Razor, is quite non-trivial. Naively keeping an incrementing runtime counter could cause inconsistent numbering between consecutive renders.
Bolero’s current solution
In Bolero, the HTML functions build a tree representation of the DOM as a discriminated union. Then, in a second pass, this tree representation is transformed into a series of calls similar to the above. The sequence numbers are generated dynamically, and the functions cond
and forEach
ensure that they remain consistent.
Unfortunately, this has a performance cost, as we allocate the DOM tree union before every render.
The opportunity
Instead of using lists of attributes and elements to build a DOM tree, another possibility would be to use a computation expression to directly build up the sequence of builder calls. The Node
and Attr
types would be delegates that take a RenderTreeBuilder
. If this is done naively, it’s not really more efficient than the union way; it just allocates a bunch of lambdas instead of a bunch of union values.
However, F# 6’s [<InlineIfLambda>]
changes this. By making very liberal use of this attribute in the computation expression builder, it is possible to entirely flatten the generated nested lambdas, and end up with completely linear IL, just like C#!
Here is a very basic proof of concept. With it, the following code:
div {
text "Welcome to "
a {
attr.href "https://fsbolero.io"
attr.id "link"
text "Bolero"
}
text "!"
}
is decompiled by ILSpy into the following C#:
// Program.d@107
internal static int Invoke(RenderTreeBuilder tb, int i)
{
tb.OpenElement(i, div.name);
int num = i + 1;
tb.AddContent(num, "Welcome to ");
int num2 = num + 1;
tb.OpenElement(num2, a.name);
int num3 = num2 + 1;
tb.AddAttribute(num3, "href", "https://fsbolero.io");
int num4 = num3 + 1;
tb.AddAttribute(num4, "id", "link");
int num5 = num4 + 1;
tb.AddContent(num5, "Bolero");
int num6 = num5 + 1;
tb.CloseElement();
int num7 = num6;
tb.AddContent(num7, "!");
int result = num7 + 1;
tb.CloseElement();
return result;
}
It still uses a dynamically generated sequence number; that is inevitable without compile-time code generation. But it is completely linear and doesn’t allocate anything more than the equivalent Razor, which is a huge improvement!
Issue Analytics
- State:
- Created 2 years ago
- Reactions:39
- Comments:6 (3 by maintainers)
Top GitHub Comments
That’s a good question. IIRC TPs are expanded pretty late in the compiler pipeline, so I’m not sure that the inlining will go so well. We need to investigate it.
Released in v0.20.