В мире Java за последнее время произошло много интересных событий. Одним из таких событий стал выход первой production ready версии Graal VM.
Лично у меня Graal давно вызывает нескрываемый интерес и я пристально слежу за докладами и последними новостями в этой области. Одно время попался на глаза доклад Криса Талингера. В нём Крис рассказывает как в Twitter удалось получить значительный выигрыш в производительности, применив для настройки Graal алгоритмы машинного обучения. У меня появилось стойкое желание попробовать подобное самому. В этой статье хочу поделится тем, что в итоге получилось.
Эксперимент
Для реализации эксперимента мне понадобились:
- cвежий Graal VM Community Edition. На момент написания статьи это 20.2.0
- выделенное облачное окружение для нагрузочного тестирования
- NewRelic для сбора метрик
- генератор тестовой нагрузки
- программа на Python и набор скриптов, для реализации самого алгоритма ML
Если описать задачу сухим языком математики, то она будет выглядеть так
Найти такие значения параметров $inline$A =
(a_1,a_2,..,a_n)$inline$ при которых функция
$inline$f(x_1,x_2,..,x_n)$inline$ принимает максимальное
значение.
Я решил минимизировать потребление процессорного времени и выбрал такую целевую функцию:
$$display$$f=1/mean(CPUUtilization)$$display$$
Чем меньше нагружен процессор, тем ближе целевая функция к
единице.
В качестве параметров которые нужно найти взял те же, что и в
докладе:
-Dgraal.MaximumInliningSize -Dgraal.TrivialInliningSize -Dgraal.SmallCompiledLowLevelGraphSize
Все они отвечают за инлайнинг. Это важная оптимизация, которая
позволяет
сэкономить на вызове методов.
С постановкой задачи оптимизации разобрались. Теперь пришло
время пройтись по шагам
алгоритма оптимизации:
- алгоритм делает предположение о том какие параметры оптимальные
- меняет конфигурацию JVM и запускает нагрузочный тест
- снимает метрики и вычисляет значение целевой функции
- делает новое предположение на основе значения целевой функции
Процесс повторяется несколько раз.
В качестве алгоритма поиска Twitter предлагает использовать байесовскую оптимизацию. Она лучше справляется с зашумленными функциями и не требует большого количества итераций.
Байесовская оптимизация работает путем построения апостериорного
распределения функций,
которое наилучшим образом её описывает. По мере роста количества
наблюдений улучшается апостериорное распределение и алгоритм
становится более определённым в том, какие регионы в пространстве
параметров стоит изучить.
Получение метрик из NewRelic
Для работы с NewRelic REST API необходимо узнать свои APP_ID и
API_KEY.
APP_ID это уникальный идентификатор приложения в системе. Его можно
найти в разделе APM.
API_KEY необходимо создать или узнать из настроек профиля в
NewRelic.
Структура ответа для всех метрик приблизительно одинакова и имеет следующий вид:
{ "metric_data": { "from": "time", "to": "time", "metrics_not_found": "string", "metrics_found": "string", "metrics": [ { "name": "string", "timeslices": [ { "from": "time", "to": "time", "values": "hash" } ] } ] }}
В итоге метод для получения метрик будет таким:
def request_metrics(params): app_id = "APP_ID" url = "https://api.newrelic.com/v2/applications/"+ app_id + "/metrics/data.json" headers = {'X-Api-Key':"API_KEY"} response = requests.get(url, headers=headers, params=params) return response.json()
Для получения CPU Utilzation значение params следующее:
params = { 'names[]': "CPU/User/Utilization", 'values[]': "percent", 'from': timerange[0], 'to': timerange[1], 'raw': "false"}
timerange хранит значения времени начала и конца нагрузочного теста.
Далее парсим запрос и извлекаем необходимые метрики
def get_timeslices(response_json, value_name): metrics = response_json['metric_data']['metrics'][0] timeslices = metrics['timeslices'] values = [] for t in timeslices: values.append(t['values'][value_name]) return values
Алгоритм оптимизации
Перейдем к самому интересному поиску оптимальных параметров.
Для реализации байесовской оптимизации взял уже готовую библиотеку BayesianOptimization.
Сначала создадим метод для вычисления целевой функции.
def objective_function(maximumInliningSize, trivialInliningSize, smallCompiledLowLevelGraphSize): update_config(int(maximumInliningSize), int(trivialInliningSize), int(smallCompiledLowLevelGraphSize)) timerange = do_test() data = get_results(timerange) return calculate(data)
Метод _updateconfig вызывает скрипт, который обновляет конфиг приложения. Далее в _dotest происходит вызов скрипта для запуска нагрузочного теста.
Каждое изменение конфигурации требует перезапуска JVM и первые несколько минут идёт фаза прогрева. Эту фазу необходимо отфильтровать, откинув первые минуты.
В методе calculate вычисляем целевую функцию:
value = 1 / (mean(filtered_data))
Необходимо ограничить поиск
pbounds = { 'maximumInliningSize': (200, 500), 'trivialInliningSize': (10, 25), 'smallCompiledLowLevelGraphSize': (200, 650) }
Так как улучшение должно быть относительно настроек по умолчанию, то я добавил соответствующую точку
optimizer.probe( params={"maximumInliningSize": 300.0, "trivialInliningSize": 10.0, "smallCompiledLowLevelGraphSize": 300.0}, lazy=True, )
Окончательный метод ниже
def autotune(): pbounds = { 'maximumInliningSize': (200, 500), 'trivialInliningSize': (10, 25), 'smallCompiledLowLevelGraphSize': (200, 650) } optimizer = BayesianOptimization( f=objective_function, pbounds=pbounds, random_state=1, ) optimizer.probe( params={"maximumInliningSize": 300.0, "trivialInliningSize": 10.0, "smallCompiledLowLevelGraphSize": 300.0}, lazy=True, ) optimizer.maximize( init_points=2, n_iter=10, ) print(optimizer.max)
В данном примере _objectivefunction выполнится 12 раз и
в конце выведет значение
параметров, при которых наша целевая функция была максимальная. Чем
больше итераций, то тем точнее мы приближаемся к максимуму
функции.
При необходимости все итерации можно вывести вот так:
for i, res in enumerate(optimizer.res): print("Iteration {}: \n\t{}".format(i, res))
Код выведет значения целевой функции и параметров для каждой итерации.
Iteration 0: {'target': 0.02612330198537095, 'params': {'maximumInliningSize': 300.0, 'smallCompiledLowLevelGraphSize': 300.0, 'trivialInliningSize': 10.0}}Iteration 1: {'target': 0.02666666666666667, 'params': {'maximumInliningSize': 325.1066014107722, 'smallCompiledLowLevelGraphSize': 524.1460220489712, 'trivialInliningSize': 10.001715622260173}}...
Результаты
Сравнил два прогона нагрузочного теста с настройками по умолчанию и вычисленными в ходе эксперимента.
На графике можно заметить, что CPU Utilization уменьшился для случая Graal
Среднее время отклика также незначительно снизилось:
Пропускная способность ограничена сверху и никак не менялась для обоих прогонов.
Заключение
В итоге удалось получить снижение нагрузки CPU в среднем на 4-5%.
Для нашего проекта такая экономия на CPU не существенна, но для
proof of concept
результат достаточно неплохой.
С2 много лет оптимизировали под Java и поэтому соревноваться Graal с С2 пока сложно. Больше выгоды можно получить от связки Graal с другими JVM языками, такими как Scala и Kotlin.