Как стать автором
Обновить

Удав укрощает Graal VM

Время на прочтение5 мин
Количество просмотров3.7K


В мире Java за последнее время произошло много интересных событий. Одним из таких событий стал выход первой production ready версии Graal VM.


Лично у меня Graal давно вызывает нескрываемый интерес и я пристально слежу за докладами и последними новостями в этой области. Одно время попался на глаза доклад Криса Талингера. В нём Крис рассказывает как в Twitter удалось получить значительный выигрыш в производительности, применив для настройки Graal алгоритмы машинного обучения. У меня появилось стойкое желание попробовать подобное самому. В этой статье хочу поделится тем, что в итоге получилось.  


Эксперимент


Для реализации эксперимента мне понадобились:


  1. cвежий Graal VM Community Edition. На момент написания статьи это 20.2.0
  2. выделенное облачное окружение для нагрузочного тестирования
  3. NewRelic для сбора метрик
  4. генератор тестовой нагрузки
  5. программа на Python и набор скриптов, для реализации самого алгоритма ML

Если описать задачу сухим языком математики, то она будет выглядеть так


Найти такие значения параметров $A = (a_1,a_2,..,a_n)$ при которых функция
$f(x_1,x_2,..,x_n)$ принимает максимальное значение.


Я решил минимизировать потребление процессорного времени и выбрал такую целевую функцию:


$f=1/mean(CPUUtilization)$


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


-Dgraal.MaximumInliningSize -Dgraal.TrivialInliningSize  -Dgraal.SmallCompiledLowLevelGraphSize

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


С постановкой задачи оптимизации разобрались. Теперь пришло время пройтись по шагам
алгоритма оптимизации:


  1. алгоритм делает предположение о том какие параметры оптимальные
  2. меняет конфигурацию JVM и запускает нагрузочный тест
  3. снимает метрики и вычисляет значение целевой функции
  4. делает новое предположение на основе значения целевой функции

Процесс повторяется несколько раз.



В качестве алгоритма поиска 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.

Теги:
Хабы:
+9
Комментарии2

Публикации

Изменить настройки темы

Истории

Работа

Java разработчик
352 вакансии
Python разработчик
132 вакансии
Data Scientist
60 вакансий

Ближайшие события