Приветствую, хабровчане!
Идея создания данной публикации крутилась в моей голове уже давно, дело в том, что одно из моих хобби связанно с распределёнными вычислениями, а другое хобби связанно с нейросетями и мне давно не давала покоя идея запустить инференс LLM на нескольких компьютерах, но так чтобы все они выполняли работу над одной и той же моделью параллельно.
Погуглив некоторое время узнал, что проект LocalAI уже относительно давно поддерживает такую возможность, недолго думая я раскатал на нескольких компьютерах данный проект, после чего выполнил все необходимые настройки связав все инстансы в единую систему и, мягко говоря, был разочарован, уж слишком "фатально-недостаточным" оказалось данное решение, Docker-образ собран неоптимально, он был огромный по весу и только под amd64
, неотключаемый веб-интерфейс шел в комплекте с проектом, скупой выбор моделей, некоторые из доступных LLM не работали в режиме RPC, все эмбеддинговые модели тоже отказывались запускаться в таком режиме, и так далее и тому подобное.
Повозившись ещё немного, полез в исходники и обнаружил упоминание проекта llama.cpp, затем нашёл вызов бинарника rpc-server
. И вот я оказался на странице llama.cpp/examples/rpc и всё заверте...
Краткий(?) обзор
Давайте для начала спросим GigaChat о том, что такое протокол RPC:
Протокол RPC (Remote Procedure Call) позволяет программам вызывать функции или процедуры в другом адресном пространстве, на удаленных узлах или в независимых системах на том же узле. Он включает в себя сетевой протокол для обмена данными в режиме клиент-сервер и язык сериализации объектов для кодирования данных при их передаче через сеть.
Существуют различные реализации RPC, включая SOA, CORBA и DCOM. Для транспортного уровня часто используются протоколы TCP и UDP, но также существуют реализации на основе HTTP. Примерами реализации RPC являются XML-RPC, который использует XML для кодирования сообщений и HTTP в качестве транспортного механизма, и gRPC, использующий HTTP/2 и Protocol Buffers для описания интерфейсов. RPC широко применяется в различных сетевых сервисах, включая NFS.
В проекте llama.cpp данный протокол реализован в формате клиент-сервер, при этом в роли RPC-клиентов выступают утилиты навроде llama-server
, llama-cli
, llama-embedding
и так далее, а в роли RPC-серверов специализированные бинарники rpc-server
.
Если очень кратко расписать как всё это работает получается следующее:
Некий RPC-клиент, скажем
llama-server
, в момент запуска получает через аргументы командной строки список RPC-серверов и модель;RPC-клиент считывает модель затем "нарезает" её слои таким образом, чтобы они были равномерно распределены между всеми RPC-серверами;
Далее RPC-клиент разливает слои по серверам и запускает инференс.
В общих чертах вся эта схема будет выглядеть следующим образом:
При этом rpc-server
может быть собран под разные бэкенды, это могут быть разные архитектуры процессоров, с поддержкой тех или иных функций, скажем можно собрать один RPC-сервер под x86_64 с поддержкой CUDA, а второй - под x86_64 без CUDA, ну а третий - скажем под ARM64 чтобы на RepkaPi 3 запустить и... RPC-клиент сможет с ними всеми прекрасно работать и выполнять инференс.
Сборка бинарников
Внимательно изучив инструкцию по сборке как сервера так и клиентов пришёл к выводу, что для решения задачи понадобится минимум четыре бинарных файла:
llama-cli
- утилита командной стройки, которая позволяет запускать инференс LLM;llama-embedding
- утилита командной стройки, которая позволяет запускать инференс эмбеддинговых моделей;llama-server
- это очень простой API-сервер который может работать как в режиме инференса LLM так и в режиме инференса эмбеддинговых моделей;rpc-server
- бинарник который будет запускаться на удалённых машинах и выполнять всю работу по инференсу.
Ну так вот, если очень кратко сборку llama.cpp
можно выполнить в три простых шага.
Ставим пакеты необходимые для сборки:
apt install -fyq bash wget git make g++
Клонируем репозиторий к себе на хост и переходит в директорию с исходниками:
git clone https://github.com/ggerganov/llama.cpp.git
cd llama.cpp
Запускаем компиляцию (в инструкции приводится пример через
cmake
, но мне больше нравитсяmake
):
GGML_RPC=ON make llama-server llama-cli llama-embedding rpc-server libggml.so libllama.so
Важно перед перед make
прописать переменную окружения GGML_RPC=ON
(можно и через export, но мне удобнее в inline формате) данная переменная позволяет включить в инструкциях по сборке блоки кода добавляющие поддержку RPC.
По завершению компиляции в директории появятся перечисленные после make
исполняемые бинарные файлы.
Сборка Docker-образов
Возможность компилировать бинарники под разные архитектуры конечно штука полезная, но что делать если у нас имеется скажем десяток компьютеров и виртуальных машин или скажем кластер в Kubernetes, не будем же мы на каждом узле запускать компиляцию? Конечно нет! Вместо этого мы воспользуемся DevOps практиками и соберём бинарники в Docker-образы.
В качестве базового образа с целью унификации была выбрана библиотечная Ubuntu 22.04 LTS, так как она же используется в базовых контейнерах nvidia/cuda.
Для реализации проекта решил использовать multi-stage сборку разделённую на два этапа.
На первом этапе пусть выполняется загрузка всего необходимого для компиляции и собественно сама компиляция:
FROM ubuntu:22.04 AS builder
WORKDIR /app
ARG LLAMACPP_REPO="https://github.com/ggerganov/llama.cpp.git"
ARG LLAMACPP_VERSION="master"
# Install dependencies
RUN apt update -q \
&& apt install -fyq bash wget git make g++ \
&& apt clean
# Clone repo
RUN git clone --branch "$LLAMACPP_VERSION" --depth 1 "$LLAMACPP_REPO"
# Build binaries
WORKDIR /app/llama.cpp
RUN GGML_RPC=ON make -j$(nproc) llama-server llama-cli llama-embedding rpc-server libggml.so libllama.so
А на втором этапе пусть копируются собранные бинарные файлы в чистый базовый образ:
FROM ubuntu:22.04
WORKDIR /app
# Install basic dependencies
RUN apt update -q \
&& apt install -fyq libgomp1 \
&& apt clean
# Create folders
RUN mkdir -pv /app/models
# Copy compiled tools
COPY --from=builder /app/llama.cpp/libllama.so /usr/lib/x86_64-linux-gnu
COPY --from=builder /app/llama.cpp/libggml.so /usr/lib/x86_64-linux-gnu
COPY --from=builder /app/llama.cpp/rpc-server .
COPY --from=builder /app/llama.cpp/llama-cli .
COPY --from=builder /app/llama.cpp/llama-embedding .
COPY --from=builder /app/llama.cpp/llama-server .
# Init entrypoint
ADD entrypoint.sh .
ENTRYPOINT ["/app/entrypoint.sh"]
Полный код Dockerfile в репозитории на GitHub.
Сборка Docker-образов с поддержкой CUDA
Принципиальный отличий от Dockerfile основанном на библиотечной ubuntu нет, разве что на первом этапе сборки используется контейнер nvidia/cuda:devel
:
# Stage 1
FROM nvidia/cuda:12.5.1-devel-ubuntu22.04 AS builder
Ну а команда сборки бинарников с поддержкой CUDA выглядит следующим образом:
GGML_CUDA=ON GGML_RPC=ON make llama-server llama-cli llama-embedding rpc-server libggml.so libllama.so
Как видно помимо GGML_RPC
добавлена ещё и переменная GGML_CUDA
.
На втором этапе используется nvidia/cuda:runtime
:
# Stage 2
FROM nvidia/cuda:12.5.1-runtime-ubuntu22.04
Полный код Dockerfile.cuda в репозитории на GitHub.
Про entrypoint.sh
Поскольку мне хотелось собрать универсальный контейнер который можно использовать в различных режимах потребовалось реализовать специальный entrypoint.sh
скрипт, который будет выполнять каждый раз при запуске контейнера.
По плану контейнер будет работать в следующих режимах:
backend
Режим в котором запускается rpc-server
, команда его запуска сервера выглядит следующим образом:
rpc-server --host "0.0.0.0" --port "50052" --mem "1024"
Тут видно, что есть некая странная опция --mem
она позволяет указать какое количество оперативной памяти (в Мегабайтах) может использовать данный RPC-сервер, если rpc-server собран под CUDA то этот параметр отвечает за количество VRAM (видепамяти), если без поддержки CUDA то за количество RAM (системной оперативной памяти).
server
Режим в котором запускается llama-server
, представляющий из себя простейший API-сервер предоставляющий возможность интерактивного взаимодействия с большими (и малыми) языковыми и эмбеддинговыми моделями, команда запуска выглядит следующим образом:
llama-server --host "0.0.0.0" --port "8080" --model "/app/models/TinyLlama-1.1B-q4_0.gguf" --gpu-layers 99 --rpc backend01:50052,backend02:50052
Тут важно обратить внимание на опцию --gpu-layers
при обычных обстаятельствах она указывает на то сколько слоёв максимум можно выгрузить в память видеокарты, однако, в случае если указана опция --rpc
, её поведение меняется и она указывает сколько слоёв можно выгрузить на RPC-серверы.
С опцией --rpc
в ней мы через запятую перечисляем хосты и порты RPC-серверов, к которым RPC-клиент будет подключаться.
none
Специальный режим, который запускает команду sleep inf
, чтобы можно было подключиться к контейнеру и вручную запустить llama-cli
или скажем llama-embedding
.
Если собрать всё это в рамках одного скрипта то получится универсальный entrypoint.sh.
Кросс-платформенная сборка Docker-образов
Одна из любопытных особенностей библиотечного образа ubuntu является то, что она он поддерживает множество процессорных архитектур, но мне в первую очередь было важно amd64
, arm64
и arm/v7
, первая понятно почему, а вот последние две мне нужны чтобы иметь возможность запускать RPC-сервер на микрокомпьютерах, а вот контейнер nvidia/cuda
поставляется только под архитектуры amd64
и arm64
.
Сама же сборка будет выполняться при помощи docker buildx
специального плагина, расширяющего базовый функционал Docker, в нашем же случае интересно только лишь возможность кросс-компиляции контейнеров, так как сборку под ARM64 планируется выполнять на x86_64 процессоре.
И так, для начала создадим сборщик buildx
, назовём его скажем my_builder
.
docker buildx create --name my_builder --driver=docker-container
Далее, предположим что файл Dockerfile
и entrypoint.sh
находятся в директории под названием llama.cpp:
docker buildx build --builder=my_builder --platform=linux/amd64,linux/arm64,linux/arm/v7 --build-arg LLAMACPP_VERSION=master ./llama.cpp/
Тут видим, что сборка происходит под три архитектуры, в качестве версии используется HEAD
из master
ветки репозитория llama.cpp
.
Добавив опции --tag=${owner}/${repo}:${tag}
и --push
мы сможем тегировать образы и выгружать их в регистри.
Полный пример сборки и публикации контейнеров при помощи GitHub Actions.
Запускаем через Docker Compose
И так, предположим мы собрали несколько контейнеров, запушили их на Docker Hub и теперь хотим запустить всё это добро на своём железе, предположим у нас имеется два сервера, на одном мы можем использовать видеокарту, но при этом только 1Гб VRAM, а на втором у нас нет видеокарты и можно использовать только 2Гб RAM. Мы планируем запустить на них модель TinyLlama 1.1B таким образом чтобы пользователь взаимодействовал с API-сервером.
В общем виде такая схема будет выглядеть следующим образом:
В результате у нас получится следующего вида docker-compose.yml
version: "3.9"
services:
main:
image: evilfreelancer/llama.cpp-rpc:latest
restart: unless-stopped
volumes:
- ./models:/app/models
environment:
APP_MODE: server
APP_MODEL: /app/models/TinyLlama-1.1B-q4_0.gguf
APP_RPC_BACKENDS: backend-cuda:50052,backend-cpu:50052
ports:
- "127.0.0.1:8080:8080"
backend-cpu:
image: evilfreelancer/llama.cpp-rpc:latest
restart: unless-stopped
environment:
APP_MODE: backend
APP_MEM: 2048
backend-cuda:
image: evilfreelancer/llama.cpp-rpc:latest-cuda
restart: "unless-stopped"
environment:
APP_MODE: backend
APP_MEM: 1024
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [ gpu ]
Далее потребуется рядом с docker-compose.yml
создать директорию models
и скачать в неё файл TinyLlama-1.1B-q4_0.gguf.
Запускаем композицию командой:
docker compose up -d
Далее ждём некоторое время и после того как композиция запустится можем попробовать через curl выполнить инференс:
curl \
--request POST \
--url http://localhost:8080/completion \
--header "Content-Type: application/json" \
--data '{"prompt": "Building a website can be done in 10 simple steps:"}'
В ответе будет что-то вроде этого:
Что дальше?
В принципе проектом уже можно пользоваться, в нём есть всё необходимое, а чего нет можно без особых усилий добавить в будущем.
Из любопытного на что я обратил бы ваше внимание это вот этот небольшой PR в проект ollama (который на момент публикации данной статьи ещё висел в несмердженных) и вот это обсуждение, всё там же, в тикетах проекта ollama. Если кратко, то разработчики хотят добавить возможность выполнять распределённый инференс на RPC-бэкендах по типу того, что был продемонстрирован в данной публикации. Так что в дальнейшем я планирую попробовать подружить ollama с моими Docker-контейнерами.
Ещё я планирую использовать данные контейнеры в Kubernetes, поэтому скорее всего в ближайшем будущем подготовлю k8s operator или просто deployment в формате Helm-чарта дабы упростить процедуру развёртывания серверов по нодам.
А ещё у меня на антрисолях есть немало микрокомпьютеров, а также две специальные материнские платы под названием TuringPi v1 для кластеризации RaspberryPi CM3, на них я тоже планирую проводить эксперименты в будущем и именно поэтому среди всех перечисленных архитектур контейнеров ниличествует arm/v7
.
В общем планов грамодьё, было бы время...
За сим откланиваюсь, спасибо что дочитали статью до конца, если вас заинтересовало будущее данного проекта приглашаю ко мне на канал @evilfreelancer в Телеграме.
Ссылки
Прочее:
Комментарии (15)
1TheNikita
14.09.2024 17:14так как сборку под ARM64 планируется выполнять на x86_64 процессоре.
Осталось дождаться полноценной кросс-платформы (если будет, конечно) для большей производительности и в бой.
А так выглядит классно, как раз задумывался об интеграции llama.
Надо будет попробовать в песочнице.
Krypt
14.09.2024 17:14+1Любопытства ради: а какова производительность такого подхода? Скажем, 2 GPU в одной системе против 2х систем с 1 GPU? (все GPU одинаковые)
В моих (довольно скромных) экспериметнах бутылочным горлишком, например при inference llama3 q8 70b с помощью CPU был доступ к памяти - на практике выражалось в том, что увеличене потоков больше 2х не увеличивало производитекльность совершено. (Немного другой случай, но, имхо, показательный)
Так же lamma3 f16 8b на tesla p40 быстрее, чем на gtx 3060 Ti + tesla p40efreelancer Автор
14.09.2024 17:14+1Серъёзных замеров ещё не проводил, поэтому точных цифр дать не смогу, производительность замерял на следующих схемах: 1x RTX 3050, 1x RTX 4090 и пара из этих видекарт соединённых по RPC (сеть 1Гбит), вот gist с замерами.
Это кажется странным, но в режиме RPC инференс либо чуть быстрее, либо такой же как на самой быстрой карте.
UPD. Добавлю, что основная моя цель была не в том, чтобы ускорить инференс (хотя это было бы приятным бонусом), а в том чтобы выпонять его на кластере из маломощных микрокомпьютеров, которые по отдельности не способны на инференс больших моделей, скажем на жмене RaspberryPi CM3.
Krypt
14.09.2024 17:14Ну вообще по поводу быстродействия вопрос вполне себе практичный: как альтернатива, вы можете нафигать в систему ssd до сатурации pci-e по скорости и использовать прямой доступ GPU к этим самым ssd (nvidia gpu это умеют). Другой вопрос сколько это стоить будет, но "не сильно много"... А то llama3 q8 70b на CPU я тоже запустит могу, со скоростью 1.2 token/sec...
rPman
14.09.2024 17:14+1на какой машине будет выделяться память под KV-cache? так как для для 128к нужно порядка 16гб памяти только под контекст, а ведь его нужно умножать еще на батчинг, который ускоряется значительно именно в случае испоьзования нескольких нод/видеокарт?!
efreelancer Автор
14.09.2024 17:14Если я правильно понял в формате RPC схемы все низовые работы по инференсу происходят на стороне бэкенда, следовательно если мы имеем систему из нескольких серверов работа будет распределена между ними равномерно (с учётом доступной
rpc-server
RAM или VRAM), следовательно можно предположить, что вся работа с кешем и его хранение будет происходить на бэкенде.Косвенно для меня это подтверждается в этой issue на гитхабе, если в двух словах, то пользователь жалуется на сегфолты когда он отключает кеширование на стороне бэкенда.
А вот как это всё синхронизируется мне пока что непонятно.
UnknownHero
14.09.2024 17:14+3https://github.com/evilsocket/cake
Оставлю тут на всякий случайefreelancer Автор
14.09.2024 17:14Занятный проект, судя по коду поддерживается ограниченное количество моделей и предполагается использовать оригинальные веса, без квантизации GGUF или какой бы то ни было ещё, docker-образов нет, плюс смотрю там нет автоматики и все конфигурации будет необходимо прописывать вручную.
Cпасибо за ссылочку, проект пощупаю и сравню с аналогами.
riv9231
Здорово! Т.е. можно запустить llm разделив инференс, условно, на пару хотстов с rtx3090, а оставшуюся часть по нескольким ryzen 7950X3D, например. То, что было нужно для запуска моделей размеров от 70b. Наконец можно будет с llama 3.1 405B получит не 0.5 токенов в секунду, а намного больше, запустив на всём доступном оборудтвании.
Получается бекэнды между собой не общаются во время инференса. Это, скорее всего, означает, что 10Gbe-сеть не нужна. Тогда зачем в некоторых фреймворках для распребеленного запуска упоминаются требовпния к сети?
efreelancer Автор
Думаю это нужно для того чтобы запустить инференс можно было быстрее, так как инференс выполняет только после того как все слои будут выгружены на бэкенды. Иными словами если у есть модель скажем 13B и чекпоинты которой весят кажется 9Гб и есть два бэкенда нужно залить на каждый бэкенд 4.5Гб данных.
Следовательно моя гипотезав в том, что чем быстрее сеть, тем быстрее запустится инференс.
d00m911
Модели от 70b можно запускать и на обычном ПК. Ну, как обычном... я нашёл материнскую плату, которая поддерживает работу с тремя видеокартами, у меня три разных видеокарты (7900 xtx, 4080 и 4070). Так уж вышло, что они относятся к разным архитектурам, поэтому использую Vulkan через konoldcpp. Квантованная Llama 3.1 70b работает очень хорошо и быстро.
1TheNikita
А что по цифрам? Интересно посмотреть, так как и GPU из "гражданской" серии, и сборка интересной получилась.
У самого сейчас лежит без дела 3070Ti (далеко до 4070, но всё же), надо применить её тоже, почему бы и нет
d00m911
Только что проверил:
Processing Prompt [BLAS] (84 / 84 tokens)
Generating (420 / 512 tokens)
(EOS token triggered! ID:128009)
CtxLimit:504/4096, Amt:420/512, Process:1.76s (20.9ms/T = 47.75T/s), Generate:45.81s (109.1ms/T = 9.17T/s), Total:47.57s (8.83T/s)
Конечно, если бы было три видеокарты, которые работают с CUDA, было бы быстрее, но меня и эта скорость устраивает - текст выводится гораздо быстрее, чем я успеваю его читать)
rPman
какой размер файла весов сети? llama 70b?