💦

Foundry Local SDKをWPFで使う話

に公開

Microsoft.AI.Foundry.Local.WinML を試したら WPF では使えなかった話

先日、Foundry Local SDK の新しいバックエンドとして Microsoft.AI.Foundry.Local.WinML が公開された。
WinML(Windows ML)をバックエンドに使うことで、WindowsのML推論スタックと統合され、NPUやGPUを自動活用できるという触れ込みだ。

「これはWPFアプリに組み込めるのでは」と期待して試してみたが、結論としてWPFでは使えなかった。

WPF で使えない理由

WPFプロジェクトに Microsoft.AI.Foundry.Local.WinML を追加すると、ビルド時に次のエラーが発生してパッケージ自体が読み込めない。

あー。はい。
Windows環境に設定しているのに、 Gpu.Linux 読むのやめてもらえませんか。

つまり、

  • WinMLバックエンドが内部的に参照しているパッケージのバージョン指定が欠落している
  • その結果、依存パッケージ全体が読み込めない
  • Visual Studioのエラー一覧が大量のエラーで埋め尽くされる

対応フレームワークが特殊すぎる

さらに、Microsoft.AI.Foundry.Local.WinML が要求するターゲットフレームワークは、 net9.0-windows10.0.26100NuGetページにnet10.0-windowsと書いてある のにだ。

そもそも 解説ページに記載されている プロジェクト構成。これはコンソール版の構成で、ここから一歩でも外れるとコンパイルが成功しない。
例えば、

<UseWPF>true</UseWPF>

とか入れようものなら、エラーが発生するわけで。

解決方法:WinMLをあきらめて Microsoft.AI.Foundry.Local を使う

サクッと解決方法としては、Microsoft.AI.Foundry.Local.WinML をあきらめて Microsoft.AI.Foundry.Local にしておくこと。
これなら、WPFでも問題なく動くし、Foundry Localの基本機能(ローカル推論)は利用可能。

注意点:NPUは使えなさそう

Microsoft.AI.Foundry.Local.WinML の売りはWinML経由でNPUを使えることだが、標準版の Microsoft.AI.Foundry.Local では以下が使えない。

  • OpenVINO(Intel/Qualcomm NPU)
  • Vitis AI(AMD NPU)

WPFでNPUを使いたい場合は、現状ではFoundry Local SDKではなく、OpenVINO RuntimeやDirectMLを直接使う構成の方が現実的、ということらしい。

以下は動かしてみたソースコード

では、実際に動かしてみたWPFのコード。

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

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net10.0-windows10.0.22621.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UseWPF>true</UseWPF>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AI.Foundry.Local" Version="1.0.0" />
    <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
    <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.5" />
    <PackageReference Include="Microsoft.ML.OnnxRuntime.Foundry" Version="1.24.4" />
    <PackageReference Include="Microsoft.ML.OnnxRuntimeGenAI.Foundry" Version="0.13.1" />
    <PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" />
    <PackageReference Include="ReactiveProperty.WPF" Version="9.8.0" />
    <PackageReference Include="RyzenAI_Deployment" Version="1.7.1" />
  </ItemGroup>

</Project>

いろいろ試しているうちにざっくり不必要なライブラリも入れてしまっている。(そして動いていない)
キモは

<TargetFramework>net10.0-windows10.0.22621.0</TargetFramework>

かな。

MainWindow.xaml
<Window x:Class="FoundryLocalTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:FoundryLocalTest"
        mc:Ignorable="d"
        Title="Foundry Local Test" Height="450" Width="800">

    <Window.Resources>
        <BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter"/>
    </Window.Resources>

    <d:Window.DataContext>
        <local:MainWindowModelMock/>
    </d:Window.DataContext>

    <DockPanel>
        <StatusBar DockPanel.Dock="Bottom">
            <StatusBarItem DockPanel.Dock="Right">
                <ProgressBar Width="100" Height="16" IsIndeterminate="False" Visibility="{Binding ProgressVisibleFlag.Value, Converter={StaticResource BooleanToVisibilityConverter}}" Minimum="0" Maximum="100" Value="{Binding ProgressValue.Value}"/>
            </StatusBarItem>
            <StatusBarItem>
                <TextBlock Text="{Binding StatusText.Value}" VerticalAlignment="Center"/>
            </StatusBarItem>
        </StatusBar>

        <Grid Margin="10">
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="*"/>
            </Grid.RowDefinitions>

            <Grid Grid.Row="0">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="Auto"/>
                </Grid.ColumnDefinitions>
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="10"/>
                    <RowDefinition Height="Auto"/>
                </Grid.RowDefinitions>

                <TextBlock Grid.Column="0" Grid.Row="0" Text="Models:" Margin="0,0,10,0" VerticalAlignment="Center"/>
                <ComboBox Grid.Column="1" Grid.Row="0" ItemsSource="{Binding Models}" DisplayMemberPath="Name" SelectedItem="{Binding SelectedModel.Value, Mode=TwoWay}"/>
                <Button Grid.Column="2" Grid.Row="0" Content="Load Model" Command="{Binding ModelLoadCommand}" Margin="10,0,0,0" Padding="5,0"/>

                <TextBlock Grid.Column="0" Grid.Row="2" Text="Prompt:" Margin="0,0,10,0" VerticalAlignment="Center"/>
                <TextBox Grid.Column="1" Grid.Row="2" Text="{Binding PromptText.Value, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
                <Button Grid.Column="2" Grid.Row="2" Content="Go !" Command="{Binding PromptGoCommand}" Margin="10,0,0,0" Padding="5,0"/>
            </Grid>

            <TextBox Grid.Row="1" Text="{Binding ResponseText.Value}" IsReadOnly="True" Margin="0,10,0,0" AcceptsReturn="True" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto"/>
        </Grid>
    </DockPanel>
</Window>

ここは特に難しいことはしてませんね。

MainWindow.xaml.cs
public partial class MainWindow : Window
{
    public MainWindow(
        IMainWindowModel viewModel
        )
    {
        DataContext = viewModel;
        InitializeComponent();
    }

    protected override async void OnInitialized(EventArgs e)
    {
        base.OnInitialized(e);

        await ViewModel.MLInitialize();
    }

    private IMainWindowModel ViewModel => (IMainWindowModel)DataContext;
}

DI使っているので、こんなコードになります。
DI使う App.xaml.cs は適宜作ってください。
OnInitialized でViewModelからFoundry Localの初期化を行っているので、 InitializeComponent の前に DataContext にViewModelを設定してます。

MainWindowModel.cs
using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels;
using Microsoft.AI.Foundry.Local;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Reactive.Bindings;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Reactive.Linq;
using System.Text;
using Windows.ApplicationModel.Store;

namespace FoundryLocalTest;

public interface IMainWindowModel
{
    ReactiveCollection<ModelItem> Models { get; }
    ReactiveProperty<ModelItem?> SelectedModel { get; }
    ReactiveCommand ModelLoadCommand { get; }
    ReactiveProperty<string> StatusText { get; }
    ReactiveProperty<bool> ProgressVisibleFlag { get; }
    ReactiveProperty<double> ProgressValue { get; }
    ReactiveProperty<string> PromptText { get; }
    ReactiveProperty<string> ResponseText { get; }
    ReactiveCommand PromptGoCommand { get; }

    Task MLInitialize();
}

public class MainWindowModelMock : IMainWindowModel
{
    public ReactiveCollection<ModelItem> Models { get; } = new ReactiveCollection<ModelItem>();
    public ReactiveProperty<ModelItem?> SelectedModel { get; } = new ReactiveProperty<ModelItem?>();
    public ReactiveCommand ModelLoadCommand { get; } = new ReactiveCommand();
    public ReactiveProperty<string> StatusText { get; } = new ReactiveProperty<string>("-");
    public ReactiveProperty<bool> ProgressVisibleFlag { get; } = new ReactiveProperty<bool>(true);
    public ReactiveProperty<double> ProgressValue { get; } = new ReactiveProperty<double>(40);
    public ReactiveProperty<string> PromptText { get; } = new ReactiveProperty<string>("Hello, world !!");
    public ReactiveProperty<string> ResponseText { get; } = new ReactiveProperty<string>("Ya! ya! ya!");
    public ReactiveCommand PromptGoCommand { get; } = new ReactiveCommand();

    public Task MLInitialize()
    {
        throw new NotImplementedException();
    }
}

class MainWindowModel : IMainWindowModel
{
    public ReactiveCollection<ModelItem> Models { get; } = new ReactiveCollection<ModelItem>();
    public ReactiveProperty<ModelItem?> SelectedModel { get; } = new ReactiveProperty<ModelItem?>();
    public ReactiveCommand ModelLoadCommand { get; }
    public ReactiveProperty<string> StatusText { get; } = new ReactiveProperty<string>("-");
    public ReactiveProperty<double> ProgressValue { get; } = new ReactiveProperty<double>(0);
    public ReactiveProperty<string> PromptText { get; } = new ReactiveProperty<string>(string.Empty);
    public ReactiveProperty<string> ResponseText { get; } = new ReactiveProperty<string>(string.Empty);
    public ReactiveCommand PromptGoCommand { get; }
    public ReactiveProperty<bool> ProgressVisibleFlag { get; } = new ReactiveProperty<bool>(false);

    private IServiceProvider _serviceProvider;
    private IModel? _loadedModel = null;

    public MainWindowModel(
        IServiceProvider serviceProvider
        )
    {
        _serviceProvider = serviceProvider;

        ModelLoadCommand = SelectedModel
            .Select(m => m != null)
            .ToReactiveCommand();
        ModelLoadCommand.Subscribe(async () =>
        {
            if (SelectedModel.Value != null)
            {
                var mgr = FoundryLocalManager.Instance;
                var catalog = await mgr.GetCatalogAsync();
                var model = await catalog.GetModelAsync(SelectedModel.Value.Name);
                if (model != null)
                {
                    var isCached = await model.IsCachedAsync();
                    if (!isCached)
                    {
                        ProgressValue.Value = 0;
                        StatusText.Value = $"ダウンロード中... ({model.Id})";
                        ProgressVisibleFlag.Value = true;

                        await model.DownloadAsync(progress =>
                        {
                            ProgressValue.Value = progress;
                        });

                        ProgressVisibleFlag.Value = false;
                        StatusText.Value = string.Empty;
                    }

                    if (_loadedModel != null)
                    {
                        await _loadedModel.UnloadAsync();
                    }

                    var isLoaded = await model.IsLoadedAsync();
                    if (!isLoaded)
                    {
                        StatusText.Value = $"モデルをロード中... ({model.Id})";
                        await model.LoadAsync();
                        if (await model.IsLoadedAsync())
                        {
                            _loadedModel = model;
                        }
                        StatusText.Value = $"モデルがロードされました。({model.Id})";
                    }
                }
            }
        });

        PromptGoCommand = PromptText
            .Select(text => !string.IsNullOrWhiteSpace(text) && _loadedModel != null)
            .ToReactiveCommand();
        PromptGoCommand.Subscribe(async () =>
        {
            if (_loadedModel != null)
            {
                StatusText.Value = "推論中...";
                var chatClient = await _loadedModel.GetChatClientAsync();
                if (chatClient != null)
                {
                    var result = await chatClient.CompleteChatAsync(new ChatMessage[]
                    {
                        new ChatMessage("user", PromptText.Value),
                    });
                    ResponseText.Value = result.Choices[0].Message.Content ?? string.Empty;
                }
                StatusText.Value = string.Empty;
            }
        });
    }

    public async Task MLInitialize()
    {
        var config = new Configuration
        {
            AppName = "foundry_local_samples",
            LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Information
        };

        // Initialize the singleton instance.
        await FoundryLocalManager.CreateAsync(config, _serviceProvider.GetRequiredService<ILoggerFactory>().CreateLogger<IMainWindowModel>());
        var mgr = FoundryLocalManager.Instance;

        var eps = mgr.DiscoverEps();
        int maxNameLen = 30;
        Debug.WriteLine("Available execution providers:");
        Debug.WriteLine($"  {"Name".PadRight(maxNameLen)}  Registered");
        Debug.WriteLine($"  {new string('─', maxNameLen)}  {"──────────"}");
        foreach (var ep in eps)
        {
            Debug.WriteLine($"  {ep.Name.PadRight(maxNameLen)}  {ep.IsRegistered}");
        }

        // Download and register all execution providers with per-EP progress.
        // EP packages include dependencies and may be large.
        // Download is only required again if a new version of the EP is released.
        // For cross platform builds there is no dynamic EP download and this will return immediately.
        Debug.WriteLine("\nDownloading execution providers:");
        if (eps.Length > 0)
        {
            string currentEp = "";
            await mgr.DownloadAndRegisterEpsAsync((epName, percent) =>
            {
                if (epName != currentEp)
                {
                    if (currentEp != "")
                    {
                        Debug.WriteLine("");
                    }
                    currentEp = epName;
                }
                Debug.Write($"\r  {epName.PadRight(maxNameLen)}  {percent,6:F1}%");
            });
            Debug.WriteLine("");
        }
        else
        {
            Debug.WriteLine("No execution providers to download.");
        }

        // Get the model catalog
        var catalog = await mgr.GetCatalogAsync();
        var catalogList = await catalog.ListModelsAsync();
        Models.AddRangeOnScheduler(catalogList
            .Select(m => new ModelItem { Name = m.Alias })
            .OrderBy(item => item.Name));

        foreach (var model in catalogList)
        {
            if (await model.IsLoadedAsync())
            {
                _loadedModel = model;
            }
        }
    }
}

単純に このあたり からコード引っ張ってきてます。
読み込み済みのモデルを取得するにはこんなコードじゃなく、 await catalog.GetLoadedModelsAsync() とか使うほうが現実的。

あと、VSの予測がなかなかにすばらしいので、Mockコードを作るのが楽になっててもう。

おわりに

それにしてもなんにしても。
WPFからローカルLLMで推論できるのはなかなかに助かる話です。
ある程度ツールとかも使えるようなので、RAGもできそう。
そちらも要研究かな。

で、なんでこういうローカル推論にこだわるか、というと、Core Ultra + 64G RAMノートとかいう贅沢マシンを配布しておいて、LM StudioやOllamaに行こうとするとWarningを出してくるという、にんともかんともな環境だからです。
しかも、AzureのAI使おうとすると、API URLの申請が必要だとか…。
まあ、server立てる自由はあるので、そちら経由で使えばいいんですけどね。
(聞いてますか…N西様…あなたの心に直接話しかけたいのですが…)

とりあえずは、 Microsoft.AI.Foundry.Local.WinML がちゃんといろいろな構成を考えて作り直してくれればこんな記事は要らなくなります。
よろしくお願いします。MS様。

追記

そもそも、このエラーは何とかならんでしょうかねぇ、と。
投稿してから気づきました。
そこで、Copilotさんに聞くと、

え、回避方法あるんや。
エラーなのでだめだと思い込んでた…。

NU1015 の原因(.NET 10 からの破壊的変更)

ほうほう。
いきなりエラーに格上げされました、と。
というわけで、.Net10でやってるのが悪い、ということらしいのですが。

PackageReference に Version を明示する(推奨)

と言われましても。
NuGetに登録されているパッケージ内で書かれてないものはどうしようもないわけで。

SdkAnalysisLevel を 9.0.300 以下に下げる(暫定回避)

うんこれで。
というわけで、以下のようにしてみました。

FoundryLocalTest.csproj
  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net9.0-windows10.0.26100</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UseWPF>true</UseWPF>
    <SdkAnalysisLevel>9.0.300</SdkAnalysisLevel>
  </PropertyGroup>

なんと、これで無事ビルドできましたとさ。

追記の終わりに

ちゃんとビルドが通って、これで動くぞ…。
と思ったら、

            await mgr.DownloadAndRegisterEpsAsync((epName, percent) =>
            {
                if (epName != currentEp)
                {
                    if (currentEp != "")
                    {
                        Debug.WriteLine("");
                    }
                    currentEp = epName;
                }
                Debug.Write($"\r  {epName.PadRight(maxNameLen)}  {percent,6:F1}%");
            });
            Debug.WriteLine("");

この辺で

コード 3221225477 (0xc0000005) 'Access violation' で終了しました。

などと申しており。

Discussion