dotnet 基於 DirectML 控制檯執行 Phi-3 模型

lindexi發表於2024-06-13

本文將和大家介紹如何在 C# dotnet 裡面的控制檯應用裡面,使用 DirectML 將 Phi-3 模型在本地執行起來

在微軟的 Microsoft Build 2024 大會上介紹了 Phi-3 模型,這是一個 small language models (SLMs) 本地小語言模型。簡單說就是一個可以在使用者裝置上執行的模型,據說能和 Gpt 3.5 進行 PK 的模型,不僅體積較小,且執行速度較快

上一篇部落格和大家介紹了 WinML 和 DirectML 的基礎資訊。基於 DirectML 可以更加方便的在使用者機器上部署 Phi-3 模型,簡單到直接將模型檔案複製過去就可以執行。透過 DirectML 遮蔽底層執行細節,可以在特別多的機器型號上執行,即使 GPU 不支援,還可以自動降級使用 CPU 執行

基於 DirectML 的優勢就在於可以使用 DirectML 遮蔽大量底層細節,簡化模型部署工作,且能夠充分利用機器裝置資源

更多關於 Phi-3 的介紹請參閱 https://azure.microsoft.com/en-us/blog/introducing-phi-3-redefining-whats-possible-with-slms/

在開始之前,需要大家從 https://huggingface.co/microsoft/Phi-3-mini-4k-instruct-onnx/tree/main?clone=true 下載倉庫,大概的下載命令如下

git lfs install

git clone https://huggingface.co/microsoft/Phi-3-mini-4k-instruct-onnx

前置需要下載好了 git-lfs 工具,可到 https://git-lfs.com 官網進行下載。需要這個工具的原因是模型本身是透過 git lfs 使用 git 管理的。模型檔案非常大,需要使用 git lfs 進行下載

下載的倉庫大小大概有 20GB 左右,如果大家實在拉不下來,可以郵件給我,我將透過網盤分享給大家

下載下來的倉庫有多個不同的版本,在本文例子裡面將使用的是 DirectML 版本,即需要取出 directml-int4-awq-block-128 資料夾裡面的所有檔案,將其複製的最終應用的輸出資料夾,或者自己找個資料夾放著。如我就將其複製到 C:\lindexi\Phi3\directml-int4-awq-block-128\ 資料夾,複製之後的資料夾裡面的檔案內容如下

C:\lindexi\Phi3\
├── directml-int4-awq-block-128
│   ├── added_tokens.json
│   ├── genai_config.json
│   ├── model.onnx
│   ├── model.onnx.data
│   ├── special_tokens_map.json
│   ├── tokenizer.json
│   ├── tokenizer.model
│   ├── tokenizer_config.json

完成基礎下載模型檔案之後,接下來咱來開始編寫一個 dotnet 控制檯應用。其實對於 dotnet 系應用來說,控制檯能跑了,基本上意味著搭配上層 UI 框架也都能跑,比如上層 UI 框架使用 WPF 或 WinUI 或 MAUI 等框架都是可以的。本文使用控制檯只是為了簡單方便起見

新建 dotnet 控制檯專案,編輯 csproj 檔案用於安裝 Microsoft.ML.OnnxRuntimeGenAI.DirectML 庫,編輯之後的 csproj 程式碼如下

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.ML.OnnxRuntimeGenAI.DirectML" Version="0.2.0-rc7" />
  </ItemGroup>
</Project>

完成基礎準備之後,接下來可以進行編寫核心邏輯

總的基於 DirectML 使用本地 Phi-3 模型的步驟如下

  • 載入模型
  • 構建輸入資訊
  • 執行思考和輸出

載入模型資訊的程式碼很少,只需要建立 Microsoft.ML.OnnxRuntimeGenAI.Model 物件即可,如以下程式碼

using Microsoft.ML.OnnxRuntimeGenAI;

using System.Text;

var folder = @"C:\lindexi\Phi3\directml-int4-awq-block-128\";

using var model = new Model(folder);

以上的 folder 資料夾裡面存放的是我本地的 Phi-3 模型檔案的路徑,請大家修改為自己的實際使用路徑

接下來再使用 Microsoft.ML.OnnxRuntimeGenAI.Model 物件建立出 Microsoft.ML.OnnxRuntimeGenAI.Tokenizer 物件。這裡的 Tokenizer 是將文字轉換為機器友好的 Token 符號的作用,由於直接輸入人類的文字對於機器來說不夠友好,才有了這一步

using var tokenizer = new Tokenizer(model);

完成了模型的載入之後,接下來將透過控制檯獲取使用者的輸入內容,構建輸入資訊

    Console.WriteLine("請輸入聊天內容");

    var text = Console.ReadLine();

    var prompt = text;

以上的程式碼裡面直接使用控制檯輸入的內容作為提示詞資訊,這樣做比較簡單,但實際的效果將會讓 Phi-3 模型完全作為填充完成的存在。即 Phi-3 將嘗試補全輸入的文字後續的內容

如想要有一個更好的提示詞效果,可以使用如下字串方式進行填充

            var prompt = $@"<|system|>{systemPrompt}<|end|><|user|>{userPrompt}<|end|><|assistant|>";

為了簡單起見,本文只採用使用者輸入資訊作為提示詞。本文只是讓大家能夠將 Phi-3 模型跑起來,至於模型輸出效果,那就看大家自己煉丹了

獲取到提示詞之後,需要使用上文建立的 tokenizer 將其轉換為 token 列表,這裡的 token 列表其實就是一個數字集合。簡單理解就是一個給機器友好的字串編碼過程而已

var sequences = tokenizer.Encode(prompt);

將獲取到的 token 列表進行構建輸入引數

    var generatorParams = new GeneratorParams(model);

    generatorParams.SetSearchOption("max_length", 1024);
    generatorParams.SetInputSequences(sequences);
    generatorParams.TryGraphCaptureWithMaxBatchSize(1);

將輸入引數傳遞給到 Microsoft.ML.OnnxRuntimeGenAI.Generator 物件,程式碼如下

    using var generator = new Generator(model, generatorParams);

接下來即可使用 generator.ComputeLogits 方法讓模型進入思考狀態,以及透過 GenerateNextToken 方法生成模型所輸出的 token 內容

由於模型的輸出也是一個 token 內容,不是人類最佳化的文字,此時就需要再使用 tokenizer 的 Decode 方法將 token 轉換為文字

這裡有一個坑點在於不是每一個 token 都能對應一個單詞,有些是需要多個 token 才能對應一個單詞。為了方便開發者,微軟提供了 Microsoft.ML.OnnxRuntimeGenAI.TokenizerStream 型別,支援一個個 token 傳入。自動處理多個 token 對應一個單詞的情況。使用方法就是不斷將模型生成的 token 傳入給到 TokenizerStream 裡,如果 TokenizerStream 判斷輸入的 token 足夠生成單詞了,就會返回單詞字串,否則將會返回空字串。舉個例子,如果有個單詞需要三個 token 才能生成,那在傳入給到 TokenizerStream 第一個和第二個 token 時,都會返回空字串,傳入第三個 token 時才會返回單詞字串

建立 TokenizerStream 的程式碼如下

    using TokenizerStream tokenizerStream = tokenizer.CreateStream();

由於模型不是一次思考就能完成的,每次思考只是算出下一個 token 而已,需要編寫一個迴圈等待模型完成

    while (!generator.IsDone())
    {
        ... // 忽略其他程式碼
    }

進入迴圈,先呼叫 ComputeLogits 進行思考,再呼叫 GenerateNextToken 獲取模型建立的下一個 token 內容

    while (!generator.IsDone())
    {
        generator.ComputeLogits();
        generator.GenerateNextToken();

        ... // 忽略其他程式碼
    }

模型生成的下一個 token 都會自動追加到模型的 Sequence 裡面。而 Sequence 可以認為就是拼接了輸入的內容,也就是說模型將在輸入的內容的基礎上繼續追加 token 內容

    while (!generator.IsDone())
    {
        generator.ComputeLogits();
        generator.GenerateNextToken();

        // 這裡的 tokenSequences 就是在輸入的 sequences 後面新增 Token 內容
        var tokenSequences = generator.GetSequence(0);

        ... // 忽略其他程式碼
    }

此時拿到的 tokenSequences 就是在輸入的 sequences 後面新增 Token 內容。可以使用 Tokenizer 的 Decode 方法將其轉換為人類可讀的文字

        // 當前全部的文字
        var allText = tokenizer.Decode(tokenSequences);

這裡轉換到的是全部的文字內容,包括了輸入的內容以及模型每次思考建立的內容

如果只是想要實現獲取模型每一次思考時建立的內容,即實現一個詞一個詞輸出,則需要使用 TokenizerStream 輔助,程式碼如下

        // 這裡的 tokenSequences 就是在輸入的 sequences 後面新增 Token 內容
        var tokenSequences = generator.GetSequence(0);

        // 每次只會新增一個 Token 值
        // 需要呼叫 tokenizerStream 的解碼將其轉為人類可讀的文字
        // 由於不是每一個 Token 都對應一個詞,因此需要根據 tokenizerStream 壓入進行轉換,而不是直接呼叫 tokenizer.Decode 方法,或者呼叫 tokenizer.Decode 方法,每次都全部轉換

        // 取最後一個進行解碼為文字
        var decodeText = tokenizerStream.Decode(tokenSequences[^1]);

只需將 decodeText 在控制檯輸出,即可看到控制檯不斷一個個詞進行輸出

        Console.Write(decodeText);

可以看到這個過程實現的程式碼很少,本文使用的 Program.cs 的全部程式碼如下

// See https://aka.ms/new-console-template for more information

using Microsoft.ML.OnnxRuntimeGenAI;

using System.Text;

var folder = @"C:\lindexi\Phi3\directml-int4-awq-block-128\";
if (!Directory.Exists(folder))
{
    folder = Path.GetFullPath(".");
}

using var model = new Model(folder);
using var tokenizer = new Tokenizer(model);

for(var i = 0; i < int.MaxValue; i++)
{
    Console.WriteLine("請輸入聊天內容");

    var text = Console.ReadLine();

    var prompt = text;

    var generatorParams = new GeneratorParams(model);

    var sequences = tokenizer.Encode(prompt);

    generatorParams.SetSearchOption("max_length", 1024);
    generatorParams.SetInputSequences(sequences);
    generatorParams.TryGraphCaptureWithMaxBatchSize(1);

    using var tokenizerStream = tokenizer.CreateStream();
    using var generator = new Generator(model, generatorParams);
    StringBuilder stringBuilder = new();

    while (!generator.IsDone())
    {
        generator.ComputeLogits();
        generator.GenerateNextToken();

        // 這裡的 tokenSequences 就是在輸入的 sequences 後面新增 Token 內容
        var tokenSequences = generator.GetSequence(0);

        // 每次只會新增一個 Token 值
        // 需要呼叫 tokenizerStream 的解碼將其轉為人類可讀的文字
        // 由於不是每一個 Token 都對應一個詞,因此需要根據 tokenizerStream 壓入進行轉換,而不是直接呼叫 tokenizer.Decode 方法,或者呼叫 tokenizer.Decode 方法,每次都全部轉換

        // 當前全部的文字
        var allText = tokenizer.Decode(tokenSequences);

        // 取最後一個進行解碼為文字
        var decodeText = tokenizerStream.Decode(tokenSequences[^1]);
        // 有些時候這個 decodeText 是一個空文字,有些時候是一個單詞
        // 空文字的可能原因是需要多個 token 才能組成一個單詞
        // 在 tokenizerStream 底層已經處理了這樣的情況,會在需要多個 Token 才能組成一個單詞的情況下,自動合併,在多個 Token 中間的 Token 都返回空字串,最後一個 Token 才返回組成的單詞
        if (!string.IsNullOrEmpty(decodeText))
        {
            stringBuilder.Append(decodeText);
        }
        Console.Write(decodeText);
    }

    Console.WriteLine("完成對話");
}

Console.WriteLine("Hello, World!");

本文程式碼放在 githubgitee 上,可以使用如下命令列拉取程式碼

先建立一個空資料夾,接著使用命令列 cd 命令進入此空資料夾,在命令列裡面輸入以下程式碼,即可獲取到本文的程式碼

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin c7700766b617586eccb090ba859557ef08817484

以上使用的是 gitee 的源,如果 gitee 不能訪問,請替換為 github 的源。請在命令列繼續輸入以下程式碼,將 gitee 源換成 github 源進行拉取程式碼

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin c7700766b617586eccb090ba859557ef08817484

獲取程式碼之後,進入 Bp/FaldekallroCigerlurbe 資料夾,即可獲取到原始碼

將下載的 Phi-3 模型的檔案放入到一個資料夾,修改 folder 變數使用你自己本機的 Phi-3 模型資料夾路徑,執行程式碼,在控制檯輸入你想和 Phi-3 模型互動的提示詞,即可看到 Phi-3 模型的輸出內容

這個過程可以配合開啟工作管理員,看看自己裝置的 CPU 和 GPU 的執行情況

如果想要釋出給到其他夥伴執行,可以將模型檔案放入到你的專案輸出資料夾裡面,這樣即可讓其他夥伴執行。如此也可以看到此方式的部署是非常簡單的,不需要額外部署複雜的環境,只需要複製檔案過去即可

本文實際使用的 Microsoft.ML.OnnxRuntimeGenAI.DirectML 還是預覽版,也許後續正式版本將會更改一些內容

儘管本文演示的是控制檯方式執行,但大家可以非常方便在此基礎上構建一個 UI 介面,歡迎大家在此基礎上製作自己的應用

相關文章