elm-optimize-level-2/transformations.md

377 lines
11 KiB
Markdown
Raw Normal View History

2020-08-16 17:01:18 +03:00
# Overview of Transformations
2020-08-13 02:55:03 +03:00
This is an overview of the transformations for `elm-optimize`.
2020-08-16 17:01:18 +03:00
Not all of them made the cut, but seeing that a transformation is not as effective as initially thought is really good information.
2020-08-13 02:55:03 +03:00
We got a huge head start because of [Robin's article](https://dev.to/skinney/improving-elm-s-compiler-output-5e1h).
2020-08-16 17:01:18 +03:00
Each transformation also has a rough summary of impact.
2020-08-13 02:55:03 +03:00
# Applying Functions Directly
2020-08-16 21:25:20 +03:00
Elm wraps functions in an object that tracks how many arguments the function takes(also known as 'arity').
2020-08-13 02:55:03 +03:00
2020-08-16 21:25:20 +03:00
This is so that functions can be partially applied, meaning you can apply a few arguments and get a new function that has those arguments "built in".
2020-08-13 02:55:03 +03:00
The most significant speedups we've seen is in finding places where we can skip the wrapper and call the actual function directly. This happens when you call a function with exactly the number of arguments it needs.
2020-08-13 02:55:03 +03:00
In order to do this, we need to adjust function declarations so that the original function can be called either in the standrd 'wrapped' way, or directly.
before
```js
var MyFunction = F2(function (tag, value) {
return value;
2020-08-13 02:55:03 +03:00
});
```
after
```js
2020-08-16 21:25:20 +03:00
var MyFunction_fn = function (tag, value) {
return value;
},
MyFunction = F2(MyFunction_fn);
2020-08-13 02:55:03 +03:00
```
Then, if this function is called with `A2, we can unwrap the wrapper and call the function directly.
before
2020-08-13 02:55:03 +03:00
```js
A2(MyFunction, one two)
```
after
2020-08-13 02:55:03 +03:00
```js
MyFunction_fn(one two)
```
2020-08-16 21:25:20 +03:00
## Results Summary
- Included in `elm-optimize` tool\*\*
2020-08-16 21:25:20 +03:00
- Potentially large positive effect on speed
- Likley small but positive effect on asset size
This has lead to dramatic speedups in some cases, especially when a large number of smaller functions are called and the overhead of calling twice as many functions is significant.
As well, it has a really interesting characteristic in that it makes the initial size of the generated JS **larger**, but usually results in a **smaller** minified asset size.
We generate two definitions for a function, but in most cases a function is either always partially applied, or always called with the full number of arguments.
If a function is always called with the full number of arguments, the minifier can eliminate our wrapped version (`F2(MyFunction_fn)`) and _also_ eliminate the `A2` call, which is explicitly smaller than before.
2020-08-13 02:55:03 +03:00
# Passing unwrapped functions and calling them directly
Let's say we have some elm code that produces the following js.
```js
var f = function (func, a, b) {
return A2(func, a, b);
};
f(
F2(function (a, b) {
return a + b;
}),
1,
2
);
```
we can transform it to
```js
var f = function (func, a, b) {
return A2(func, a, b);
},
f_unwrapped = function (func, a, b) {
return func(a, b); // <-- direct function call!
};
// note that the lambda is unwrapped as well
f_unwrapped(
function (a, b) {
return a + b;
},
1,
2
);
```
This transformation works with separately defined functions too.
2020-08-13 02:55:03 +03:00
# Passing in Unwrappable Functions to Higher Order Functions
**Future Work**
2020-08-16 21:25:20 +03:00
Higher order functions like `List.map` have a hard time taking advantage of the direct function calls because we don't know the arity of the function within the `List.map` call.
2020-08-19 16:25:25 +03:00
This is a challenging case, but worth exploring!
2020-08-13 02:55:03 +03:00
# Making type representation isomorphic
Currently the Elm compiler will generate objects that match the shape of a given type.
2020-08-13 02:55:03 +03:00
`Maybe` looks like this:
2020-08-01 19:56:23 +03:00
```js
var elm$core$Maybe$Just = function (a) {
2020-08-01 19:56:23 +03:00
return { $: 0, a: a };
};
2020-08-01 19:56:23 +03:00
var elm$core$Maybe$Nothing = { $: 1 };
```
However, the V8 engine is likely better able to optimize these objects if they have the same shape.
So, this transformation fills out the rest of the variants with `field: null` so that they have the same shape.
2020-08-01 19:56:23 +03:00
```js
var elm$core$Maybe$Just = function (a) {
2020-08-01 19:56:23 +03:00
return { $: 0, a: a };
};
2020-08-01 19:56:23 +03:00
var elm$core$Maybe$Nothing = { $: 1, a: null };
```
This does require information from the Elm code itself, which we're currently getting through `elm-tree-sitter`.
2020-08-16 21:25:20 +03:00
## Results Summary
- Included
- Has an effect in certain circumstances in browsers using V8(Chrome and Edge). Nothing observable otherwise.
- Most prominently observed in the `Elm Core - sum 300 list of custom types` benchmark. Otherwise I didn't notice it.
2020-08-16 21:25:20 +03:00
- No noticable effect on asset size.
2020-08-13 02:55:03 +03:00
# Inlining literal list constructors
2020-08-13 02:55:03 +03:00
Before
```js
_List_fromArray(['a', 'b', 'c']);
```
2020-08-13 02:55:03 +03:00
After, using `InlineMode.UsingConsFunc`
```js
2020-08-01 19:56:23 +03:00
_List_cons('a', _List_cons('b', _List_cons('c', _List_Nil)));
```
2020-08-13 02:55:03 +03:00
with `InlineMode.UsingLiteralObjects`
2020-08-01 19:56:23 +03:00
```js
2020-08-13 02:55:03 +03:00
({ $: 1, a: 'a', b: { $: 1, a: 'b', b: { $: 1, a: 'c', b: _List_Nil } } });
```
_Note_ - Elm actually had this originally(the literal objects verion)! But there's an issue in Chrome with more than 1000 elements.
2020-08-13 02:55:03 +03:00
There's also tradeoff between asset size and speed.
Also of note, becaue `_List_fromArray` is used for lists of _anything_, that it's likely being deoptimized by the javascript compiler.
2020-08-13 02:55:03 +03:00
There may be a nice trade off here of using `InlineMode.UsingConsFunc`, but only inlining at most 20 elements or something, and then using `List_fromArray` after that.
2020-08-16 21:25:20 +03:00
## Results Summary
- Not included in the elm-optimize tool because it was hard to find a benchmark that reported numbers to justify it.
- Though maybe we just need to be better at benchmarking it
2020-08-16 21:25:20 +03:00
# Object Update
When updating a record in elm via `{ record | field = new }`, elm runs the following function:
```javascript
function _Utils_update(oldRecord, updatedFields) {
var newRecord = {};
for (var key in oldRecord) {
newRecord[key] = oldRecord[key];
}
for (var key in updatedFields) {
newRecord[key] = updatedFields[key];
}
return newRecord;
2020-08-16 21:25:20 +03:00
}
```
We tried a few different variations in order to see if we could speed this up.
The trick here is that we need to copy the entire record so that it has a new reference.
So, we can't just do `record.field = new` in the js.
All of these tricks rely on either the spread operator or `Object.assign`, both of which are not supported in IE.
## Replacing the implementation of `_Util_update`:
Spread operator
```javascript
2020-08-16 21:25:20 +03:00
const _Utils_update = (oldRecord, updatedFields) => {
var newRecord = { ...oldRecord };
for (var key in updatedFields) {
newRecord[key] = updatedFields[key];
}
return newRecord;
};
2020-08-16 21:25:20 +03:00
```
Spread for both
```javascript
const _Utils_update = (oldRecord, updatedFields) => ({
...oldRecord,
...updatedFields,
});
2020-08-16 21:25:20 +03:00
```
Use Object.assign
2020-08-16 21:25:20 +03:00
```javascript
const _Utils_update = (oldRecord, updatedFields) =>
Object.assign({}, oldRecord, updatedFields);
2020-08-16 21:25:20 +03:00
```
## Inline the call altogether
At the call site, replace
```
_Utils_update(old, newFields)
```
2020-08-16 21:25:20 +03:00
with
2020-08-16 21:25:20 +03:00
```
Object.assign({}, old, newFields)
```
## Result Summary
- Not included in elm-optimize tool
- Again, all of these tricks rely on either the spread operator or `Object.assign`, both of which are not supported in IE.
- The most promising approach was inlining the call completely with `Object.assign`.
- Gave a `366%` boost in chrome!
- And caused firefox to reduce performance by 50% :sweat_smile:
Simply creating a new record and copying each field manually is significantly faster than and of these transformations.(~9x in chrome, and ~6.5x in firefox). You can do this directly in elm.
2020-08-16 21:25:20 +03:00
```
updateSingleRecordManually record =
{ one = 87
, two = record.two
, three = record.three
}
```
It's worth exploring automating this transformation, though of course there's a question of how much this affects asset size on larger projects.
2020-08-16 21:25:20 +03:00
However, it's hard to explore further without knowing the actual shape of the records being updated.
2020-08-16 21:25:20 +03:00
**Future work**
Explore more approaches. Next on TODO list:
```
_Utils_update(old, {a: newA})
```
to
```
{...old, a: newA}
```
2020-08-16 21:25:20 +03:00
2020-08-13 02:55:03 +03:00
# Inline Equality
If Elm's `==` is applied to any primitive such as:
- Int
- Float
- String
- Bool
2020-08-13 02:55:03 +03:00
2020-08-19 16:25:25 +03:00
Then we can inline the definition directly as JS strict equality: `===`.
2020-08-13 02:55:03 +03:00
2020-08-16 21:25:20 +03:00
Right now `elm-optimize` will infer if something is a primitive if a literal is used.
## Results Summary
2020-08-13 02:55:03 +03:00
- Included in `elm-optimize` tool.
2020-08-19 16:25:25 +03:00
- Looks to have the some impact on code that does a lot of equality comparisons, like parsing.
The `_Utils_eq` function is very likely deoptimized because it can take _any_ two values and either do a reference check, or do structural equality, which we also know takes a while.
So, my guess is the benefit here is from avoiding the call to a deoptimized function completely.
Chrome doesn't really see a speedup here though, so it's likely smart enough to do that already.
2020-08-13 02:55:03 +03:00
# Inline String.fromFloat/Int
2020-08-14 16:27:15 +03:00
Before
```
String$fromFloat(val)
```
After:
```
val + ""
2020-08-16 21:25:20 +03:00
```
## Results Summary
- Not included in the tool
This hasn't shown any measureable benefit. Likely because this is such a simple function that all js compilers are already optimizing the intermedaite calls.
2020-08-16 21:25:20 +03:00
# Arrowizing Functions
Before
```
var x = function(x){}
```
2020-08-16 21:25:20 +03:00
After
2020-08-16 21:25:20 +03:00
```
var x = (x) => {}
```
This was done for asset size. The nuance being that it's done to potentially optimize the _minified_ size of code, but not necessarily the gzipped version.
2020-08-16 21:25:20 +03:00
This is still a benefit because the minified code is what ultimately needs to be parsed and parsing is one of the larger steps on the way to getting a page running.
2020-08-16 21:25:20 +03:00
## Results Summary
- Not included in the `elm-optimize` tool
2020-08-16 21:25:20 +03:00
- Comes with the caveat that the [code will not work on IE](https://caniuse.com/#feat=arrow-functions)
We weren't able to pin down a benchmark where this reported a benefit in the numbers, though likely to explore this we need (1) A larger codebase, and (2)
2020-08-16 21:25:20 +03:00
We didn't include this in the first version of the tool because the effect seems to be so modest and carries the risk of breaking things on IE.
We would have to add something like a `--modernize` or `--no-ie` flag to the tool, and I really like this tool having no configurability.
# Lifting Constants
**Future Work**
This transformation hasn't been attempted yet, but the idea is that if a constant is detected in a let statement, it can be declared moved to top-level instead of recalculated every function run.
This is risky! You do less computation, but you are (1) moving a bunch of computation to happen on start-up and (2) the results are allocated but can never be freed and (3) you may have data locality issues.
This could be worthwhile in HTML though, where there is a x === y check on nodes:
https://github.com/elm/virtual-dom/blob/master/src/Elm/Kernel/VirtualDom.js#L706-L709
So if two nodes were reference equal, you wouldn't have to ever diff them. I imagine this could be a big benefit if there was a long list where each element contained a somewhat large "constant" node for some UI thing. (edited)
2020-08-19 16:25:25 +03:00
# Eta Conversion
This is when you add or remove anonymous functions:
`map (f x y) zs` to `map (\z -> f x y z) zs`
Because of our previous optimizations where we can call a function directly, this can make sure we're getting the fast version of `f`!