Я пытался измерить влияние мономорфизации пользовательских типов на производительность типичной функции обновления, когда наткнулся на открытие, которое привело бы к самому большому улучшению производительности, которое я еще не нашел.
Код, который я тестировал, был таким:
type alias Model =
{ someString : String
, someNum : Int
, sortKey : ( String, Int )
}
emptyRecord : Model
emptyRecord =
{ someString = "Str"
, someNum = 0
, sortKey = ( "Str", 0 )
}
type Msg
= SetString String
| SetAll String Int
update : Msg -> Model -> Model
update msg model =
case msg of
SetString val ->
{ model
| someString = val
, sortKey = ( val, model.someNum )
}
SetAll str int ->
{ model
| someString = str
, someNum = int
, sortKey = ( str, int )
}
range : List Int
range =
List.range 0 100
suite : Benchmark
suite =
describe "Updates"
[ benchmark "Update" <|
\_ ->
List.foldl updater emptyRecord range
]
updater : Int -> Model -> Model
updater idx rec =
rec
|> update (SetString ("String" ++ String.fromInt idx))
|> update (SetAll ("Str-" ++ String.fromInt idx) idx)
Функция обновления вызывается 100 раз с каждым типом сообщения и предыдущей моделью в качестве параметров. Запустив это через deoptigate, вы обнаружите кое-что интересное:
Как и ожидалось, свойство $
сообщения считается полиморфным. Что не ожидается, так это то, что встроенный кэш для model.O
или model.someNum
находится в полиморфном состоянии. Модель никогда не меняет форму, так почему же это происходит?
Раскрытие скрытого изменения формы
Ответ заключается в том, как Elm выполняет обновление записей. Рассмотрим подробнее встроенную функцию обновления:
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;
}
Elm неизменяем, поэтому при обновлении записи мы сначала должны сделать копию исходной записи. Получив копию, мы можем перезаписать некоторые поля новыми значениями.
Проблема здесь в том, что при создании копии Elm начинает с создания нового пустого объекта, который, естественно, не имеет той же формы, что и объект, который мы хотим скопировать. Затем он продолжает добавлять свойства к этому новому пустому объекту, каждый раз изменяя форму объекта. В конце концов, у нас есть новый объект, который содержит те же значения, что и наш исходный объект, но формы отличаются, потому что они были построены по-другому.
Это означает, что если у вас есть функция, которая работает с определенным типом записи, и этой функции передается запись, которая в какой-то момент была обновлена, доступ ко всем свойствам записи будет полиморфным.
Другая проблема с тем, как Elm выполняет обновления записей, заключается в том, что два цикла с набором доступа к свойствам (большинство из которых, вероятно, будут полиморфными или даже мегаморфными) неизбежно будут медленнее, чем простое создание объекта напрямую с использованием объектного литерала.
Если бы мы могли заменить все обновления записей эквивалентом литерального выражения объекта, мы бы не только повысили производительность обновлений записей, но и улучшили бы доступ к свойствам везде в обновленной записи.
Оптимизация обновлений записей
elm-optimize-level-2
— это транспилятор JavaScript в JavaScript, поэтому он не имеет доступа к информации о типах Elm. К счастью, компилятор Elm выводит литералы объектов с короткими, но уникальными ключами. Таким образом, две записи должны иметь одинаковую форму только в том случае, если они относятся к одному и тому же типу записей.
Мы можем использовать это, сначала просмотрев весь вывод JavaScript и зарегистрировав различные формы записи. С помощью этой информации мы можем создавать функции-конструкторы верхнего уровня. Итак, если мы найдем литерал объекта, например:
var person = { o: "Name", iJ: 32 };
Мы можем сгенерировать функцию в верхней части файла, которая выглядит так:
function $$Record1(a, b) {
this.o = a;
this.iJ = b;
}
Затем нам нужно заменить все литералы объектов вызовами этой новой функции-конструктора, например:
var person = new $$Record1("Name", 32);
Что это нам дает? Теперь, когда мы преобразовали все записи в эквивалент класса JavaScript, мы можем прикрепить к нему методы. Полезной операцией является метод clone
:
$$Record1.prototype.$clone = function() {
return new $$Record1(this.o, this.iJ);
}
При этом мы можем преобразовать выражение обновления записи, которое первоначально выглядело так:
var newPerson = _Utils_update(person, { o: "New name" });
в:
var newPerson = (function()
var $c = person.$clone()
$c.o = "New name";
return $c;
)();
При этом все обновления записей должны выполняться не только быстрее, но и весь доступ к свойствам обновленных записей теперь также должен быть мономорфным.
Полученные результаты
Запуск бенчмарка дает нам следующее:
Record Update
safari, original 15,649 runs/sec
safari, altered 90,779 runs/sec (580%)
firefox, original 33,058 runs/sec
firefox, altered 99,995 runs/sec (302%)
chrome, original 31,714 runs/sec
chrome, altered 267,819 runs/sec (844%)
Это безумие.
Это преобразование уже находится в elm-optimize-level-2
, и его можно включить, передав флаг -O3
. Вы спросите, почему оптимизация скрыта за флагом?
Как вы могли догадаться, преобразование действительно увеличивает размер конечного пакета JavaScript. В моем тестировании размер актива увеличился примерно на 5%. Однако это число будет варьироваться в зависимости от того, сколько типов записей вы выполняете обновления.
На этом моя работа по оптимизации среды выполнения Elm завершается. Надеюсь, это было и весело, и познавательно. Если вам нужна более высокая производительность в приложениях Elm, попробуйте elm-optimize-level-2
.