大模型推理框架llama.cpp開發流程和常用函式介紹

冷豪發表於2024-10-05

llama.cpp是一個高效能的CPU/GPU大語言模型推理框架,適用於消費級裝置或邊緣裝置。開發者可以透過工具將各類開源大語言模型轉換並量化成gguf格式的檔案,然後透過llama.cpp實現本地推理。經過我的調研,相比較其它大模型落地方案,中小型研發企業使用llama.cpp可能是唯一的產品落地方案。關鍵詞:“中小型研發企業”,“產品落地方案”。

中小型研發企業:相較動輒千萬+的硬體投入,中小型研發企業只能支撐少量硬體投入,並且也缺少專業的研發人員。

產品落地方案:專案需要具備在垂直領域落地的能力,大多數情況下還需要私有化部署。

網上有不少介紹的文章,B站上甚至有一些收費課程。但是版本落後較多,基本已經沒有參考價值。本文采用b3669版本,釋出日期是2024年9月,參考程式碼:examples/main.cpp。由於作者(Georgi Gerganov)沒有提供詳細的介面文件,examples的程式碼質量也確實不高,因此學習曲線比較陡峭。本文旨在介紹如何使用llama.cpp進行推理和介紹重點函式,幫助開發人員入門,深入功能還有待研究。

一、推理流程

1. 過程描述

以常見的互動推理為例,程式大概可以分成5個子功能模組。

初始化:模型和系統提示詞初始化。其實從程式處理過程上分析,並沒有特別區分系統提示詞與使用者輸入,實際專案開發中完全可以放在一起處理。後面會再解釋它們在概念上的區別。

使用者輸入:等待使用者輸入文字資訊。大語言模型其實就是對人類的文字資訊進行分析和理解的過程,而產品落地的本質就是藉助大模型的理解進一步完成一些指定任務。在這個過程中,網際網路上又造了許多概念,什麼agent,function等。其實本質上都是在研究如何將大模型與程式進一步結合並完成互動。至少目前,我的觀點是:大模型僅具備語義分析,語義推理的能力。

分析預測:這個是大語言模型的核心能力之一,它需要分析上下文(系統提示詞、使用者輸入、已推理的內容)再進一步完成下一個詞語(token)的預測。

推理取樣:這個是大語言模型的另一個核心能力,它需要從分析預測的結果中隨機選擇一個token,並將它作為輸入反向傳送給分析預測模組繼續進行,直到輸出結束(EOS)。

輸出:這個模組嚴格說不屬於大模型,但是它又是完成使用者互動必須模組。從產品設計上,可以選擇逐字輸出(token-by-token)或者一次性輸出(token-by-once)。

2. 概念介紹

角色(roles):大語言模型通常會內建三種角色:系統(system),使用者(user),助手(assistant)。這三種角色並非所有模型統一指定,但是基本目前所有開源的大模型都相容這三種角色的互動,它有助於大模型更好的理解人類語境並完成任務。system表示系統提示詞,就是我們常說的prompt。網上有不少課程將寫系統提示詞描述為提示詞工程,還煞有介事的進行分類,其實大可不必。從我的使用經驗看,一個好的系統提示詞(prompt)應具備三個要點即可:語義明確,格式清晰,任務簡單。語義明確即在系統提示詞中儘量不要使用模稜兩可的詞語,用人話說就是“把問題說清楚”。格式清晰即可以使用markdown或者json指定一些重要概念。如果你需要讓大模型按照某個固定流程進行分析,可以使用markdown的編號語法,如果你需要將大模型對推理結果進行結構化處理,可以使用json語法。任務簡單即不要讓大模型處理邏輯太複雜或者流程太多的任務。大模型的推理能力完全基於語義理解,它並不具備嚴格意義上的程式執行邏輯和數學運算邏輯。這就是為什麼,當你問大模型:1.11和1.8誰大的時候,它會一本正經的告訴你,當整數部分一樣大的時候,僅需要比較小數部分,因為11大於8,因此1.11大於1.8。那麼如果我們現實中確實有一些計算任務或複雜的流程需要處理怎麼辦?我的解決方案是,與程式互動和動態切換上下文。除了系統角色以外,使用者一般代表輸入和助手一般代表輸出。

token:這裡不要理解為令牌,它的正確解釋應該是一組向量的id。就是常見的描述大模型上下文長度的單位。一個token代表什麼?網際網路上有很多錯誤的解釋,比較常見說法是:一個英文單詞為1個token,一箇中文通常是2-3個token。上面的流程介紹一節,我已經解釋了“分析預測”與“取樣推理”如何互動。“推理取樣”生成1個token,反向輸送給“分析預測”進行下一個token的預測,而輸出模組可以選擇token-by-token的方式向使用者輸出。實際上,對於中文而言,一個token通常表示一個分詞。例如:“我愛中國”可能的分詞結果是“我”,“愛”,“中國”也可能是“我”,“愛”,“中”,“國”。前者代表3個token,後者代表4個token。具體如何劃分,取決於大模型的中文指令訓練。除了常見的代表詞語的token以外,還有一類特殊token(special token),例如上文提到的,大模型一個字一個字的進行推理生成,程式怎麼知道何時結束?其實是有個eos-token,當讀到這個token的時候,即表示本輪推理結束了。

3. 程式結構

llama.cpp的程式結構比較清晰,核心模組是llama和ggmll。ggml透過llama進行呼叫,開發通常不會直接使用。在llama中定義了常用的結構體和函式。common是對llama中函式功能的再次封裝,有時候起到方便呼叫的目的。但是版本迭代上,common中的函式變化較快,最好的方法是看懂流程後直接呼叫llama.h中的函式。

4. 原始碼分析

下面我以examples/main/main.cpp作為基礎做重點分析。

(1) 初始化

全域性引數,這個結構體主要用來接收使用者輸入和後續用來初始化模型與推理上下文。

gpt_params params;

系統初始化函式:

llama_backend_init();
llama_numa_init(params.numa);

系統資源釋放函式:

llama_backend_free();

建立模型和推理上下文:

llama_init_result llama_init = llama_init_from_gpt_params(params);

llama_model *model = llama_init.model;
llama_context *ctx = llama_init.context; 

它宣告在common.h中。如果你需要將模型和上下文分開建立可以使用llama.h中的另外兩對函式:

llama_model_params model_params = llama_model_params_from_gpt_params(gpt_params_);
llama_model_ = llama_load_model_from_file(param.model.c_str(), model_params);

llama_context_params ctx_eval_params = llama_context_params_from_gpt_params(gpt_params_);
llama_context *ctx_eval = llama_new_context_with_model(llama_model_, ctx_eval_params);

建立ggml的執行緒池,這個過程可能和模型加速有關,程式碼中沒有對它的詳細解釋:

struct ggml_threadpool * threadpool = ggml_threadpool_new(&tpp);

llama_attach_threadpool(ctx, threadpool, threadpool_batch);

除了完成一般的推理任務,llama.cpp還實現了上下文儲存與讀取。上下文切換的前提是不能換模型,且僅首次推理接收使用者輸入的prompt。利用這個特性,可以實現上下文的動態切換。

std::string path_session = params.path_prompt_cache;
std::vector<llama_token> session_tokens;

至此,有關係統初始化模組的過程已經完成。

(2) 使用者輸入

為了接收使用者輸入和推理輸出,原始碼集中定義了幾個變數:

std::vector<llama_token> embd_inp;

std::vector<llama_token> embd;

檢查編碼器,現代模型大多都沒有明確定義的encodec

if (llama_model_has_encoder(model)) {
    int enc_input_size = embd_inp.size();
    llama_token * enc_input_buf = embd_inp.data();
    if (llama_encode(ctx, llama_batch_get_one(enc_input_buf, enc_input_size, 0, 0))) {
        LOG_TEE("%s : failed to eval\n", __func__);
        return 1;
    }
    llama_token decoder_start_token_id = llama_model_decoder_start_token(model);
    if (decoder_start_token_id == -1) {
        decoder_start_token_id = llama_token_bos(model);
    }

    embd_inp.clear();
    embd_inp.push_back(decoder_start_token_id);
}

(3) 分析預測

分析預測部分的核心程式碼如下,我將處理關注力和session的邏輯刪除,僅保留推理部分的邏輯。

// predict
if (!embd.empty()) {
    // Note: (n_ctx - 4) here is to match the logic for commandline prompt handling via
    // --prompt or --file which uses the same value.
    int max_embd_size = n_ctx - 4;

    // Ensure the input doesn't exceed the context size by truncating embd if necessary.
    if ((int) embd.size() > max_embd_size) {
        const int skipped_tokens = (int) embd.size() - max_embd_size;
        embd.resize(max_embd_size);

        console::set_display(console::error);
        printf("<<input too long: skipped %d token%s>>", skipped_tokens, skipped_tokens != 1 ? "s" : "");
        console::set_display(console::reset);
        fflush(stdout);
    }

    for (int i = 0; i < (int) embd.size(); i += params.n_batch) {
        int n_eval = (int) embd.size() - i;
        if (n_eval > params.n_batch) {
            n_eval = params.n_batch;
        }

        LOG("eval: %s\n", LOG_TOKENS_TOSTR_PRETTY(ctx, embd).c_str());

        if (llama_decode(ctx, llama_batch_get_one(&embd[i], n_eval, n_past, 0))) {
            LOG_TEE("%s : failed to eval\n", __func__);
            return 1;
        }

        n_past += n_eval;

        LOG("n_past = %d\n", n_past);
        // Display total tokens alongside total time
        if (params.n_print > 0 && n_past % params.n_print == 0) {
            LOG_TEE("\n\033[31mTokens consumed so far = %d / %d \033[0m\n", n_past, n_ctx);
        }
    }
}

embd.clear();

邏輯的重點是:首先,如果推理的上下文長度超限,會丟棄超出部分。實際開發中可以考慮重構這個部分的邏輯。其次,每次推理都有一個處理數量限制(n_batch),這主要是為了當一次性輸入的內容太多,系統不至於長時間無響應。最後,每次推理完成,embd都會被清理,推理完成後的資訊會儲存在ctx中。

(4) 推理取樣

取樣推理部分的原始碼分兩個部分:

if ((int) embd_inp.size() <= n_consumed && !is_interacting) {
    // optionally save the session on first sample (for faster prompt loading next time)
    if (!path_session.empty() && need_to_save_session && !params.prompt_cache_ro) {
        need_to_save_session = false;
        llama_state_save_file(ctx, path_session.c_str(), session_tokens.data(), session_tokens.size());

        LOG("saved session to %s\n", path_session.c_str());
    }

    const llama_token id = llama_sampling_sample(ctx_sampling, ctx, ctx_guidance);

    llama_sampling_accept(ctx_sampling, ctx, id, /* apply_grammar= */ true);

    LOG("last: %s\n", LOG_TOKENS_TOSTR_PRETTY(ctx, ctx_sampling->prev).c_str());

    embd.push_back(id);

    // echo this to console
    input_echo = true;

    // decrement remaining sampling budget
    --n_remain;

    LOG("n_remain: %d\n", n_remain);
} else {
    // some user input remains from prompt or interaction, forward it to processing
    LOG("embd_inp.size(): %d, n_consumed: %d\n", (int) embd_inp.size(), n_consumed);
    while ((int) embd_inp.size() > n_consumed) {
        embd.push_back(embd_inp[n_consumed]);

        // push the prompt in the sampling context in order to apply repetition penalties later
        // for the prompt, we don't apply grammar rules
        llama_sampling_accept(ctx_sampling, ctx, embd_inp[n_consumed], /* apply_grammar= */ false);

        ++n_consumed;
        if ((int) embd.size() >= params.n_batch) {
            break;
        }
    }
} 

首先要關注第2部分,這一段的邏輯是將使用者的輸入載入上下文中,由於使用者的輸入不需要推理,因此只需要呼叫llama_sampling_accept函式。第1部分只有當使用者輸入都完成以後才會進入,每次取樣一個token,寫進embd。這個過程和分析預測交替進行,直到遇到eos。

if (llama_token_is_eog(model, llama_sampling_last(ctx_sampling))) {
    LOG("found an EOG token\n");

    if (params.interactive) {
        if (params.enable_chat_template) {
            chat_add_and_format(model, chat_msgs, "assistant", assistant_ss.str());
        }
        is_interacting = true;
        printf("\n");
    }
}

chat_add_and_format函式只負責將所有互動過程記錄在char_msgs中,對整個推理過程沒有影響。如果要實現使用者輸出,可以在這裡處理。

二、關鍵函式

透過gpt_params初始化llama_model_params

struct llama_model_params     llama_model_params_from_gpt_params    (const gpt_params & params);

建立大模型指標

LLAMA_API struct llama_model * llama_load_model_from_file(
                             const char * path_model,
            struct llama_model_params     params);

建立ggml執行緒池和設定執行緒池

GGML_API struct ggml_threadpool*         ggml_threadpool_new          (struct ggml_threadpool_params  * params);
LLAMA_API void llama_attach_threadpool(
               struct   llama_context * ctx,
            ggml_threadpool_t   threadpool,
            ggml_threadpool_t   threadpool_batch);

透過gpt_params初始化llama_context_params

struct llama_context_params   llama_context_params_from_gpt_params  (const gpt_params & params);

LLAMA_API struct llama_context * llama_new_context_with_model(
                     struct llama_model * model,
            struct llama_context_params   params);

對輸入進行分詞並轉換成token

std::vector<llama_token> llama_tokenize(
  const struct llama_context * ctx,
           const std::string & text,
                        bool   add_special,
                        bool   parse_special = false);

獲取特殊token

LLAMA_API llama_token llama_token_bos(const struct llama_model * model); // beginning-of-sentence
LLAMA_API llama_token llama_token_eos(const struct llama_model * model); // end-of-sentence
LLAMA_API llama_token llama_token_cls(const struct llama_model * model); // classification
LLAMA_API llama_token llama_token_sep(const struct llama_model * model); // sentence separator
LLAMA_API llama_token llama_token_nl (const struct llama_model * model); // next-line
LLAMA_API llama_token llama_token_pad(const struct llama_model * model); // padding

批次處理token並進行預測

LLAMA_API struct llama_batch llama_batch_get_one(
                  llama_token * tokens,
                      int32_t   n_tokens,
                    llama_pos   pos_0,
                 llama_seq_id   seq_id);

LLAMA_API int32_t llama_decode(
            struct llama_context * ctx,
              struct llama_batch   batch);

執行取樣和接收取樣

llama_token llama_sampling_sample(
        struct llama_sampling_context * ctx_sampling,
        struct llama_context * ctx_main,
        struct llama_context * ctx_cfg,
        int idx = -1);

void llama_sampling_accept(
        struct llama_sampling_context * ctx_sampling,
        struct llama_context * ctx_main,
        llama_token id,
        bool apply_grammar);

將token轉成自然語言

std::string llama_token_to_piece(
        const struct llama_context * ctx,
                       llama_token   token,
                       bool          special = true);

判斷推理是否結束,注意,這個token可能和llama_token_eos獲取的不一致。因此一定要透過這個函式判斷

// Check if the token is supposed to end generation (end-of-generation, eg. EOS, EOT, etc.)
LLAMA_API bool llama_token_is_eog(const struct llama_model * model, llama_token token);

三、總結

本文旨在介紹llama.cpp的基礎用法,由於Georgi Gerganov更新較快,且缺少文件。因此可能有些解釋不夠準確。如果大家對框架和本文敢興趣可以給我留言深入討論。

相關文章