Я пытался измерить влияние мономорфизации пользовательских типов на производительность типичной функции обновления, когда наткнулся на открытие, которое привело бы к самому большому улучшению производительности, которое я еще не нашел.

Код, который я тестировал, был таким:

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.