Что делать, если ваша модель работает медленнее, чем ожидалось

Шаги, описанные в этой статье, также задокументированы в этой проблеме GitHub

ONNX Runtime — кроссплатформенный ускоритель машинного обучения для логического вывода и обучения. Он обеспечивает единый стандартизированный формат для выполнения моделей машинного обучения.

Чтобы дать представление о широте поддержки, на изображении ниже показаны все текущие платформы сборки.

ONNX подает большие надежды и является отличным проектом. Это расширяет возможности выполнения модели. Но это не всегда просто.

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

Фон

txtai недавно добавил поддержку моделей преобразования текста в речь. Выбранными моделями были модели PyTorch в ESPnet, экспортированные в ONNX с использованием espnet_onnx. Эти модели ONNX доступны на Hugging Face Hub.

Подробнее об этом можно прочитать по ссылке ниже.



Проблема

В следующих двух разделах кода показан минимальный пример выполнения вывода с использованием ESPnet напрямую (PyTorch) и запуска той же модели через ONNX.

Сначала код, использующий ESPnet напрямую и PyTorch.

import time

from espnet2.bin.tts_inference import Text2Speech

model = "espnet/kan-bayashi_ljspeech_vits"
model = Text2Speech.from_pretrained(model, device="cuda")

def run(text):
    start = time.time()
    output = model(text)
    speech = output["wav"].cpu().numpy()
    print("Time:", time.time() - start)

Запуск приведенного выше кода после прогрева (второй запуск) приводит к следующему:

>>> run("warmup")
>>> run("Text to speech models have recently made great strides in quality")
Time: 0.16863441467285156

Затем модель запускается с ONNX.

import time

import onnxruntime
import yaml

from ttstokenizer import TTSTokenizer

with open("ljspeech-vits-onnx/config.yaml", "r", encoding="utf-8") as f:
    config = yaml.safe_load(f)

tokenizer = TTSTokenizer(config["token"]["list"])

model = onnxruntime.InferenceSession(
    "ljspeech-vits-onnx/model.onnx",
    providers=["CUDAExecutionProvider", "CPUExecutionProvider"]
)

def run(text):
    print(text)

    # Tokenize text to phoneme token ids
    inputs = tokenizer(text)

    start = time.time()
    outputs = model.run(None, {"text": inputs})
    speech = outputs[0]
    print("Time:", time.time() - start)

Запуск этого кода выше после прогрева (второй запуск) приводит к следующему:

>>> run("warmup")
>>> run("Text to speech models have recently made great strides in quality")
Time: 1.134232521057129

0,17 с в PyTorch против 1,13 с в ONNX, оба с включенными графическими процессорами. PyTorch почти в 7 раз быстрее.

Посмотрим, сможем ли мы добраться до сути этого.

Попытка № 1 — привязка ввода-вывода

После пары поисковых запросов в Интернете по запросу PyTorch vs ONNX slow чаще всего всплывал вопрос, связанный с передачей данных от процессора к графическому процессору. Хотя входные данные для этой модели невелики, выходные данные wav могут легко достигать 100 КБ+. Так что это может быть оно. Подробнее о передаче данных в ONNX можно прочитать в Руководстве по API.

Мы изменим наш метод запуска следующим образом и перезапустим его.

def run(text):
    print(text)

    # Tokenize text to phoneme token ids
    inputs = tokenizer(text)

    io_binding = model.io_binding()
    io_binding.bind_cpu_input("text", inputs)
    io_binding.bind_output("wav", "cuda")

    start = time.time()

    # Run model
    model.run_with_iobinding(io_binding)
    outputs = io_binding.copy_outputs_to_cpu()
    print("Time:", time.time() - start)
>>> run("warmup")
>>> run("Text to speech models have recently made great strides in quality")
Time: 1.1370353698730469

Примерно такое же время работы. Не похоже, по крайней мере в этом случае, проблема связана с передачей данных.

Включить профилирование

Мы внесем следующие изменения в нашу программу ONNX, чтобы включить профилирование и повторный запуск, чтобы получить больше информации о замедлении.

opts = onnxruntime.SessionOptions()
opts.enable_profiling = True

model = onnxruntime.InferenceSession(
    "ljspeech-vits-onnx/model.onnx",
     opts,
     providers=["CUDAExecutionProvider", "CPUExecutionProvider"]
)

Файл JSON в формате onnxruntime_profile_{DATE}.json должен находиться в каталоге среды выполнения. Этот файл имеет длинный список элементов JSON с продолжительностью выполнения каждого узла.

Давайте запустим следующее, чтобы извлечь и найти самые медленные узлы.

$ jq . onnxruntime_profile.json | grep dur | cut -d ":" -f2 | sort --numeric

Мы возьмем самые большие времена и поищем их в файле onnxruntime_profile.

Первые несколько раз были связаны с полным запуском модели и инициализацией сеанса. Но после этого выделился этот.

    "cat": "Node",
    "pid": 1027615,
    "tid": 1027615,
    "dur": 313964,
    "ts": 4425685,
    "ph": "X",
    "name": "/w_1/Conv_kernel_time",

И таких было больше. Это сверточный слой.

Попытка №2 — Настройки CUDA

Давайте заглянем в документацию и посмотрим, какие опции CUDA доступны.

cudnn_conv_algo_search — вариант, который выделялся больше всего. Значение по умолчанию EXHAUSTIVE с упоминанием дорогого также показалось уместным.

Давайте попробуем изменить этот параметр и перезапустить.

opts = onnxruntime.SessionOptions()
opts.enable_profiling = True

model = onnxruntime.InferenceSession(
  "ljspeech-vits-onnx/model.onnx",
  opts,
  providers=[
    ("CUDAExecutionProvider", {"cudnn_conv_algo_search": "DEFAULT"}),
    "CPUExecutionProvider"
  ]
)
>>> run("warmup")
>>> run("Text to speech models have recently made great strides in quality")
Time: 0.1624901294708252

Теперь среда выполнения такая же, если не немного лучше, чем PyTorch!

Вот полный код, используемый для достижения этих результатов.

import time

import onnxruntime
import yaml

from ttstokenizer import TTSTokenizer

with open("ljspeech-vits-onnx/config.yaml", "r", encoding="utf-8") as f:
    config = yaml.safe_load(f)

tokenizer = TTSTokenizer(config["token"]["list"])

model = onnxruntime.InferenceSession(
  "ljspeech-vits-onnx/model.onnx",
  providers=[
    ("CUDAExecutionProvider", {"cudnn_conv_algo_search": "DEFAULT"}),
    "CPUExecutionProvider"
  ]
)

def run(text):
    print(text)

    # Tokenize text to phoneme token ids
    inputs = tokenizer(text)

    start = time.time()
    outputs = model.run(None, {"text": inputs})
    speech = outputs[0]
    print("Time:", time.time() - start)

Осмысление результатов

Давайте взглянем на PyTorch и посмотрим, есть ли намеки на то, что может объяснить разницу с настройками по умолчанию.

Файл Conv_v7.cpp в PyTorch имеет следующую логику, которая представляется актуальной.

  // Code reduced to these statements for clarity
  static constexpr auto DEFAULT_ALGO =
     CUDNN_CONVOLUTION_FWD_ALGO_IMPLICIT_PRECOMP_GEMM;

  if (!benchmark) {
    AT_CUDNN_CHECK_WITH_SHAPES(cudnnGetConvolutionForwardAlgorithm_v7(
    )
  } else {
    AT_CUDNN_CHECK_WITH_SHAPES(cudnnFindConvolutionForwardAlgorithmEx(
    )
  }

Открытие нового Python REPL и запуск следующих шоу.

>>> import torch
>>> torch.backends.cudnn.deterministic
False
>>> torch.backends.cudnn.benchmark
False

Это означает, что cudnnGetConvolutionForwardAlgorithm_v7 является функцией по умолчанию. Даже если для параметра benchmark задано значение True, PyTorch будет кэшировать результат, если входная форма является статической. В случае этой модели входы являются динамическими. Дополнительную информацию см. в этой ветке обсуждения.

Давайте проверим это на исходном примере ESPnet PyTorch.

>>> run("warmup")
>>> run("Text to speech models have recently made great strides in quality")
Time: 0.17191290855407715
>>> 
>>> import torch
>>> torch.backends.cudnn.benchmark
False
>>> torch.backends.cudnn.benchmark = True
>>> torch.backends.cudnn.benchmark
True
>>> run("warmup")
>>> run("Text to speech models have recently made great strides in quality")
Time: 1.0886101722717285

Установка для torch.backends.cudnn.benchmark значения True соответствует исходному результату ONNX.

Оглядываясь назад на документацию ONNX выше, в ней говорится:

ЭВРИСТИКА (1)

облегченный эвристический поиск с использованием cudnnGetConvolutionForwardAlgorithm_v7

Похоже, что если не указано иное, PyTorch не выполняет исчерпывающий поиск. По умолчанию PyTorch будет запускать то, что ONNX называет HEURISTIC. При тестировании HEURISTIC и DEFAULT была получена одинаковая производительность.

Подведение итогов

В этой статье рассказывается, как отлаживать производительность модели ONNX, особенно при работе на графическом процессоре. Чаще всего обращают внимание на передачу данных от ЦП к графическому процессору и делают ее более эффективной с помощью привязок ввода-вывода. Но в этом случае изменение настройки CUDA было решением.

Возможно, это единичный случай с данной моделью или железом, на котором проводились тесты. Но это по крайней мере то, что можно проверить, если эта проблема возникнет в будущем!