У меня нет мощной видеокарты от NVidia. Соответственно нет CUDA мощи, принятой в качестве стандарта в машинном обучении (ML - Machine Learning). Но есть желание сделать что то похожее на нейронку.
На просторах интернета был найденпост о реализации простой llm на Rust. Сам проект находится на github:tekaratzas/RustGPT.
Т.к. моих знаний и опыта не хватит для самостоятельного разбора исходного кода на Rust, и переписывать на c# логику - за пределами моих возможностей, в помощь были привлечены все доступные бесплатные LLM (Qwen CLI, Gemini CLI, Kilo-code, Perplexy, и даже ИИ режим гугла в браузере).
В статьеАлиса, подвинься, я уже писал немного теории в разделе "LLM, БЯМ, Нейронка, Чат-гпт. Краткий понятийный курс":
Фактическинейронка— это массив чисел загруженный в оперативную память, которому подают на вход какую-либо информацию (текст, фото, звук), и эта информация, проходя через массив, выдает определенный результат, тоже в виде массива чисел.
Что собственно необходимо сделать что бы обучить llm? И что значит "обучить"?
Представим что модель - это конвейер, через который проходит и видоизменяется информация.
- Токенизация.Так как на вход нейронки нельзя просто передать слова (а так же изображение, звук), ей нужно передать массив чисел, которые мы "закрепим" за определенной единицей информации. Проще говоря: за словом "привет" можно застолбить число 1375, за пробелом число 333, а за словом "мир" - 777. Многократно передав эти числа (токены) в нейронку - мы обучим её так, что бы она могла запомнить эту последовательность: 1375, 333, 777 (то есть словосочетание "привет мир"). Так вот, токенизация - это разбитие информации на части. Можно разбить предложения по словам ("привет"), можно по символам ("п|р|и|в|е|т"), а можно выбрать золотую середину ("прив|ет|").Кроме слов, в предложениях существуют и знаки препинания, управляющие "потоком мысли" предложения. Они тоже - отдельные токены.
- Embeddingслой имеет связь междутокеномего числовым описанием (вектором) хранящемся в весах. Изначально до обучения, в весах - полный хаос.
- TransformerBlock- это блок, который при получении последовательностивекторов, преобразует эти последовательности в связи, и передает их следующему блоку (так же в виде последовательности векторов). Фактически он получает вектора, делает их "умнее", и передает дальше.Упрощенно он состоит из следующих частей:Self-attention- механизм "внимания" связывающий слова (т.е. векторы) между собой, через четыреLinearслоя:Query("запрос") - веса, которые формулируют вопрос от текущего слова к остальнымKey("ключ") - веса, которые описывают характеристики слова для другихValue("значение") - сама суть, содержательная информация словаOutput("проекция") - веса, которые собирают результаты всех "голов" внимания (если их несколько) обратно в один вектор.QueryиKeyрешают,кто с кемсвязан.Valueдает ответ на вопросчто именнопередать по этой связи.Outputупаковывает результат для передачи дальше.FeedForward- небольшая "сеть", концентрирующая информацию полученную отSelf-attention, для следующего слоя. Здесь хранятся "знания" и "факты". Состоит из двухLinearслоев.Layer normalization- "выравнивание" чисел после обучения. Содержит небольшие веса (обычноgammaиbeta) помогающие модели удерживать числа в рабочем диапазоне.Residual connection- делает "поправку" с тем что было на входе и с тем что выходит из предыдущих частей: складывает числа на входе и на выходе, что бы не потерять исходную информацию.
- Output projection- последнийLinearслой, переводящий внутреннее состояние модели в вероятность следующего слова. Содержит веса решающие, какое именно слово из 50 000 вариантов станет следующим.
То есть главная задача данного конвейера (нейронки/модели) - предсказать какое слово будет дальше, если ему на вход подать начальное слово. Да, этакийT9, но имеющий контекст и понимающий смысл текста.
Как происходит обучение:
- Pre-processing: преобразуем подготовленные данные (dataset) в токены.
- Разделение обучающих данных на "вход" и "выход" для обучения предсказанию. Например фраза "Мама мыла раму" (токены: 111, 555, 777). Вход: "Мама мыла" (токены: 111, 555), выход: "мыла раму" (токены: 555, 777).
- Forward Pass: передача конвейеру "вход" ("Мама мыла", токены: 111, 555). Данные бегут через все слои (Embedding -> Attention -> FeedForward -> Layer normalization -> Residual connection). На выходе получаемLogits- сырые оценки вероятности для каждого слова из словаря.
- Оценка ошибкиLoss: сравнивается вероятностьLogitsс "выходом", и чем меньше вероятность с "выходом" тем больше числоloss. На основе loss мы вычисляемградиент (Backpropagation)- степень виновности каждого веса в ошибке.Последний слой (FeedForward) спрашивает: "На сколько мои весаw2 виноваты в этой ошибке?". Ответ записывается вградиентдляw2.Затем этот слой передает сигнал ошибки назад предыдущему слою (SelfAttention).SelfAttentionделает то же самое: вычисляет, как его матрицыwQ,wK, wVповлияли на итоговый провал.
- ClipGradients: еслиградиент("виновность") слишком велик, модель может "психануть" и слишком сильно изменить веса, всё испортив. Поэтому прежде чем обновить веса, мы делаем "обрезку".
- Optimizerобновляет веса во всех слоях (вEmbeddings,SelfAttention, и вFeedForward)на основе градиента:Если градиентположительный: Значит, увеличение веса увеличивает ошибку. Мы вычитаем его, чтобы уменьшить вес.Если градиентотрицательный: Значит, увеличение веса уменьшит ошибку. Минус на минус дает плюс — мы увеличиваем вес.Если градиент равен0: Мы в идеальной точке (или в тупике), менять ничего не нужно.У оптимизатора есть некий рычаг управленияLearning Rate (LR)- коэффициент скорости обучения. Если градиент показывает направление (увеличивать или уменьшать вес), тоLR- силу с которой надо это делать. ВысокийLR- модель делает большие шаги в обучении, но может проскочить идеальную "обученность" и Loss будет прыгать, так и не обучив модель до конца. НизкийLR- модель делает мелкие шаги в обучении, медленно учится, и может застрять на определенном уровне, так как ей не хватит преодолеть "локальный минимум"Loss.
Первая версия
Бесплатный ИИ сделал перенос кода на c# сразу с использованием библиотекиMathNet.
Данные для обучения:
Pretrain- общие сведения о мире: "солнышко блестит, дождик капает".Tune- псевдо обучение диалогу. Но на такой маленькой модели, после дообучения (Tune), "знания" натренированные с помощьюPretrain- "перезатираются".
Уже тут видно, как нейронка - просто дописывает за пользователем предложения, на которых обучена. Т9 во всей красе.
Но какой потенциал! Это уже хоть какая-то говорилка! Самая маленькая текстовая нейросеть!
ПараметрmaxSeqLen(Maximum Sequence Length)- это тот самый размер контекста в токенах! Этомаксимально допустимая длина текста(количество токенов), которую модель может обработать за один раз. В программе параметр вычисляется автоматически, но никто не мешает установить его намного больше вручную.
Ничто не мешает нам обучить модель на русском языке. Нужно только увеличить количество эпох так, что бы значение было примерно или ниже:Loss = 0,1850.
В данном случае я передаю модели строку"User: " + текст набранный в консоли.
Модель запомнила строку:"User: Как изготавливается стекло? Assistant: Стекло изготавливается путем нагревания песка, кальцинированной соды и известняка до очень высоких температур, пока они не расплавятся "
Передаю модели строку:"User: Как изготавливается стекло?Модель выдаёт продолжение заученной строки:"Assistant : Стекло изготавливается путем нагревания песка , кальцинированной соды и известняка до очень высоких температур , пока они не расплавятся"
Заметили что перед запятыми и после них - пробелы? Модель выдает не точную копию исходного текста, а последовательность токенов, преобразованную обратно в слова.
Модель выдает токены (для нагладности добавил разделитель):293|2|254|104|216|154|182|1|118|246|101|103|78|176|53|261|1|200|173|155|219|5
А потом эти токены преобразовываются в слова:Assistant|:|Стекло|изготавливается|путем|нагревания|песка|,|кальцинированной|соды|и|известняка|до|очень|высоких|температур|,|пока|они|не|расплавятся|
В коде после каждого токена добавляется пробел.
Приинференсе, если дать модели слова которых она не знает, она будет выдавать мусор. Когда модель выдает нам готовый результат, мы можем вычислить "уверенность" с которой модель выдает каждый токен. И на основе этого определять: правильные ли токены она нам выдает, или нет.
Модель можно сохранить и загрузить из .bin файла. Размер файла готовой модели llm_model.bin -6,87 Мб(!).
Откапываем OpenCL
Обучать микро модель на центральном процессоре еще можно, но если хочется сделать более осмысленную модель, с бОльшими весами, вмещающую больше информации - терпения не хватит. Это долго, это шум кулера, это ужасно если долго ждал - а результата ноль и нужно переучивать заново.
Нейронка - это матрицы. Умножать, делить, складывать ...матрицы это тяжело для CPU. Нужна мощь видеокарты. Видеокарты заточены на операции с матрицами.
У меня нет видеокарты NVidia с CUDA процессорами на борту. У меня встройка от AMD. Да, у нее есть NPU модуль, но не у всех он есть. За то есть OpenCL.
OpenCL- этогетерогеннаятехнология операций с матрицами. То, что CPU делает в один поток, OpenCL делает тысячами потоков параллельно.
OpenCL работает с2009года на GeForce 8000 (8800 GT), Radeon HD 4000, Intel HD 4000.
Главная особенность OpenCL - расчеты ведутся вkernel. Кернел - это блок кода выполняющийся параллельно на нескольких ядрах. Примерно как шейдер для видеокарты, но не для графики, а для вычислений. Код пишется на языкеOpenCL C(похож на C99).
...А ведь появлениеCUDAобязано пиксельным шейдерам для этих ваших игрулек. В 2003 студенты Стендфорда разработали проектBrookGPU, позволяющий производить расчёты сложных физических симулиций и прочего через шейдеры. Этот метод получил названиеGPGPU- General-Purpose computing on GPU, то есть "универсальные вычисления на графическом процессоре". Затем NVIDIA вдохновленная этим проектом наняла создателя BrookGPUЯна Бака, и придумала CUDA.OpenCLпоявился в 2008 году уже как ответка всей остальной индустрии на монополию CUDA. Придумала его Apple, и пошло поехало...
На просторах интернета есть проектhttps://ilgpu.net.ILGPUэто JIT-компилятор с открытым исходным кодом, который позволяет писать код для GPU (CUDA и OpenCL) прямо на обычномC#.
Есть проект на github:https://github.com/m4rs-mt/ILGPU.
Вот пример перемножения матриц на CPU (одна из рутинных операций при обучении нейронки):
А теперь сравним сGPUOpenCL. Каждая матрица размерностью 1024 на 1024.
Неплохо. Первый вариант нейронки уже работает на стероидах - библиотекеMath.NETиспользующей некислый хак от производителя процессора: инструкции SIMD (AVX, AVX2,AVX-512,SSE2, FMA3).
Но это всё равно хуже OpenCL.
Как обычно, реальность немного приземляет. Последняя версияILGPU 1.5.3поддерживает устройства сOpenCL 2.0и выше. Проверил на NVIDIA GeForce GTX 560 - библиотека ILGPU не хочет работать с видеокартой, т.к. старая видеокарта поддерживает только OpenCL 1.1.Можно попробовать задействовать более старые версии библиотеки... Можно, а зачем?
Вторая версия. OpenCL
На этот раз я попросил ИИ перевести вычисления на OpenCL, т.е. задействовать библиотеку ILGPU для работы с матрицами.
Хитрый ИИ сделал так, что программа перегоняла данные в GPU, возвращала обратно, а бОльшую часть вычислениий производила на CPU. В диспетчере задач, во вкладке GPU, на графике "Copy" была большая активность. А в графике "Compute" - почти не заметная активность. В коде обилие методов "CopyFromCPU", "CopyToCPU".
После многократных исправлений нагрузка перешла в график "Compute".
Теперь при вычислениях CPU не нагружается вообще, вентилятор не шумит!
Mixture of Experts (MoE)
Поигравшись в рабочую версию, решил добавить блок MoE.
Если в "обычном" Transformer блоке структура такая:Layer Norm->Self-Attention (+ Residual)->Layer Norm->FeedForward (+ Residual).
То в Transformer MoE (как в Mixtral):Layer Norm->Self-Attention (+ Residual)->Layer Norm->MoE (Router->FeedForward1,FeedForward2...) (+ Residual).
Router- это легкийLinearслой, который анализирует вектор и "решает", каким именноFeedForwardслоям (экспертам) его передать. Грубо говоряRouter- это "диспетчер" выдающий вероятность выбора того или иного "эксперта". АFeedForward- эксперт.
Особых различий в обучении не заметил. Но это и понятно: корпус (текст) для обучения очень маленький.
А почему бы собственно не экспортировать модель в формат gguf что бы любой софт использующий llama.cpp (Ollama, LM Studio и прочие) могли загрузить нашу модель?
Оказалось что не всё так просто. С наскока такое не провернуть. Конечно я пробовал экспортировать модель с такой конфигурацией:Embedding->TransformerBlockMultiHead->Linear. Но, llama.cpp выдавала ошибки типа:llama_model_load: error loading model: done_getting_tensors: wrong number of tensors; expected 37, got 33
Необходимо создать такую конфигурацию модели, какую понимает llama.cpp. Бесплатный ИИ предложил сделать архитектуруGPT-2:Embedding->PositionalEmbedding->TransformerBlockPreNorm->LayerNorm->Linear.
Токенизатор
Для полной совместимости с llama.cpp нужно было создатьGPT-2 Byte-Level BPE (Byte Pair Encoding)токенизатор. Главная фишка такого токенизатора в том, что для него нет неизвестных символов, он может работать совершенно с любыми словами и символьными последовательностями. В старых токенизаторах (например BERT) если встречался неизвестный символ, модель выдавала[UNK](Unknown). А BPE работает с базовым словарем на 256 символов и может их комбинировать как угодно. Это гарантирует, что любой файл или символ в любой кодировке может быть прочитан моделью. Он объединяет пары символов которые встречаются часто вместе - в один токен. Т.е. несколько часто встречающихся вместе токенов "п" "р" "и" "в" "е" "т" при анализе текста, объединится в один токен "привет". Фактически это означает что появится новый вектор (число) за которым будет закреплено слово "привет".
Encode- кодирование сырого текста в список токенов предоставленных в виде векторов (чисел).
Decode- преобразование токенов в текст.
Обучение токенизатора происходит в два этапа:
- Разрезание текста на куски (pre-tokenization) по определенному регулярному выражению (Regex). Такое разрезание гарантирует, что буквы, цифры и знаки препинания не будут объединяться в один токен. Например "привет!" будет разбито на "привет" и "!".
- Ну и собственно слияние (merges). Токенизатор ищет самую частую пару соседних байтов/токенов внутри нарезанных кусков текста и объединяет их в новый токен. Слияние происходит не сразу. В первый проход из "п" "р" "и" "в" "е" "т", произойдет слияние "пр" "и" "в" "е" "т", затем "при" "в" "е" "т" и т.д. А может и так: "при" "вет". Все зависит от корпуса.
У llama.cpp есть утилита показывающая как она интерпретирует запросы в своем токенизаторе. Команда:lama-tokenize-m model.gguf -p " привет!" --idsтак же выдает[2, 4, 448, 7, 5]
Корпус- это собственно текст на котором учится модель. Корпус можно взять готовый, например здесь:https://huggingface.co/datasets/d0rj/alpaca-cleaned-ru. А можно попросить любую крупную LLM сгенерировать вам свой корпус. Так можно получить одну из разновидностейDistilled(дистилляция) модели. Т.е. малая модель обучается на более концентрированных и чистых корпусах от большой умной модели.
В нашем случае, корпус нужно правильно оформить для обучения. Например строка:
"User:приветAssistant:привет! рада тебя видеть. чем могу помочь?"
для обучения преобразуется в такой вид:
"приветпривет! рада тебя видеть. чем могу помочь?".
- стартовый токен (Bos- Beginning of Sequence), позволяющий модели понять, что начинается новая генерация. Для большинства моделей этот токен позволяет сбросить контекст и настроить механизмы внимания (Attention) для первого слова.
- конечный токен (Eos- End of Sequence). Модель может генерировать текст бесконечно. Если она запомнит этот токен при обучении, то программа которая будет делать инференс этой модели (llama.cpp) при получении этого заученного токена тут же завершит генерацию следующих токенов. Этот токен нужен больше для инференса.
В таком случае инференс преобразует вашу строку:
"привет"
И модель получив незаконченную строку начинает генерировать недостающие последовательности токенов которые должны стоять друг за другом с высокой вероятностью:
" привет! рада тебя видеть. чем могу помочь?ёлки палки какой-то мусор который не пропустит llama.cpp после eos токена й3зшо2хщ0шо2хй3щ"
Еще был такой токен(Separator) - для разделения строки на вопрос и ответ. Но у нас для этого естьи.
Для llama.cpp очень важен шаблон по которому она будет преобразовывать ваши запросы к модели в правильные токены, и преобразовывать результат модели в текст.
Вот такой шаблон получился для нашей модели с нашим особенным корпусом:
Почему особенный корпус? Потому что наши Bos, Eos, User, Assistant токены нестандартные. Например в Llama3 Bos токен - это<|begin_of_text|>, в ChatML (Qwen, DeepSeek):<|im_start|>.
Такой шаблон будет генерировать растущий запрос к модели с каждым запросом пользователя. Вы напишете "привет", он отравит модели
"привет"
Затем напишете "как дела?", он отправит:
"приветпривет! рада тебя видеть. чем могу помочь? как дела?"
Но на самом деле llama.cpp держит предыдущий ответ вKV-кеше, и не пересылает всю эту огромную строку текста заново. При вводе нового запроса, llama.cpp дописывает его в свободные ячейки памяти кеша. Если контекст у модели маленький, llama.cpp начнет "выбрасывать" начало истории из кеша и инференс ломается.
Только вот моделька у нас не то что бы микро размеров, она ну очень маленькая, и не может переварить историю запросов. Ей нужно при каждом инференсе сбрасывать накопленные данные. Для этого мы схитрим, сделав такой шаблон:
Тут надо озвучить одну особенность. Данный шаблон заработает если мы в метаданных экспорта в GGUF укажем такое (установимadd_bos_token = true):
Если установитьadd_bos_token = false, тогда нам надо добавить Bos токенв шаблон:
Метаданные GGUF
Метаданные это фактически инструкция для llama.cpp как загружать модель в память, и как с ней работать.
general.architecture- gpt2. Если указали эту архитектуру, то для архитектуры добавляются специфичные поля:gpt2.context_length - длина контекстаgpt2.embedding_length - размерEmbeddingслояgpt2.feed_forward_length - размер скрытого слояgpt2.attention.head_count - количество "голов"gpt2.block_count - количество слоёви прочее.
general.file_type- точность, степень квантования: 32 бита - полная точность, F16, Q4_0 и т.д.
Токенизатор:tokenizer.ggml.tokens- массив строк, включая , tokenizer.ggml.merges- правила склейкиtokenizer.ggml.bos_token_id- наши знакомыеbostokenizer.ggml.eos_token_id- иeos
Ну и конечно же шаблон:tokenizer.chat_template.
Запускаем в LM Studio
Итак. Прежде чем сделать инференс, нужно модель обучить. Давайте посмотрим на конфигурацию нашей модели:
- EmbeddingDim = 64 (размерEmbeddingслоя для связи междутокеномего числовым описанием (вектором))
- HiddenDim = 128 (размер скрытого слоя)
- NumHeads = 2 (количество "голов")
- NumLayers = 1 (количество слоёв, т.е. трансформераTransformerBlockPreNorm)
- VocabSize = 512 (размер словаря соответствий Токен <-> ID)
- MaxSeqLen = 80 (длина контекста... который может переварить модель за один раз)
Итого параметров у модели: 103 744. Это где то примерно0,000103744 миллиардов параметров...А размерmodel.ggufфайла422 Кб.
Особенности для вашей конфигурации:
- Плотность информации: ПриEmbeddingDim = 64каждый токен описывается очень коротким вектором. Это значит, что модель сможет различать только самые базовые понятия. Сложные связи (ирония, глубокий контекст) ей будут недоступны.
- Головы внимания:NumHeads = 2означает, что на каждую «голову» приходится всего по 32 измерения (64 / 2). Это необходимый минимум, чтобы модель могла одновременно следить, например, за подлежащим и сказуемым.
- Критичность параметров: ПриNumLayers = 1у модели всего один шанс «понять» смысл текста. В глубоких моделях (Llama, GPT-4) десятки слоев уточняют смысл, а здесь модель работает как очень продвинутый Т9.
Обучение модели:
- Pretrainоколо 100 эпох с LR (learning rate) = 0.001
- Tune(обучение диалогам) около 100 эпох с LR=0.0005 (loss упадет с 8,4 до 0,3), затем еще несколько раз с меньшим LR но больше эпох, пока loss не достигнет 0.12. Это самое долгое.
Да, не густо, loss=0,3926. Но даже loss=0.12 для llama.cpp это так себе результат. При загрузке модели через llama-cli с флагом –verbose, нас ругают за очень плохое обучение:
Но всё же в llama.cpp работает
Находим где хранятся наши модели в LM Studio
Создаем примерно такой путь: D:\lmstudio\models\test\virex. Сохраняем туда gguf и готово.
Самым сложным было экспортировать в gguf так, что бы инференс не поломался. Изначально я пытался сделатьllamaмодель, с такой конфигурацией:EmbeddingLayer->Transformer Blocks (RMSNorm + RoPE + SiLU gated FFN)->RmsNormLayer->LinearLayer, но как-то сильно застрял в отладке. Начал всё заново. Попросил бесплатный ИИ написать новый токенизатор основываясь на исходниках мелкософта:https://github.com/dotnet/machinelearning/blob/main/src/Microsoft.ML.Tokenizers/Model/BPETokenizer.cs. И оно заработало. Не сразу, не с десятого и не с двадцатого раза, но таки удалось добиться рабочей версии.
Осталась одна проблема: инференс в llama.cpp ломается если есть вопросительный или восклицательный знак в пользовательском запросе.
Для тех кто хочет проверить сразу:Исходный код:https://github.com/virex-84/LLMGPT2Релиз (отдельно model.gguf):https://github.com/virex-84/LLMGPT2/releases/tag/v1
А на сегодня всё...