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

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

# 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-сервером.

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

Схема из двух 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 в Телеграме.

Ссылки

Прочее:

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


  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
      +2

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

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

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


    1. d00m911
      14.09.2024 17:14
      +1

      Модели от 70b можно запускать и на обычном ПК. Ну, как обычном... я нашёл материнскую плату, которая поддерживает работу с тремя видеокартами, у меня три разных видеокарты (7900 xtx, 4080 и 4070). Так уж вышло, что они относятся к разным архитектурам, поэтому использую Vulkan через konoldcpp. Квантованная Llama 3.1 70b работает очень хорошо и быстро.


      1. 1TheNikita
        14.09.2024 17:14
        +1

        А что по цифрам? Интересно посмотреть, так как и GPU из "гражданской" серии, и сборка интересной получилась.

        У самого сейчас лежит без дела 3070Ti (далеко до 4070, но всё же), надо применить её тоже, почему бы и нет


        1. d00m911
          14.09.2024 17:14

          Только что проверил:

          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, было бы быстрее, но меня и эта скорость устраивает - текст выводится гораздо быстрее, чем я успеваю его читать)


          1. rPman
            14.09.2024 17:14

            какой размер файла весов сети? llama 70b?


  1. 1TheNikita
    14.09.2024 17:14

    так как сборку под ARM64 планируется выполнять на x86_64 процессоре.

    Осталось дождаться полноценной кросс-платформы (если будет, конечно) для большей производительности и в бой.

    А так выглядит классно, как раз задумывался об интеграции llama.
    Надо будет попробовать в песочнице.


  1. 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 p40


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

      Серъёзных замеров ещё не проводил, поэтому точных цифр дать не смогу, производительность замерял на следующих схемах: 1x RTX 3050, 1x RTX 4090 и пара из этих видекарт соединённых по RPC (сеть 1Гбит), вот gist с замерами.

      Это кажется странным, но в режиме RPC инференс либо чуть быстрее, либо такой же как на самой быстрой карте.

      UPD. Добавлю, что основная моя цель была не в том, чтобы ускорить инференс (хотя это было бы приятным бонусом), а в том чтобы выпонять его на кластере из маломощных микрокомпьютеров, которые по отдельности не способны на инференс больших моделей, скажем на жмене RaspberryPi CM3.


      1. Krypt
        14.09.2024 17:14

        Ну вообще по поводу быстродействия вопрос вполне себе практичный: как альтернатива, вы можете нафигать в систему ssd до сатурации pci-e по скорости и использовать прямой доступ GPU к этим самым ssd (nvidia gpu это умеют). Другой вопрос сколько это стоить будет, но "не сильно много"... А то llama3 q8 70b на CPU я тоже запустит могу, со скоростью 1.2 token/sec...


  1. rPman
    14.09.2024 17:14
    +1

    на какой машине будет выделяться память под KV-cache? так как для для 128к нужно порядка 16гб памяти только под контекст, а ведь его нужно умножать еще на батчинг, который ускоряется значительно именно в случае испоьзования нескольких нод/видеокарт?!


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

      Если я правильно понял в формате RPC схемы все низовые работы по инференсу происходят на стороне бэкенда, следовательно если мы имеем систему из нескольких серверов работа будет распределена между ними равномерно (с учётом доступной rpc-server RAM или VRAM), следовательно можно предположить, что вся работа с кешем и его хранение будет происходить на бэкенде.

      Косвенно для меня это подтверждается в этой issue на гитхабе, если в двух словах, то пользователь жалуется на сегфолты когда он отключает кеширование на стороне бэкенда.

      А вот как это всё синхронизируется мне пока что непонятно.


  1. UnknownHero
    14.09.2024 17:14
    +3

    https://github.com/evilsocket/cake
    Оставлю тут на всякий случай


    1. rPman
      14.09.2024 17:14

      жаль автор пока не допилил llama31 поддержку


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

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

      Cпасибо за ссылочку, проект пощупаю и сравню с аналогами.