Compute — волшебная пилюля?

При разработке flutter‑приложения может возникнуть задача, в рамках которой придется выполнять какую‑то «тяжелую» операцию над большим объемом данных. Если потратить на нее больше 16 миллисекунд (или 8, если говорим о 120 fps), то пользователи могут заметить небольшое подлагивание при скролле или анимациях. Во фреймворке подготовлена удобная функция compute, которая выполнит нужную операцию в отдельном изоляте в фоновом режиме.

Казалось бы, вот оно идеальное решение — как только начинаются проблемы, оборачиваем вызов в compute и продолжаем жить без проблемдальше. В большинстве случаев — да, но иногда этого может быть недостаточно.

362960b571b37974c9847777dd586df2.png

Это статья будет полезна тем, кто использует отдельные изоляты для объемных вычислений, а особенно тем, кто передает между ними огромные объемы данных. Чтобы разобраться в ситуации, давайте взглянем на документацию к методу для отправки данных между изолятами.

The send happens immediately and may have a linear time cost to copy the transitive object graph.

Также частично эта история была затронута в недавней статье, где упоминались разные подходы при передаче данных в изолят:

  • для примитивных типов выполняется сериализация в любом случае (при использовании TransferrableTypeData передается указатель, из которого выполняется копирование в память нового изолята);

  • для heap‑объектов в пределах группы изолятов выполняется копирование внутреннего представления и выделение памяти в памяти нового изолята (без полной сериализации);

  • в случае несовпадения групп изолятов выполняется полная сериализация объекта и повторная материализация его в контексте изолята получателя порта.

Если мы говорим о непримитивных типах, то чем больше объект, который мы передаем в изолят, тем больше времени на него затратится. А теперь давайте посмотрим насколько большим должен быть объект, чтобы можно было увидеть разницу в частоте кадров, и можем ли мы как‑то повлиять на затраченное время.

О способе замеров

Скрытый текст

В первой части для всех замеров использовался Macbook Pro 13» M1, 2020, 16 GB RAM. Сборка проводилась на версии dart 3.3.4 командой dart compile exe…. В каждом замере создавался долгоживущий изолят через Isolate.spawn, потом заполнялись данные для передачи разными способами. Перед стартом отправки данных вывожу текущее время в консоль, а первым делом внутри колбэка для изолята вывожу время получения. В каждом замере было по 50 попыток. Количество элементов в коллекциях — 1024×1024 * 100 — такое большое число подойдет для того, чтобы показать разницу между способами передачи.

Во второй части замеров использовалось стандартный counter app, в котором был добавлен CircularProgressIndicator. Сборка проходила в режиме profile, видео с экрана записывалось с Samsung S21

Создание данных производилось до замера следующими способами:

 final size = 1024 * 1024 * 100;
 1) final list = List.filled(size, 0, growable: false);
 2) final list = List.filled(size, 0, growable: true);
 3) final list = List.generate(size, (i) => 0, growable: false);
 4) final list = Int64List(size);
 5) final list = Uint16List(size);
 6) final list = Uint8List(size);
 7) final rawList = [Int64List(size)];
    final list = TransferableTypedData.fromList(rawList);
 8) final list = '0' * size;
 9) final list = generateObject(3);

Результаты замеров

В первой группе выступают обычные целочисленные списки List.filled, List.generated.

Среднее, мс

Медиана, мс

Минимальное, мс

Максимальное, мс

95-й перцентиль, мс

List.filled (growable: false)

714

615,5

341

1516

1347,25

List.filled (growable: true)

624,78

577,5

372

1008

980,2

List.generated (growable: false)

627,14

576,5

369

1210

943,2

Среднее значение, потраченное на передачу таких списков имеет один порядок. Это не удивительно: способ создания списка остается за рамками сегодняшних замеров, а с точки зрения передачи данных результаты сравнимы. Забегая вперед, скажу, что это самый медленный способ передачи из рассмотренных в статье.

Во второй группе идут различные TypedData:

Среднее, мс

Медиана, мс

Минимальное, мс

Максимальное, мс

95-й перцентиль, мс

Int64List

232,32

224,5

117

454

319,2

Uint16List

33,7

30

22

74

57,95

Uint8List

16,78

14

11

58

29,1

Напомню, что на нативных платформах в int входят 64-битные числа, то есть напрямую List уместно сравнивать с Int64List. Среднее время на передачу у него в 3 раза меньше по сравнению с первой группой. Если ваши данные можно преобразовать к Uint16 или Uint8, то результат будет уже на порядок лучше. 

В третьей группе будут TransferrableTypedData и String:

Среднее, мс

Медиана, мс

Минимальное, мс

Максимальное, мс

95-й перцентиль, мс

TransferrableTypedData

0,76

0

0

6

4

String

0,04

0

0

1

0

TransferrableTypedData позволяет передавать readonly данные между изолятами. Они могут быть прочитаны только один раз, но зато их передача O (1), потому что передается только ссылка. По ссылке также передаются неизменяемые объекты (например, строки). Конечно, эта группа безусловный победитель в том, что касается времени на отправку данных между изолятами. 

Предлагаю ещё посмотреть на смешанный пример. Пусть наш объект будет
содержать следующие поля:

class MixedObject {
  final String s;
  final int i;
  final double d;
  final List listS;
  final List listI;
  final List listD;
  final Map mapS;
  final Map> mapSl;
  final List innerState;
  
  //
}

Заполним его случайными значениями, каждый уровень кроме последнего будет содержать по 10 потомков, всего уровней будет 4. 

MixedObject generateObject(int maxDepth, [int depth = 0]) {
 if (depth >= maxDepth) {
   return MixedObject(
     s: 'abcd' * 10,
     i: 123456789,
     d: 2345.345678,
     listS: [for (int i = 0; i < 750; i++) 'asdfg' * i],
     listI: List.filled(100, 1),
     listD: List.filled(100, 1.0),
     mapS: {'a': 1, 'b': 2},
     mapSl: {'a': List.filled(100, 1), 'b': List.filled(100, 1)},
     innerState: [],
   );
 }

 final innerStates = [
   for (int i = 0; i < 10; i++) generateObject(maxDepth, depth + 1)
 ];

 return MixedObject(
   s: 'abcd' * 10,
   i: 123456789,
   d: 2345.345678,
   listS: [for (int i = 0; i < 750; i++) 'asdfg' * i],
   listI: List.filled(100, 1),
   listD: List.filled(100, 1.0),
   mapS: {'a': 1, 'b': 2},
   mapSl: {'a': List.filled(100, 1), 'b': List.filled(100, 1)},
   innerState: innerStates,
 );
}

Если запустим инструменты разработчика и посмотрим на вкладку с памятью, то увидим там следующее:  

Объем занимаемой памяти при создании такого объекта

Объем занимаемой памяти при создании такого объекта

Объем занимаемой памяти обычного dart-приложения, без создания объекта

Объем занимаемой памяти обычного dart-приложения, без создания объекта

При отправке такого объема в dart-приложении получим в среднем 7 мс, а при отправке данных в самом простом flutter-приложении СounterApp уже около 10 мс.

Среднее, мс

Медиана, мс

Минимальное, мс

Максимальное, мс

95-й перцентиль, мс

State (3), macos

7,76

4

2

27

23

State (3), android

10,84

10

6

26

22,1

Если добавим несколько анимаций, то этот долгий кадр уже можно заметить визуально.

a212422f30ba4d4b077c01bc6b0934c9.gif

Конечно, я специально растянул размер передаваемого объекта до того, что на телефоне был бы виден этот junk. Но и ваше приложение может делать в рамках этого кадра множество других задач, а у пользователей могут быть не самые новые телефоны, что в итоге может привести к просадкам fps и на меньших объемах данных.

Выводы

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

По результатам выше однозначно видно, что пара TransferrableTypedData и String справилась с отправкой лучше всех. То есть если стоит задача максимально быстро передать данные в другой изолят, то эти два варианта — идеальное решение. Вам очень повезло, если ваши данные уже представлены этими типами, но если нет, то задача уже сводится к тому, как максимально быстро конвертировать данные в эти типы. Создание TransferrableTypedData занимает O (n), создание строки, из которой потом можно будет десериализовать объект, тоже не бесплатно.

Поэтому если всё‑таки есть потребность передать большие данные, то их стоит разбивать на части, чтобы дать возможность flutter отрисовать новый кадр.

© Habrahabr.ru