Преобразование ваших контейнерных моделей как сервисов в пакетные задания
GravityAI — это торговая площадка для ML-моделей, где специалисты по исследованию данных могут публиковать свои модели в виде контейнеров, а потребители могут подписаться на доступ к этим моделям. В этой статье мы поговорим о различных вариантах развертывания этих контейнеров, а затем рассмотрим их использование для пакетных заданий с помощью Meadowrun — библиотеки с открытым исходным кодом для запуска кода Python и контейнеров в облаке.
Контейнеры против библиотек
В этом разделе изложена мотивация этого поста — если вы заинтересованы просто в том, чтобы все работало, переходите к следующему разделу!
Если вы рассматриваете ML-модель как библиотеку, то может показаться более естественным опубликовать ее как пакет, либо на PyPI для использования с pip, либо на Anaconda.org для использования с conda, а не как контейнер. Трансформаторы Hugging Face являются хорошим примером — вы запускаете pip install transformers
, после чего ваш интерпретатор Python может делать такие вещи, как:
from transformers import pipeline
classifier = pipeline("sentiment-analysis")
classifier("We are very happy to show you the 🤗 Transformers library.")
У этого подхода есть несколько недостатков:
- Для pip (не для conda) нет возможности выразить в пакетах зависимости не от Python. С библиотекой transformers вы обычно зависите от чего-то вроде CUDA для поддержки GPU, но пользователи должны знать, что это нужно устанавливать отдельно.
- Библиотеке transformers также необходимо загрузить гигабайты весов моделей, чтобы сделать что-нибудь полезное. Они кэшируются в локальном каталоге, поэтому их не нужно загружать при каждом запуске, но в некоторых случаях вы можете захотеть сделать эти веса моделей фиксированной частью развертывания.
- Наконец, при публикации пакета pip или conda пользователи ожидают, что вы будете указывать зависимости относительно гибко. Например, пакет transformers указывает «tensorflow>=2.3», который заявляет, что пакет работает с любой версией tensorflow 2.3 или выше. Это означает, что если сопровождающие tensorflow внесут ошибку или нарушат обратную совместимость, они могут фактически заставить ваш пакет перестать работать (добро пожаловать в ад зависимостей). Гибкие зависимости полезны для библиотек, потому что это означает, что больше людей могут установить ваш пакет в свои среды. Но для развертывания, например, если вы развертываете модель в своей компании, вы не используете преимущества этой гибкости и предпочитаете иметь уверенность в том, что она будет работать каждый раз, и воспроизводимость развертывания.
Одним из распространенных способов решения этих проблем является создание контейнера. Вы можете установить CUDA в контейнер, если вы включите веса модели в образ, Docker позаботится о том, чтобы у вас была только одна копия этих данных на каждой машине, если вы правильно управляете слоями Docker, и вы сможете выбрать и протестировать конкретную версию tensorflow, которая войдет в контейнер.
Итак, мы решили кучу проблем, но обменяли их на новую проблему, которая заключается в том, что каждый контейнер работает в своем собственном маленьком мире, и мы должны проделать определенную работу, чтобы открыть наш API для пользователя. С библиотекой потребитель может просто вызвать функцию Python, передать некоторые входные данные и получить обратно некоторые выходные. В случае контейнера наша модель больше похожа на приложение или сервис, поэтому у потребителя нет встроенного способа «вызвать» контейнер с некоторыми входными данными и получить обратно некоторые выходные данные.
Один из вариантов — создать интерфейс командной строки, но это требует явного связывания входных и выходных файлов с контейнером. Это кажется немного неестественным, но мы можем увидеть пример этого в этом контейнерном изображении ImageMagick от dpokidov. В разделе «Использование» автор рекомендует привязать локальную папку к контейнеру, чтобы запустить его как приложение командной строки.
Традиционным ответом на этот вопрос для Docker-образов является предоставление API на основе HTTP, что и делают контейнеры Gravity AI. Но это означает превращение нашей функции (например, classifier()
) в сервис, а значит, нам нужно придумать, где разместить этот сервис. Чтобы снова дать традиционный ответ, мы можем развернуть его в Kubernetes с автоскалером и балансировщиком нагрузки перед ним, что может хорошо работать, если, например, у вас есть постоянный поток процессов, которым нужно вызывать classifier()
. Но вместо этого вы можете столкнуться с ситуацией, когда некоторые данные от поставщика появляются каждые несколько часов, что запускает задание пакетной обработки. В этом случае все может быть немного странно. Вы можете оказаться в ситуации, когда пакетное задание запущено, вызывает classifier()
, а затем вынуждено ждать, пока автоскалер/Kubernetes найдет свободную машину, которая сможет запустить сервис, в то время как машина, на которой выполняется пакетное задание, простаивает.
Другими словами, оба варианта (библиотека против сервиса) разумны, но у них есть свои недостатки.
В качестве небольшого отступления можно представить себе способ получить лучшее из обоих миров с помощью расширения для Docker, которое позволит вам опубликовать контейнер, открывающий Python API, чтобы кто-то мог вызвать sentiment = call_container_api(image="huggingface/transformers", "my input text")
прямо из своего кода на python. Это фактически будет удаленный вызов процедуры в контейнере, который не работает как сервис, а создан только для выполнения функции по требованию. Это кажется очень тяжелым подходом к решению проблемы зависимостей, но если ваши библиотеки используют кросс-платформенный формат памяти (привет Apache Arrow!), то вы можете представить, что для уменьшения накладных расходов можно применить некоторые забавные трюки, например, предоставить контейнеру доступ только для чтения в пространство памяти вызывающей стороны. Это немного неправдоподобно, но иногда полезно набросать такие идеи, чтобы прояснить компромиссы, на которые мы идем, используя доступные нам более практичные технологии.
Использование моделей в контейнерах локально
В этой статье мы рассмотрим сценарий пакетных заданий с контейнерами. Чтобы конкретизировать ситуацию, предположим, что каждое утро поставщик предоставляет нам относительно большой дамп всего, что все говорили в интернете (Twitter, Reddit, Seeking Alpha и т.д.) о компаниях из S&P 500 за ночь. Мы хотим передать эти фрагменты текста в FinBERT, который представляет собой версию BERT, доработанную для анализа финансовых настроений. BERT — это языковая модель от Google, которая была передовой на момент публикации в 2018 году.
Мы будем использовать контейнер Gravity AI для FinBERT, но мы также предположим, что мы работаем в среде, ориентированной в основном на пакетные процессы, поэтому у нас нет, например, кластера Kubernetes, и даже если бы мы его создали, вероятно, было бы сложно обеспечить правильное автомасштабирование из-за нашей модели использования.
Если мы просто пробуем это на нашей локальной машине, то все довольно просто, согласно документации Gravity AI:
docker load -i Sentiment_Analysis_o_77f77f.docker.tar.gz
docker run -d -p 7000:80 gx-images:t-39c447b9e5b94d7ab75060d0a927807f
Sentiment_Analysis_o_77f77f.docker.tar.gz
— это имя файла, который вы загружаете из Gravity AI, а gx-images:t-39c447b9e5b94d7ab75060d0a927807f
— это имя образа Docker после его загрузки из файла .tar.gz, который появится в выводе первой команды.
А затем мы можем написать небольшой код клея:
import time
import requests
def upload_license_file(base_url: str) -> None:
with open("Sentiment Analysis on Financial Text.gravity-ai.key", "r") as f:
response = requests.post(f"{base_url}/api/license/file", files={"License": f})
response.raise_for_status()
def call_finbert_container(base_url: str, input_text: str) -> str:
add_job_response = requests.post(
f"{base_url}/data/add-job",
files={
"CallbackUrl": (None, ""),
"File": ("temp.txt", input_text, "text/plain"),
"MimeType": (None, "text/plain"),
}
)
add_job_response.raise_for_status()
add_job_response_json = add_job_response.json()
if (
add_job_response_json.get("isError", False)
or add_job_response_json.get("errorMessage") is not None
):
raise ValueError(f"Error from server: {add_job_response_json.get('errorMessage')}")
job_id = add_job_response_json["data"]["id"]
job_status = add_job_response_json["data"]["status"]
while job_status != "Complete":
status_response = requests.get(f"{base_url}/data/status/{job_id}")
status_response.raise_for_status()
job_status = status_response.json()["data"]["status"]
time.sleep(1)
result_response = requests.get(f"{base_url}/data/result/{job_id}")
return result_response.text
def process_data():
base_url = "http://localhost:7000"
upload_license_file(base_url)
# Pretend to query input data from somewhere
sample_data = [
"Finnish media group Talentum has issued a profit warning",
"The loss for the third quarter of 2007 was EUR 0.3 mn smaller than the loss of"
" the second quarter of 2007"
]
results = [call_finbert_container(base_url, line) for line in sample_data]
# Pretend to store the output data somewhere
print(results)
if __name__ == "__main__":
process_data()
call_finbert_container
вызывает REST API, предоставляемый контейнером, для отправки задания, опрашивает о завершении задания, а затем возвращает результат задания. process_data
делает вид, что получает некоторые текстовые данные и обрабатывает их с помощью нашего контейнера, а затем делает вид, что записывает результат куда-то еще. Мы также предполагаем, что вы загрузили ключ Gravity AI в текущий каталог как Sentiment Analysis on Financial Text.gravity-ai.key
.
Использование моделей в контейнерах в облаке
Это отлично подходит для локальной игры с нашей моделью, но в какой-то момент мы, вероятно, захотим запустить ее в облаке, либо для доступа к дополнительным вычислениям, будь то CPU или GPU, либо для запуска в качестве запланированного задания, например, с помощью Airflow. Обычно мы упаковываем наш код (finbert_local_example.py и его зависимости) в контейнер, что означает, что теперь у нас есть два контейнера — один, содержащий наш glue-код, и контейнер FinBERT, который нам нужно запустить вместе и скоординировать (т.е. наш контейнер glue-кода должен знать адрес/имя контейнера FinBERT, чтобы получить к нему доступ). Мы можем начать тянуться к Docker Compose, который отлично подходит для долгоиграющих сервисов, но в контексте распределенного пакетного задания или задания по расписанию с ним будет сложно работать.
Вместо этого мы будем использовать Meadowrun, чтобы сделать большую часть тяжелой работы. Meadowrun не только позаботится об обычных трудностях выделения экземпляров, развертывания нашего кода и т.д., но и поможет запустить дополнительный контейнер и сделать его доступным для нашего кода.
Чтобы следовать этому, вам потребуется создать окружение. Мы покажем, как это работает с pip в Windows, но вы должны быть в состоянии следовать за ним с помощью выбранного вами пакетного менеджера (окружения conda не работают на разных платформах, поэтому conda будет работать, только если вы работаете в Linux).
python -m venv venv
venvScriptsactivate.bat
pip install requests meadowrun
meadowrun-manage-ec2 install
Это создаст новый virtaulenv, добавит requests и meadowrun, а затем установит Meadowrun в вашу учетную запись AWS.
Когда вы загружаете контейнер с Gravity AI, он приходит в виде файла .tar.gz, который необходимо загрузить в реестр контейнеров для работы. В документации по Gravity AI есть несколько более подробные инструкции, но вот краткая версия того, как создать репозиторий ECR (Elastic Container Registry) и загрузить в него контейнер из Gravity AI:
aws ecr create-repository --repository-name mygravityaiaws ecr get-login-password | docker login --username AWS --password-stdin 012345678901.dkr.ecr.us-east-2.amazonaws.comdocker tag gx-images:t-39c447b9e5b94d7ab75060d0a927807f 012345678901.dkr.ecr.us-east-2.amazonaws.com/mygravityai:finbertdocker push 012345678901.dkr.ecr.us-east-2.amazonaws.com/mygravityai:finbert
012345678901
появляется в нескольких местах в этом фрагменте, и его нужно заменить на идентификатор вашей учетной записи. Вы увидите идентификатор вашей учетной записи в выводе первой команды как registryId
.
Еще один шаг, прежде чем мы сможем запустить код: нам нужно дать права роли Meadowrun на этот репозиторий ECR.
meadowrun-manage-ec2 grant-permission-to-ecr-repo mygravityai
Теперь мы можем запустить наш код:
import asyncio
import meadowrun
from finbert_local_example import upload_license_file, call_finbert_container
# Normally this would live in S3 or a database somewhere
_SAMPLE_DATA = {
"Company1": [
"Commission income fell to EUR 4.6 mn from EUR 5.1 mn in the corresponding "
"period in 2007",
"The purchase price will be paid in cash upon the closure of the transaction, "
"scheduled for April 1 , 2009"
],
"Company2": [
"The loss for the third quarter of 2007 was EUR 0.3 mn smaller than the loss of"
" the second quarter of 2007",
"Consolidated operating profit excluding one-off items was EUR 30.6 mn, up from"
" EUR 29.6 mn a year earlier"
]
}
def process_one_company(company_name: str):
base_url = "http://container-service-0"
data_for_company = _SAMPLE_DATA[company_name]
upload_license_file("http://container-service-0")
results = [call_finbert_container(base_url, line) for line in data_for_company]
return results
async def process_data():
results = await meadowrun.run_map(
process_one_company,
["Company1", "Company2"],
meadowrun.AllocCloudInstance("EC2"),
meadowrun.Resources(logical_cpu=1, memory_gb=1.5, max_eviction_rate=80),
await meadowrun.Deployment.mirror_local(working_directory_globs=["*.key"]),
sidecar_containers=meadowrun.ContainerInterpreter(
"012345678901.dkr.ecr.us-east-2.amazonaws.com/mygravityai", "finbert"
),
)
print(results)
if __name__ == "__main__":
asyncio.run(process_data())
Давайте пройдемся по функции process_data
построчно.
- Функция run_map в Meadowrun запускает указанную функцию (в данном случае
process_one_company
) параллельно на облаке. В данном случае мы предоставляем два аргумента (["Company1", "Company2"]
), поэтому мы запустим эти две задачи параллельно. Идея заключается в том, что мы разделяем рабочую нагрузку, чтобы быстро завершить работу. - AllocCloudInstance указывает Meadowrun запустить экземпляр EC2 для выполнения этого задания, если он еще не запущен, а Resources указывает Meadowrun, какие ресурсы необходимы для выполнения этого кода. В данном случае мы запрашиваем 1 процессор и 1,5 ГБ оперативной памяти на задание. Мы также указываем, что мы не против точечных экземпляров с частотой выселения до 80% (вероятность прерывания).
- mirror_local говорит Meadowrun, что мы хотим использовать код в текущей директории, что важно, поскольку мы используем некоторый код из finbert_local_example.py. По умолчанию Meadowrun загружает только файлы .py, но в нашем случае нам нужно включить файл .key в текущий рабочий каталог, чтобы мы могли применить лицензию Gravity AI.
- Наконец, container_services указывает Meadowrun запустить контейнер с указанным образом для каждой параллельно выполняемой задачи. Каждая задача может получить доступ к связанному с ней контейнеру как
container-service-0
, что можно увидеть в коде дляprocess_one_company
. Если вы следите за развитием событий, вам нужно будет снова отредактировать идентификатор учетной записи, чтобы он соответствовал вашему идентификатору учетной записи.
Давайте рассмотрим несколько наиболее важных строк из вывода:
Launched 1 new instance(s) (total $0.0209/hr) for the remaining 2 workers:
ec2-13-59-48-22.us-east-2.compute.amazonaws.com: r5d.large (2.0 CPU, 16.0 GB), spot ($0.0209/hr, 2.5% chance of interruption), will run 2 workers
Здесь Meadowrun сообщает нам, что он запускает самый дешевый экземпляр EC2, который может выполнить наше задание. В данном случае мы платим всего 2¢ в час!
Далее Meadowrun реплицирует нашу локальную среду на экземпляр EC2, создав новый образ контейнера, а затем также извлечет контейнер FinBERT, который мы указали:
Building python environment in container 4e4e2c...
...
Pulling docker image 012345678901.dkr.ecr.us-east-2.amazonaws.com/mygravityai:finbert
Конечным результатом будет вывод данных из модели FinBERT, который будет выглядеть следующим образом:
[['sentence,logit,prediction,sentiment_scorenCommission income fell to EUR 4.6 mn from EUR 5.1 mn in the corresponding period in 2007,[0.24862055 0.44351986 0.30785954],negative,-0.1948993n',
...
Заключительные замечания
Gravity AI упаковывает ML-модели в контейнеры, которые очень легко использовать в качестве сервисов. Заставить эти контейнеры работать естественно для пакетных заданий требует некоторой работы, но Meadowrun делает это легко!