Оптимізація Java-коду в Android Marshmallow

Підвищення продуктивності системи, поліпшення вражень користувачів від роботи з додатками: ось напрями, в яких розвивається 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 постійно розвивається, розробники можуть бути впевнені в тому, що код, який вони пишуть, буде добре оптимізований. А значить, їх програми будуть радувати користувачів швидкістю і приємними враженнями від взаємодії з ними. Ми впевнені, що той, хто буде працювати з такими програмами, це оцінить.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.