65.9K
CodeProject 正在变化。 阅读更多。
Home

当今将 Windows 应用迁移到 Windows on Arm 的最佳实践

starIconstarIconstarIconstarIconstarIcon

5.00/5 (1投票)

2021 年 1 月 28 日

CPOL

12分钟阅读

viewsIcon

15968

在本文中,我们将演示一个示例应用程序在模拟模式下会受到性能影响,并展示如何将现有代码库移植到 Windows on Arm。我们将介绍如何设置开发环境,使用 .NET Framework 4.8 来针对 ARM64 处理器。

在本文中,我们将演示一个示例应用程序在模拟模式下会受到性能影响,并展示如何将现有代码库移植到 Windows on Arm。我们将介绍如何设置开发环境,使用 .NET Framework 4.8 来针对 ARM64 处理器。

我们开始看到 Windows 10 on Arm 的采用速度加快,市场上出现了许多新设备,这些设备运行在基于 Arm 的设备上,例如 Microsoft Surface Pro X、Samsung Galaxy Book S 和 Lenovo Yoga 5G 设备。Windows 10 on Arm 完全支持运行 x86 win32 应用程序,包括 Windows Forms 应用程序和 Windows Presentation Foundation (WPF) 应用程序。主要优势是 Arm 处理器的惊人电池续航能力。我在这篇文章中使用的设备是运行 ARM64 处理器上的 Surface Pro X。

虽然这些应用程序可以在 Windows 10 on Arm 上运行,但为 Intel 处理器编译的应用程序在这些设备上通过 x86 和 X64 模拟层运行。当然,在模拟模式下运行的应用程序比原生运行的 ARM64 应用程序要慢。

在本文中,我将从一个执行大量浮点计算的 WPF 应用程序开始。我们可以将其编译为 x86 或 x64。当应用程序在 Surface Pro X 上执行时,它将在 x86 模拟模式下运行。

然后,我将演示如何将此应用程序移植到 UWP 应用程序,并将此 UWP 应用程序部署为在 ARM64 处理器上运行。

最后,我将比较在模拟模式下运行的 WPF 应用程序和在 Windows 10 on Arm 上原生运行的 UWP 应用程序之间的运行时性能结果。

对于本文,我假设您具备 .NET 知识(代码是 C#),以及一些 WPF、UWP 和 XAML 知识。所有示例代码都可以在 GVerelst/Mandelbrot (github.com) 中找到。

模拟层

为 .NET Framework 编译的应用程序可以在 ARM64 Windows 系统上运行,但代码将在模拟器中执行。这使得 Windows 10 on Arm 能够运行几乎任何代码,但不是最优化方式。

对于 x86 应用程序,与在 x86 Windows 上运行没有区别。x86 系统 DLL 会经过 Windows on Windows (WoW) 应用程序层。该层包含一个 x86 到 Arm 的 CPU 模拟器,它将使 x86 代码在 Arm 处理器上运行。之后,正常的执行将继续到系统服务和内核。

x86 指令将在运行时转换为 ARM64,并在磁盘上缓存以供进一步使用。这意味着不需要特殊的安装。非原生代码的数量已尽可能限制,这解释了为什么它仍然可以运行得相当快。但这不会像原生 ARM64 应用程序那样快。要了解更多信息,请参阅 Microsoft Windows 开发者文档中的 ARM 上的 x86 模拟如何工作

但是,如果您可以为 Arm 处理器编译二进制文件,它们就可以直接与原生系统 DLL 通信。这些 DLL 又与系统服务通信,然后从那里根据需要由内核接管。无需模拟。这显然是最佳方案。

示例应用程序的架构

为了看到性能差异,我们需要一个执行大量计算的应用程序。为了使其在图形上更具吸引力,我选择了著名的 Mandelbrot 集合的表示。维基百科文章准确地解释了点是如何计算的,但以下是我们了解的关键:

屏幕上的每个点都是通过对该点重复执行一个小函数来计算的,直到该点到原点的距离大于 2,或者达到了最大迭代次数。在达到最大迭代次数后仍未“逃逸”出 2 单位圆的点属于 Mandelbrot 集合,而其他点则不属于。在点“逃逸”之前,函数被调用的次数将决定其颜色。

这里的关键在于,对于每个点,都需要执行大量的浮点计算来确定其颜色。这就是应用程序的样子:

更多的迭代意味着更多的处理时间。在这种情况下,我们使用了 5000 次迭代,这在我的开发 PC 上花费了 3.824 秒。我们将使用此数据作为基准测试。我们只计时实际计算,不包括将其输出到位图并显示的时间。

橙色表面上的每个点都需要计算,因此计算次数也取决于位图的大小。这就像一个隐藏参数。

我创建了一个单独的 Mandelbrot 库用于计算。该库在两个项目之间共享,因此用于计算的代码完全相同。

WPF 应用程序使用此库,并包含一个带有绘图区域和一些控件的主窗口。单击“开始计算”按钮会调用计算函数并输出结果。

UWP 应用程序也将执行相同的操作,但由于 UWP 的性质会做一些更改。

对于此项目,我使用了 最新版本的 Visual Studio 2019 和 .NET Framework 4.8。

在 VS2019 安装程序中,请验证您已安装“.NET 桌面开发”和“通用 Windows 平台开发”工作负载。

构建 x86 WPF 应用程序

初始应用程序是使用我单独创建的 Mandelbrot 库的 WPF 应用程序。我们可以在 Surface 上运行它。它不是专门为 ARM64 编译的,因此它将在 x86 模拟模式下运行。

要部署 WPF 应用程序,我们只需将解决方案编译为“Any CPU”,然后将包含可执行文件和 DLL 的应用程序 bin 文件夹复制到 Surface 上的一个文件夹中。

在当前的 .NET Framework 5 中,无法直接为 ARM64 编译 WPF 应用程序。Microsoft 计划在 .NET 5 或可能是 .NET 6 的未来版本中包含 WPF ARM64 支持。

现在,我们可以使用 UWP 来创建可以编译为 ARM64 的应用程序。我们将使用 .NET Framework 4.8 在我们的解决方案中创建一个新的 UWP 项目。UWP 应用程序可以作为原生 ARM64 应用程序发布,无需模拟即可运行。

在 UWP 应用程序中,我们将尽可能多地重用 WPF 代码。

创建新的 UWP 应用程序的过程非常简单:

  1. 右键单击解决方案。
  2. 选择添加 > 新建项目…
  3. 在窗口顶部的搜索框中,键入“UWP”。
  4. 选择空白应用程序 (通用 Windows)
  5. 选择正确的语言的项目。在本文中,我将使用 C#。
  6. 单击“下一步”。在下一页,为项目命名 (Mandelbrot.UWP),保留默认位置,然后单击创建

当我们运行此项目时,它会显示一个空窗口。

为 ARM64 编译项目

在我们开始编码之前,让我们发布该项目并将其安装在 Surface 上。当我们运行该项目时,它应该以 ARM64 原生模式运行。

首先,右键单击 UWP 项目,然后选择发布 > 创建应用程序包…

在第一页,我们选择分发方法。在开发过程中选择侧载。另一个选项是 Microsoft Store,它会带来其他挑战。请参阅我关于此主题的 msdev.pro 上的文章。保持启用自动更新未选中,然后单击下一步

在签名方法页面,保留默认设置,然后单击下一步

在最后一页使用以下设置:

我只选择了 ARM64,因为此选项与其他选项是互斥的。您不能在此页面上同时选择 ARM64 和其他选项。

最后,单击创建。项目现在已为 ARM64 编译,并且包文件已在 c:\temp\deploy 中创建。

创建包后,请检查文件夹内容。请注意,由于我们未将配置设置为“发布”,因此我们现在创建了一个包含调试信息的包。目前这没关系。对于基准测试,我们将使用“发布”模式。

在 Surface 上部署包

在 Surface Pro X 上,将 Deploy 文件夹中创建的版本复制到 Surface 可以访问的文件夹中。我使用了 OneDrive,但您可以使用任何您喜欢的方法。简单的 USB 驱动器即可。

在安装包之前,我们需要将 Surface 置于开发者模式:

在 Windows 搜索框(“开始”按钮旁边)中键入“开发者设置”,然后打开设置。这将带您进入“Windows 安全”设置页面。

开发者模式打开,这样您就可以直接在 Surface 上安装 UWP 应用程序,而无需通过 Windows 应用商店。

完成后,转到共享文件夹并启动 Install.ps1 脚本。这将执行必要的步骤来部署空应用程序。

我们现在可以从 Windows 开始菜单启动该应用程序。启动应用程序后,打开任务管理器。请注意,UWP 应用程序以原生模式运行 — 无需模拟!WPF 版本以 32 位模拟模式运行。

完成 Mandelbrot 应用程序的 UWP 版本

现在我们已经证明 UWP 版本在 Surface 上以原生模式运行,我们可以实现与 WPF 版本相同的功能。我们将在这两个项目中使用相同的计算库。计时将仅针对库中的计算。

我们从具有以下结构的 WPF 应用程序开始:

UWP 应用程序的用户界面是 XAML 驱动的,因此大部分代码都是可重用的。不过,并非所有 API 都相同。在 WPF 应用程序中,我使用了 MVVM 模式来分离功能和表示。这将在转换过程中得到回报。

在 Mandelbrot.UWP 中,通过右键单击“引用”来引用 Mandelbrot.Calculations 项目,然后选择添加引用…。选中Mandelbrot.Calculations复选框以包含该项目。

接下来,通过右键单击 UWP 项目,然后选择添加 > 新建文件夹来创建一个名为 ViewModels 的文件夹。同样,重复此操作来创建一个名为 Extensions 的文件夹。

将文件 MandelbrotParameters.csMandelbrot.WPF/ViewModels 复制到 Mandelbrot.UWP/ViewModels

为了代码整洁,请通过将其更改为 Mandelbrot.UWP.ViewModels 来修复命名空间。这不是强制性的,但建议这样做。

WPF 应用程序运行在 WPF 窗口内,但 UWP 应用程序运行在一个页面内,因此我们必须修复数据类型。

class MandelbrotParameters : INotifyPropertyChanged
{
    private readonly MainPage _window;
    public MandelbrotParameters(MainPage window)
    {
        _window = window;
        Reset();
    }
// ...

您可能希望将名称 _window 更改为 _page,但为了使两个代码库保持相似,我决定不这样做。我也可以从使用 Page 而不是 Window 的 WPF 应用程序开始。但这只是一个小小的转换步骤。

RelayCommand.cs 复制到 UWP 项目。同样,请修复命名空间。

现在我们来修复 XAML。我们不能直接从 WPF 项目复制 XAML 文件,因为它描述的是一个窗口。但是,我们可以复制 <Window> 元素下的顶级 <Grid> 元素。所以选择 WPF 中的顶级 <Grid> 元素,并用它替换 UWP 中的顶级 <Grid> 元素。

UWP 不认识 <Label> 元素,所以让我们将所有 <Label> 元素替换为 <TextBlock> 元素。对每个 <Label> 执行此操作。
 

WPF 版本

<Label Content="Top:" Grid.Row="1" Grid.Column="0" VerticalAlignment="Top" Margin="0,0,0,5" HorizontalAlignment="Right"
/>

UWP 版本

<TextBlock Grid.Row="1" Grid.Column="0" VerticalAlignment="Top" Margin="0,0,0,5" HorizontalAlignment="Right" >
Top: </TextBlock>

在 UWP 中,数据绑定默认是 OneWay 的。这意味着,如果您在代码中设置了绑定到控件的属性的值,它将在页面中反映出来,但如果您在 UI 中更改控件的值,其值将不会传回绑定的属性。每个 {Binding xxx} 元素现在都需要变成 {Binding xxx, Mode=TwoWay}。这是顶部 TextBox 的示例:

<TextBox HorizontalAlignment="Left" VerticalAlignment="Center" Width="51" Grid.Row="1" Grid.Column="1" Margin="0,0,0,5" Text="{Binding Top, Mode=TwoWay}"/>

MainPage 类中,将 DataContext 设置为一个新的 MandelbrotParameters 实例。我们将此作为参数传递,以便我们的 ViewModel 能够调用 DrawImage 方法。

public MainPage()
{
    this.InitializeComponent();
    DataContext = new MandelbrotParameters(this);
}

DrawImage 方法从 WPF 的 MainWindow.cs 复制到 UWP 的 MainPage.cs。这里我们看到了 UWP API 并不总是与 WPF API 相同的例子:WriteableBitmap 构造函数不接受六个参数,只接受两个:宽度和高度。修复很容易:删除所有其他参数。

同样复制 GetImageViewPort。此方法无需更改。

Extensions 文件夹复制 WriteableBitmapExtensions.cs。您会看到编译错误,因为 UWP 中的位图 API 与其 WPF 对应项略有不同。替换 SetPixels 方法。

这是原始 WPF 版本:

public static void SetPixels(
    this WriteableBitmap wbm,
    IEnumerable<FractalPoint> pts)
{
    wbm.Lock();
    IntPtr buff = wbm.BackBuffer;
    int Stride = wbm.BackBufferStride;

    unsafe
    {
        byte* pbuff = (byte*)buff.ToPointer();

        foreach (FractalPoint pt in pts)
        {
            System.Drawing.Color c = pt.Color;
            int loc = pt.Point.Y * Stride + pt.Point.X * 4;
            pbuff[loc] = c.B;
            pbuff[loc + 1] = c.G;
            pbuff[loc + 2] = c.R;
            pbuff[loc + 3] = c.A;
        }
    }

    wbm.AddDirtyRect(new Int32Rect(0, 0, (int)wbm.Width, (int)wbm.Height));
    wbm.Unlock();
}

新的 UWP 版本应该如下所示:

public static void SetPixels(
    this WriteableBitmap wbm,
    IEnumerable<FractalPoint> pts)
{
    byte[] imageArray = new byte[(int)(wbm.PixelWidth * wbm.PixelHeight * 4)];
    int i = 0;
    foreach (var p in pts)
    {
        imageArray[i] = p.Color.B;
        imageArray[i + 1] = p.Color.G;
        imageArray[i + 2] = p.Color.R;
        imageArray[i + 3] = p.Color.A;

        i += 4;
    }
    using (Stream stream = wbm.PixelBuffer.AsStream())
    {
        //write to bitmap
        stream.Write(imageArray, 0, imageArray.Length);
    }
}

将两个应用程序都部署到 Surface

现在我们将两个应用程序都部署到 Surface,以便我们可以比较性能。

确保您使用的是发布模式并重新生成解决方案。

执行我们之前部署 UWP 应用程序到 Surface 的步骤。对 WPF 项目也执行相同的操作。

花费所有这些麻烦让应用程序以 64 位 Arm 原生模式运行是否值得?让我们拭目以待。

为了计算 Mandelbrot 点,我最大化了应用程序,以确保我们使用相同的计算量进行比较,并且我保留了默认参数,除了迭代计数设置为 5000。

WPF 应用程序花费了35.671 秒来计算 Mandelbrot 点。

UWP 应用程序仅花费了3.203 秒!这大约是11 倍的速度

总结

为原生模式开发是完全值得的。我从一个 WPF 应用程序开始,其中 职责得到了很好的分离。这是通过使用 MVVM 模式以及分离位图生成和计算的代码来实现的。因此,在 UWP 应用程序中需要进行的修改是最小的。

我没有探讨使用并行化会如何影响结果。这些点是按顺序计算的。看看这是否会改变结果以及并行化对性能的影响是否很大,可能会很有趣。

要了解更多信息,以下是一些代码示例和教程,深入探讨了我们示例应用程序代码背后的一些主题。

© . All rights reserved.