Повышение производительности системы, улучшение впечатлений пользователей от работы с приложениями: вот направления, в которых развивается Android. В Android Marshmallow можно обнаружить множество новых функций и возможностей. В частности, речь идёт о серьёзных усовершенствованиях Android Runtime (ART). Они направлены на производительность, потребление памяти и многозадачность.

Вышел новый релиз платформы? Изменилась виртуальная машина Android? Любое из этих событий означает, что разработчику нужно срочно понять суть новшеств. А именно, надо разобраться с тем, какие методы, позволявшие достичь высокой производительности решений в прошлом, теперь уже не так эффективны. Нужно найти новые подходы к разработке приложений, способные дать наилучшие результаты. О подобных тонкостях почти не пишут, поэтому разработчикам приходится выяснять всё это методом проб и ошибок.

Сегодня мы расскажем о том, как писать быстрые и удобные программы с учётом особенностей обновлённой Android Runtime. Наши советы нацелены на повышение производительности и улучшение качества машинного кода, который генерируется из Java-текстов. Так же мы поговорим об особенностях низкоуровневой оптимизации, которая не всегда зависит от исходного Java-кода приложения.

Java-код, повышение производительности и ART


Android – система с довольно сложным внутренним строением. Один из её элементов – компилятор, который преобразует Java-код в машинные команды, например, на устройствах, основанных на процессорах Intel. Android Marshmallow включает в себя оптимизирующий компилятор (Optimizing compiler). Этот новый компилятор оптимизирует Java-приложения, создаёт код, который отличается большей производительностью, чем тот, который выдавал быстрый компилятор (Quick compiler) в Android Lollipop.

В Marshmallow практически все приложения готовят к исполнению с использованием оптимизирующего компилятора. Однако, для методов Android System Framework по-прежнему используют быстрый компилятор. Делается это для того, чтобы дать Android-разработчикам больше возможностей по отладке.

Точность, скорость и математические библиотеки


Для организации вычислений с плавающей точкой можно воспользоваться различными реализациями одних и тех же операций. Так, в Java доступны библиотеки Math и StrictMath. Они предлагают разные уровни точности при проведении вычислений с плавающей точкой. И, хотя StrictMath позволяет получать более предсказуемые результаты, в большинстве случаев вполне достаточно библиотеки Math. Вот метод, в котором вычисляется косинус.

public float myFunc (float x) {
    float a = (float) StrictMath.cos(x); 
    return a;
}

Здесь мы воспользовались соответствующим методом из библиотеки StrictMath, но, если потеря точности вычислений приемлема для конкретного проекта, в похожей ситуации вполне можно использовать и Math.cos(x). Выбирая подходящую библиотеку стоит учесть то, что класс Math оптимизирован в расчёте на использование библиотеки Android Bionic для архитектуры Intel. В результате операции из Math выполняются в 3,5 раза быстрее, чем аналогичные из StrictMath.

В некоторых ситуациях, безусловно, не обойтись без StrictMath, замена его на Math нежелательна. Но, повторимся, в большинстве случаев вполне можно пользоваться Math. Выбор конкретной библиотеки – это не абстрактный поиск компромисса между скоростью и точностью. Здесь следует как можно более полно учесть требования проекта – от особенностей алгоритма и его реализации, до аппаратной платформы, на которой планируется использовать приложение.

Поддержка рекурсивных алгоритмов


Рекурсивные вызовы в Marshmallow более эффективны, нежели в Lollipop. Когда пишется рекурсивный код для Lollipop, всегда производится загрузка данных из DEX-кэша. В Marshmallow то же самое берётся из исходного списка аргументов, а не перезагружается из кэша. Конечно, чем больше глубина рекурсии – тем заметнее разница в производительности между Lollipop и Marshmallow. Однако, если алгоритм, реализованный рекурсивно, можно переписать итеративно, его производительность в Marshmallow будет выше.

Устранение проверки на выход за границу для одного массива: array.length


Оптимизирующий компилятор в Marshmallow позволяет, в некоторых случаях, избегать проверку на выход за границу массива (Bound Check Elimination, BCE).

Взглянем на пустой цикл:

for (int i = 0; i < max; i++) { }

Переменную i называют индуктивной переменной (Induction Variable, IV). Если такая переменная используется для организации доступа к массиву и цикл проходит по всем его элементам, проверку можно не выполнять, если переменная max явно установлена в значение, соответствующее длине массива.

Рассмотрим пример, в котором в коде используется переменная size, играющая роль максимального значения для индуктивной переменной.

int sum = 0;
for (int i = 0; i < size; i++) {
    sum += array[i];
}

В примере индекс элемента массива i сравнивается с переменной size. Предположим, что size либо задана за пределами метода и передана ему в качестве аргумента, либо определена где-то внутри метода. В любом из этих случаев компилятор будет не в состоянии сделать вывод о том, соответствует ли значение в переменной size длине массива. В результате такой вот неопределённости, компилятору придётся сгенерировать код проверки на выход значения i за границы массива, который попадёт в исполняемый файл и будет выполняться при каждой операции доступа к массиву.

Переделаем цикл так, чтобы компилятор избавился от проверки на выход за границы массива в исполняемом коде.

int sum = 0;
for (int i = 0; i < array.length; i++) {
    sum += array[i];
}

Устранение проверки на выход за границы для нескольких массивов


В предыдущем разделе мы рассмотрели простейший случай оптимизации и то, что нужно сделать, чтобы компилятор сгенерировал код, в котором нет проверок выхода за границу массива. Однако, существуют алгоритмы, в которых в одном и том же цикле обрабатывается несколько разных массивов, имеющих одну и ту же длину. Например, если обрабатывать в цикле два массива, компилятору придётся выполнять для каждого из них проверку на null и на выход за границы.

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

for (int i = 0; i < age.length ; i++) {
    totalAge += age[i];
    totalSalary += salary[i];
}

В нём имеется проблема. Программа никак не проверяет длину массива salary, в результате есть риск получить исключение выхода за границы массива. Такую проверку следует предусмотреть, например, на входе в цикл.

for (int i = 0; i < age.length && i < salary.length; i++) {
    totalAge += age[i];
    totalSalary += salary[i];
}

Java-программа теперь выглядит куда лучше, но при таком подходе в машинный код попадёт проверка на выход за границы массивов.
В вышеприведённом цикле программист работает с двумя одномерными массивами: age и salary. И хотя индуктивная переменная проходит проверку сравнением с длинами двух массивов, условие здесь множественное и компилятор не может исключить из исполняемого кода проверки выхода за границы массивов.

Массивы, к которым мы обращаемся в циклах – это структуры данных, которые хранятся в различных областях памяти. Как результат, логические операции, выполняемые над их свойствами, независимы. Перепишем код, разделив один цикл на два.

for (int i = 0; i < age.length; i++) {
    totalAge += age[i];
}          
for (int i = 0;  < salary.length;  i++) {
    totalSalary += salary[i];
}

После разделения циклов оптимизирующий компилятор исключит из исполняемого кода проверку на выход за границу и для массива age, и для массива salary. В результате Java-код можно ускорить в три-четыре раза.

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

Методики многопоточного программирования


В многопоточной программе следует соблюдать осторожность при работе со структурами данных.

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

for (int i = 0; i < number_tasks; i++) {
    thread_array_sum[myThreadIdx] += doWork(i);
}

Общий для всех процессорных ядер кэш последнего уровня (Last Level Cache, LLC) не применяется в некоторых аппаратных архитектурах, например, в линейке процессоров Intel Atom x5-Z8000. Раздельный LLC – это потенциальное сокращение времени отклика, так как для каждого процессорного ядра (или двух ядер) «зарезервирован» собственный кэш. Однако, нужно поддерживать согласованность кэшей. Поэтому, если поток, выполняющийся на ядре A, меняет данные, которые никогда не изменит поток, выполняющийся на ядре B, в кэше ядра B придётся обновлять соответствующие строки. Это может привести к падению производительности и к проблемам с масштабированием ядер.

Из-за особенностей архитектуры кэша, если несколько потоков будут записывать данные в один массив, есть риск столкнуться с падением производительности системы из-за возможного высокого уровня пробуксовки кэша. В подобной ситуации программисту следует воспользоваться локальной переменной для хранения результатов промежуточных вычислений, а после их завершения записать результат в массив. При таком подходе вышеописанный цикл можно преобразовать:

int tmp = 0;
    for (int i = 0; i < number_tasks; i++) {
    tmp += doWork(i);
}
thread_array_sum[myThreadIdx] += tmp;

В данном случае элемент массива thread_array_sum[myThreadIdx] не затрагивают внутри цикла. Итоговое значение, полученное в результате выполнения функции doWork() сохраняется в элементе массива за пределами цикла. Это значительно уменьшает потенциальный риск пробуксовки кэша. Пробуксовка может проявиться и при единственному обращению к массиву, показанному в последней строке кода, но это гораздо менее вероятно.

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

Оптимизация для устройств с небольшой памятью


Существуют очень разные, в плане объёма оперативной и постоянной памяти, Android-устройства. Java-программы следует писать так, чтобы они могли быть оптимизированы независимо от объёма памяти. Так, если в устройстве мало памяти, то один из факторов оптимизации – это размер кода. Это – один из параметров ART.

В Marshmallow методы, размер которых превышает 256 байт, не подвергаются предварительной компиляции для того, чтобы сэкономить постоянную память на устройстве. Поэтому, Java-приложения, которые содержат часто используемые методы большого размера, будут исполняться на интерпретаторе, что негативно скажется на производительности. Для того, чтобы достичь более высокого уровня производительности приложений в Android Marshmallow, реализуйте часто используемые фрагменты кода в виде небольших методов для того, чтобы компилятор мог их полноценно оптимизировать.

Выводы


Каждый выпуск Android – это не только новое название, но и новые элементы системы и технологии. Так было с KitKat и Lollipop, это касается и перехода к Marshmallow, который принёс значительные изменения в компиляции приложений.

Как и в случае с Lollipop, ART использует метод компиляции перед исполнением (Ahead-of-Time), при этом приложения обычно преобразуются в машинный код во время их установки. Однако, вместо использования быстрого компилятора (Quick compiler) Lollipop, Marshmallow задействует новый компилятор – оптимизирующий (Optimizing compiler). Хотя в некоторых случаях оптимизирующий компилятор передаёт работу старому, новый компилятор – это основная подсистема создания двоичного кода из Java-текстов на Android.

У каждого из компиляторов есть собственные особенности и средства оптимизации, поэтому каждый из них может генерировать разный машинный код в зависимости от того, как написано Java-приложение. В этом материале мы рассказали о нескольких главных особенностях, которые можно обнаружить в Marshmallow, о том, что должны знать разработчики, создающие приложения для новой платформы.

В оптимизирующем компиляторе присутствует множество новшеств. Некоторые из них сложно показать на примерах, так как большинство улучшений происходит, так сказать, «под капотом». Что мы знаем, так это то, что с взрослением Android-компилятора можно наблюдать за тем, как технологии, лежащие в его основе, становятся всё более и более совершенными, он постепенно догоняет другие оптимизирующие компиляторы.

Компилятор Android постоянно развивается, разработчики могут быть уверены в том, что код, который они пишут, будет хорошо оптимизирован. А значит, их приложения будут радовать пользователей скоростью и приятными впечатлениями от взаимодействия с ними. Мы уверены, что тот, кто будет работать с такими программами, это оценит.
Поделиться с друзьями
-->

Комментарии (4)


  1. Fen1kz
    18.05.2016 13:52

    Нехватает цифр / тестов. А то пример с оптимизацией одного цикла в два и "в 3-4 раза быстрее" выглядит подозрительно.


  1. lgorSL
    18.05.2016 16:17
    +2

    int sum = 0;
    for (int i = 0; i < size; i++) {
        sum += array[i];
    }

    В результате такой вот неопределённости, компилятору придётся сгенерировать код проверки на выход значения i за границы массива, который попадёт в исполняемый файл и будет выполняться при каждой операции доступа к массиву.

    Но зачем? В данном случае достаточно одной проверки — сравнения значений size и размера массива. Если size <= array.length, проверка на выход за границу не нужна, если size > array.length, опять же без проверки можно пройти все элементы массива, потом кинуть исключение. Неужели компилятор андроида настолько примитивен?


    Кстати, в данном случае можно написать так:


    int sum = 0;
    for (int i:array) {
        sum += i;
    }

    И этот вариант быстрее, если массив является полем объекта, а не переменной внутри метода. тут обоснование


    1. xapienz
      19.05.2016 15:16

      Но зачем? В данном случае достаточно одной проверки — сравнения значений size и размера массива.

      Проблема в том, что внутри цикла в общем случае не обязательно будет эта одна строчка.
      Если код чуточку усложнить, то неопределённость возникнет, и нужно-таки использовать проверку.

      int sum = 0;
      for (int i = 0; i < size; i++) {
          if (i < array.length) {
              sum += array[i];
          }
      }
      


    1. DestroyComputers
      28.05.2016 17:29

      size может измениться в теле цикла и в какой-то момент стать больше, чем array.length. Понятно, что в указанном примере всё нормально, и оно останется неизменным, но отследить этот момент может быть не так просто.