Однажды я решил изучить язык Swift и разработать свое первое приложение для iOS. Для этого я решил создать реальный проект, который заключался в оптимизации нашумевшего LLaMA.cpp под iOS. Я поставил перед собой задачу обеспечить запуск 3B и 7B моделей на iPhone 12 Pro с приемлемой скоростью. Под «приемлемой» скоростью я имею в виду такую, чтобы пользователь не успевал заскучать, читая предсказанный текст, пока генерируется новая часть. Что из этого получилось (и какие трудности были при реализации) читайте в статье.

Немного предистории. Мысль портировать LLaMA.cpp на iOS у меня появилась после того, как вышло приложение Draw Things, про его разработку писали на Хабре. Мне понравилось то, что я могу без интернета (в поезде, например), заняться «генерацией всякого» с помощью нейросетей, без подписок, цензуры и неконтролируемых обновлений. Я подумал: если Stable Diffusion запустили на iOS, то что мешает запустить языковые модели? Так появилась идея создать приложение LLM Farm.

О проекте LLaMA.cpp

LLaMA.cpp — проект c открытым исходным кодом, который предназначен для выполнения LLM inference (я так и не решил как лучше перевести это слово поэтому оставлю как есть, если кто знает пожалуйста подскажите в комментариях) моделей семейств LLaMA (на данный момент уже и Falcon и Starcoder) с высокой скоростью, поддержкой различных аппаратных ускорений, на различных платформах. Он основан на развивающейся библиотеке для машинного обучения GGML, разработанной тем же автором. В настоящее время GGML используется во множестве других проектов, связанных с машинным обучением, таких как rwkv.cpp, stable‑diffusion.cpp, minigpt4.cpp и другие.

Проблема

Основная проблема LLM inference на iOS устройствах это ограничение по оперативной памяти. На мобильных устройствах даже дополнительные 500МБ оперативной памяти, занимаемых моделью, являются существенными. Вторая проблема это скорость вычислений, к счастью в последних версиях iOS и MacOS Apple сильно улучшила Metal.

Решение

Один из способов уменьшить объем памяти, занимаемой моделью, это квантование. Квантование модели — это оптимизации при которых веса модели преобразуются из представления с плавающей точкой в представление с более низкой точностью. Например, с использованием 8 битных целых чисел вместо 32 или 16 битных с плавающей точкой.

Библиотека GGML, на которой основан LLaMA.cpp, в настоящее время поддерживает квантование от 8 до 2 бит. Методы квантования в GGML были несколько раз изменены, и на данный момент самыми актуальными являются методы, реализованные в библиотеке k_quants.c. Для обозначения использования этих методов квантования используется приставка «k», например q4_km. Подробнее о k_quants можно прочитать тут.

В репозитории LLaMA.cpp есть таблицы с результатами тестов скоростью работы и соотношением размера модели к увеличению уровня неточности: (неполноты или простым языком бреда, так я перевел perplexity)

Еще таблицы
Измерения выполнены на тестовом наборе данных wikitext2 с длиной контекста 512. Время на один токен измерено на MacBook M1 Pro с 32 ГБ ОЗУ с использованием 4 и 8 потоков.
Измерения выполнены на тестовом наборе данных wikitext2 с длиной контекста 512. Время на один токен измерено на MacBook M1 Pro с 32 ГБ ОЗУ с использованием 4 и 8 потоков.

Из таблицы видно, что при использовании 8-битного квантования точность модели практически не ухудшается, при этом размер модели в памяти сокращается в 4 раза по сравнению с точностью F32. В настоящее время модель занимает в оперативной памяти примерно столько же места, сколько и на диске. Самым оптимальным соотношением между размером оперативной памяти и потерей качества является 4-битное квантование q4_k_m. Однако для запуска моделей 7B на iPhone с приемлемой скоростью я рекомендую использовать q3_km или q4_ks, поскольку при таком квантовании модели, хватает Shared RAM чтобы использовать Metal на iPhone, что значительно повышает производительность.

Так же для уменьшения использования оперативной памяти в LLaMA.cpp используется механизм mmap файлов. Этот механизм позволяет загружать в память только необходимые части модели по мере их потребности, вместо загрузки всей модели целиком. Однако, если размер модели превышает доступную память в системе, использование mmap может привести к выгрузке страниц памяти, что негативно сказывается на производительности. Так же для запуска моделей больше 3B, без включенного mmap, приложение обязательно собирать с increasedmemorylimit entitlements, иначе оно аварийно завершится. Вот пример использования памяти моделью LLaMA-2–7B q3_k:

mmap включен
mmap включен
mmap выключен
mmap выключен

Следует уточнить, на скриншоте показан фактический объем занимаемой RAM, это не означает что LLaMA 2 можно запустить на устройстве с 128 МБ RAM, так как объем выделенной под mmap файл памяти значительно больше.

По итогу квантование модели, в сочетании с mmap, позволило запускать 3B и 7B модели, даже на iPhone, например можно запустить модель Saiga2 и поговорить с ней на русском языке. К сожалению, в случае с Saiga2 наблюдается значительное снижение точности при квантовании, что приводит к тому, что модель 7B q3_k проявляет себя несколько глуповато по сравнению с q8_0 (или я использую не правильный шаблон для промпта).

В GGML реализованы вычисления для различных видов аппаратного ускорения, таких как CUDA, OpenCL и Metal, который нам необходим. Для того чтобы Metal работал на iOS, я внес некоторые косметические изменения в код ggml‑metal.m, которые впоследствии были включены в официальный репозиторий. Благодаря Metal производительность 3B q4_k и 7B q3_k моделей на iPhone 12 pro получилась очень неплохой. Пример есть в видео ниже.

Запускаем все на iOS

Библиотека GGML написана на языке C, LLaMA.cpp — на C++, ggml‑metal — на Objective C, а приложение, которое использует все эти компоненты, написано на Swift. Для того, чтобы обеспечить их совместную работу, создан пакет llmfarm_core.swift, состоящий из двух библиотек.

Первая — llmfarm_core_cpp — содержит код GGML, LLaMA.cpp и других inference, а также вспомогательные функции на языках C, C++ и Objective C.

Вторая — llmfarm_core — написана на языке Swift и использует функции из llmfarm_core_cpp, а также содержит классы для взаимодействия пользовательского интерфейса с LLM.

В настоящее время исходный код из репозитория LLaMA.cpp компилируется без проблем, раньше требовало некоторых доработок. Кроме LLaMA.cpp, на основе GGML также реализованы другие inference, такие как gptneox, rwkv и др. большинство из которых можно найти в описании репозитория GGML. Однако поддержка аппаратного ускорения реализуется отдельно для каждого типа inference, и на данный момент Metal поддерживается только в LLaMA.cpp.

Для расширения возможностей добавления других inference, создана иерархия классов на языке Swift, наследующих методы от базового класса LLMBase. В результате получилась следующая структура приложения:

На момент написания статьи LLMFarm поддерживает следующие inference:

Для выполнения сэмплирования в LLMFarm используются методы из LLaMA.cpp. Эти методы использованы не только для llama, но и для других inference, так как входные и выходные данные сэмплирования одинаковые. В настоящее время в LLMFarm поддерживаются следующие методы сэмплирования:

На данный момент Metal ускорение реализовано только в проекте LLaMA.cpp, поэтому поддерживаются только llama, falcon и starcoder. LLMFarm_core был вынесен в отдельный репозиторий и теперь библиотеку можно подключить через SPM. В приложении имеются шаблоны настроек, которые позволяют быстро конфигурировать параметры чата, чтобы каждый раз не приходилось заново настраивать параметры сэмплирования и т. д. Так же приложение поддерживает задание формата prompt запросов на подобии:

[INST] <<SYS>>
You are a helpful, respectful and honest assistant. Always answer as helpfully as possible.  If you don't know the answer to a question, please don't share false information.
<</SYS>>
{{prompt}}[/INST]

Сложности возникшие при разработке

Версии GGML

Библиотека GGML развивается очень динамично, но ее версии несовместимы друг с другом. Некоторые inference, такие как gpt-2, gptneox, replit, реализованы для более старых версий, в то время как в rwkv используется форк библиотеки, а в LLaMA.cpp используется самая свежая версия, которая несовместима с предыдущими. Так как названия функций в разных версиях библиотеки одинаковые, линковщик отказывается собирать разные версии вместе. Я решил эту проблему путем быстрой замены названий функций в библиотеке, однако мне кажется, должно быть более «элегантное» решение, например, через директивы препроцессора или линковщика. (Я не специалист по C++, а на Swift пишу впервые, поэтому многие вещи, которые кажутся очевидными для других, для меня были новыми. Если кто‑то знает более изящное решение, я буду признателен, если поделитесь им в комментариях.)

быстрая замена названий функций в старых версиях ggml
быстрая замена названий функций в старых версиях ggml

Исключения

GGML написана на языке С, где нет исключений. В случае, когда указатель ссылается на null или функция выделения памяти завершилась неудачей, вызывается GGML_ASSERT, код которого выглядит следующим образом:

#define GGML_ASSERT(x) \
   do { \
       if (!(x)) { \
           fprintf(stderr, "GGML_ASSERT: %s:%d: %s\n", __FILE__, __LINE__, #x); \
           abort(); \
       } \
   } while (0)

Для отслеживания таких ситуаций, я использовал возможность комбинировать код на C и C++, а так же Objective‑C и C++ и затем отлавливать исключения с помощью конструкции try‑catch в Objective‑C. Теперь, вместо неконтролируемого завершения приложения, код генерирует исключение.

Код на C++
// ggml.h
#include "exception_helper.h"
#define GGML_ASSERT(x) \
    do { \
        if (!(x)) { \
            fprintf(stderr, "GGML_ASSERT: %s:%d: %s\n", __FILE__, __LINE__, #x); \
            char descr[500]; \
            sprintf(descr, "GGML_ASSERT: %s:%d: %s\n", __FILE__, __LINE__, #x);\
            throw_exception(descr); \
        } \
    } while (0)

//  exception_helper.h
#ifdef __cplusplus
#define EXTERNC extern "C"
#else
#define EXTERNC
#endif
EXTERNC void throw_exception(const char* description);
#undef EXTERNC

//  exception_helper.cpp
#include "exception_helper.h"
#include <stdexcept>

void throw_exception(const char* description){
    throw std::invalid_argument(description);
}

Код на Objective-C
//  exception_helper_objc.mm

#import <Foundation/Foundation.h>

#import "exception_helper_objc.h"
#include <exception>

@implementation ExceptionCather
+ (BOOL)catchException:(noEscape void(^)(void))tryBlock error:(__autoreleasing NSError **)error {
    try {
        tryBlock();
        return YES;
    }
    catch(NSException* e) {
        NSLog(@"%@", e.reason);
        *error = [[NSError alloc] initWithDomain:e.name code:-1 userInfo:e.userInfo];
        return NO;
    }
    catch (std::exception& e) {
        NSString* what = [NSString stringWithUTF8String: e.what()];
        NSDictionary* userInfo = @{NSLocalizedDescriptionKey : what};
        *error = [[NSError alloc] initWithDomain:@"cpp_exception" code:-2 userInfo:userInfo];
        return NO;
    }
    catch(...) {
        NSDictionary* userInfo = @{NSLocalizedDescriptionKey:@"Other C++ exception"};
        *error = [[NSError alloc] initWithDomain:@"cpp_exception" code:-3 userInfo:userInfo];
        return NO;
    }
}
@end

Чтобы ловить вышеупомянутые исключения в Swift нужно можно использовать такую конструкцию:

do{
    try ExceptionCather.catchException {
        self.model = llama_load_model_from_file(path, params)
    }
}catch {
    print(error)
    throw error
}

Таким образом, в три этапа, происходит «проброс» ошибок уровня С до исключений уровня Swift.

В настоящее время приложение все еще нуждается в доработке, поэтому оно доступно только в TestFlight. Чтобы начать пользоваться приложением, вам понадобится подходящая модель (некоторые ссылки на модели доступны в моем репозитории). Затем в LLMFarm нужно создать новый чат, указав путь к модели и выбрав соответствующий шаблон.

Формат моделей

В библиотеке GGML предусмотрен свой формат файлов который со временем несколько раз менялся. Формат менялся по разным причинам, например из за смены алгоритма квантования или для лучшей поддержки mmap. К сожалению при переходе на новый формат терялась совместимость с предыдущими и модели приходилось квантовать заново. На момент написания статьи самая актуальная версия GGUF v2, однако множество моделей на сайте huggingface по прежнему остаются в формате GGJT v3, но их легко можно переконвертировать в новый формат с помощью скрипта. Формат GGJT v3 не предусматривал хранение метаданных и только зная какая модель внутри можно было понять что там за inference и корректно прочитать заголовок и данные. GGUF — универсальный формат, содержащий в себе множество метаданных ключ значение позволяющих однозначно определить какая модель находится внутри, какие у нее свойства, а так же без потери совместимости совершенствовать формат.

Интерфейс

Приложение имеет интерфейс, напоминающий мессенджер. В качестве контактов используются модели. С каждой моделью можно создать неограниченное количество чатов с различными настройками. В будущем планирую добавить функцию группового чата, в котором пользователь сможет общаться с несколькими моделями одновременно. Интерфейс приложения разработан на SwiftUI, что позволяет его использовать как на iOS (версия 16+), так и на MacOS (версия 13+). Поддержка более старых версий операционных систем не предусмотрена в связи с использованием функций Metal 3, доступных только в более новых версиях, а так же из за несовместимости некоторых компонентов SwiftUI.

Зачем все это если есть CoreML?

На момент написания статьи, единственный пример с использованием LLaMA 2 на CoreML, который я смог найти, работает только на Mac и требует значительно больше ресурсов, чем GGML.

Что по поводу Fine Tune?

LLaMA.cpp поддерживате подключение LoRA адаптеров без использования mmap. Недавно в этом форке появился пример finetune, который был успешно включен в основную ветку после этого релиза. В скором времени я планирую добавить в LLMFarm возможность подключать LoRA.

Послесловие

В первой версии LLMFarm скорость работы модели 7B на iPhone 12 Pro составляла около 1 токена в секунду, поэтому использование ее было возможно только в академических целях. Однако сейчас, благодаря изменениям в LLaMA.cpp, которые произошли за время разработки, 7B модель с использованием Metal генерирует текст со скоростью моего чтения и быстрее. Проект постоянно развивается, и скоро в нем появится поддержка GPT-2 (Cerebras) с Metal и mmap. Возможно, мы сможем запускать модель ruGPT3.5 от Сбербанка даже на iPad Pro (уже сейчас она успешно работает в LLMFarm для Mac).

На сегодняшний день LLaMA.cpp является эталоном по соотношения количества ресурсов к производительности (вроде только GPTQ на NVidia в чем то выигрывает, если я не прав поправьте пожалуйста) . Я думаю, что в скором времени поддержка ускоренных вычислений появится и для других inference. Недавно, например, появился репозиторий stable‑diffusion‑cpp, основанный на GGML. К сожалению, в нем пока нет поддержки GPU, но я уверен, что она будет реализована со временем.

Я рад тому, что существует вектор развития локального ИИ. Конечно, монструозный ChatGPT всегда будет опережать легкие модели на ПК и тем более на мобильных устройствах. Однако у многих разработчиков теперь есть простор для фантазии. Простое «поболтать» с компьютером уже уже наскучило людям, они стремятся использовать искусственный интеллект для решения реальных задач. Чем больше разработчиков будет вовлечено в этот процесс, тем больше неожиданных решений появится в области использования LLM и других нейронных сетей.

Примеры работы:

ORCA 3B на iPhone 12 Pro Max сочиняет песню про Хабр.

Ссылка на GitHub
Ссылка на TestFlight

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


  1. PLG
    11.10.2023 14:26
    +1

    inference (я так и не решил как лучше перевести это слово

    Часто используют либо англицизм "инференс", либо "генерация".


    1. guinmoon Автор
      11.10.2023 14:26
      +1

      О! Точно, "генерация", хоть это и не перевод но по смыслу подходит, спасибо.