Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
В мире Java за последнее время произошло много интересных событий. Одним из таких событий стал выход первой production ready версии Graal VM.
Лично у меня Graal давно вызывает нескрываемый интерес и я пристально слежу за докладами и последними новостями в этой области. Одно время попался на глаза доклад Криса Талингера. В нём Крис рассказывает как в Twitter удалось получить значительный выигрыш в производительности, применив для настройки Graal алгоритмы машинного обучения. У меня появилось стойкое желание попробовать подобное самому. В этой статье хочу поделится тем, что в итоге получилось.
Эксперимент
Для реализации эксперимента мне понадобились:
- cвежий Graal VM Community Edition. На момент написания статьи это 20.2.0
- выделенное облачное окружение для нагрузочного тестирования
- NewRelic для сбора метрик
- генератор тестовой нагрузки
- программа на Python и набор скриптов, для реализации самого алгоритма ML
Если описать задачу сухим языком математики, то она будет выглядеть так
Найти такие значения параметров при которых функция
принимает максимальное значение.
Я решил минимизировать потребление процессорного времени и выбрал такую целевую функцию:
Чем меньше нагружен процессор, тем ближе целевая функция к единице.
В качестве параметров которые нужно найти взял те же, что и в докладе:
-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.