Приветствую, хабровчане!

Идея создания данной публикации крутилась с моей голове уже давно, дело в том, что одно из моих хобби связанно с распределёнными вычислениями, а другое хобби связанно с нейросетями и мне давно не давала покоя идея запустить инференс 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.

Если очень кратко расписать как всё это работает получается следующее:

  1. Некий RPC-клиент, скажем llama-server, в момент запуска получает через аргументы командной строки список RPC-серверов и модель;

  2. RPC-клиент считывает модель затем "нарезает" её слои таким образом, чтобы они были равномерно распределены между всеми RPC-серверами;

  3. Далее 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 можно выполнить в три простых шага.

  1. Ставим пакеты необходимые для сборки:

apt install -fyq bash wget git make g++
  1. Клонируем репозиторий к себе на хост и переходит в директорию с исходниками:

git clone https://github.com/ggerganov/llama.cpp.git
cd llama.cpp
  1. Запускаем компиляцию (в инструкции приводится пример через 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 а на втором этапе nvidia/cuda:runtime.

# Stage 1
FROM nvidia/cuda:12.5.1-devel-ubuntu22.04 AS builder
# 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-сервером.

В общем виде такая схема будет выглядеть следующим образом:

Схема из двух RPC-серверов и одного RPC-клиента
Схема из двух RPC-серверов и одного RPC-клиента

В результате у нас получится следующего вида 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:"}'

В ответе будет что-то вроде этого:

Ответ сервера llama.cpp
Ответ сервера llama.cpp

Что дальше?

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

Из любопытного на что я обратил бы ваше внимание это вот этот небольшой PR в проект ollama (который на момент публикации данной статьи ещё висел в несмердженных) и вот это обсуждение, всё там же, в тикетах проекта ollama. Если кратко, то разработчики хотят добавить возможность выполнять распределённый инференс на RPC-бэкендах по типу того, что было продемонстрирован в данной публикации. Так что в дальнейшем я планирую попробовать подружить ollama с моими Docker-контейнерами.

Ещё я планирую использовать данные контейнеры в Kubernetes, поэтому скорее всего в ближайшем будущем подготовлю k8s operator или просто deployment в формате Helm-чарта дабы упростить процедуру развёртывания серверов по нодам.

А ещё у меня на антрисолях есть немало микрокомпьютеров, а также две специальные материнские платы под названием TuringPi v1 для кластеризации RaspberryPi CM3, на них я тоже планирую проводить эксперименты в будущем и именно поэтому среди всех перечисленных архитектур контейнеров ниличествует arm/v7.

В общем плавно грамодьё, было бы время...

За сим откланиваюсь, спасибо что дочитали статью до конца, если вас заинтересовало, что в будущем будет происходить с данным проектом приглашаю ко мне на канал @evilfreelancer в Телеграме.

Ссылки

Прочее:

Комментарии (2)


  1. riv9231
    14.09.2024 17:14
    +2

    Здорово! Т.е. можно запустить llm разделив инференс, условно, на пару хотстов с rtx3090, а оставшуюся часть по нескольким ryzen 7950X3D, например. То, что было нужно для запуска моделей размеров от 70b. Наконец можно будет с llama 3.1 405B получит не 0.5 токенов в секунду, а намного больше, запустив на всём доступном оборудтвании.

    Получается бекэнды между собой не общаются во время инференса. Это, скорее всего, означает, что 10Gbe-сеть не нужна. Тогда зачем в некоторых фреймворках для распребеленного запуска упоминаются требовпния к сети?


    1. efreelancer Автор
      14.09.2024 17:14

      Тогда зачем в некоторых фреймворках для распребеленного запуска упоминаются требовпния к сети?

      Думаю это нужно для того чтобы запустить инференс можно было быстрее, так как инференс выполняет только после того как все слои будут выгружены на бэкенды. Иными словами если у есть модель скажем 13B и чекпоинты которой весят кажется 9Гб и есть два бэкенда нужно залить на каждый бэкенд 4.5Гб данных.

      Следовательно моя гипотезав в том, что чем быстрее сеть, тем быстрее запустится инференс.