折腾 NPU·第1章 —— 搭建 Level Zero 开发环境

在上一章 折腾 NPU·第0章 —— Intel NPU 概述与 Level-Zero 中,我们了解了 NPU 的概念以及 NPU 技术栈。本章将介绍如何在 VS Code 中搭建 Level-Zero 开发环境,以便我们在后续章节中编写 NPU 程序。

编译/构建工具准备

在 Windows 系统中,我们需要安装 VS Build Tools 和 CMake 作为编译和构建工具。CMake 可以通过 winget 安装:

winget install Kitware.CMake

VS Build Tools 则可以从 这里 下载,其包含了 MSVC 编译器 MSBuild 构建工具,类似于 Linux 中的 GCC 和 GNU Make。在安装时,我们需要勾选以下组件:

  • MSVC v143 - VS 2022 C++ x64/x86 build tools
  • Windows 11 SDK
  • C++ (v143) Universal Windows Platform tools
  • C++ CMake tools for Windows
  • MSVC v143 - VS 2022 C++ x64/x86 Spectre-mitigated libs

由于 Level Zero 编译使用了 /Qspectre 选项,列表中的最后一个组件是必选的,否则会有编译错误。如果不慎遗漏了某些组件,后续也可以通过重新运行 VS Installer 进行添加。

VS Code 准备

为了方便开发,我们需要在 VS Code 中安装以下插件:

  • CMake Tools (ms-vscode.cmake-tools)
  • C/C++ (ms-vscode.cpptools)

初始化项目

构建工具准备完成后,我们需要创建一个新的 CMake 项目 OneAPIDemo。该项目包含两个简单的文件:

int main() {
return 0;
}
cmake_minimum_required(VERSION 3.10)
project(OneAPIDemo)

set(CMAKE_CXX_STANDARD 17)

add_executable(OneAPIDemo src/main.cpp)

随后,在 VS Code 中执行 CMake: Select a Kit,选择 Visual Studio Build Tools 2022 Release - x86_amd64,并按 Ctrl + F5 尝试运行。如果到此一切正常,我们的项目就已经准备好了。

当然,我们也可以不依赖 VS Code 的 GUI 操作,而是通过命令行来构建项目。具体说来,我们可以在项目根目录下执行:

cmake --build build -G "Visual Studio 17 2022"
cmake --build build --config Debug

以上两条命令分别对应于 Select a KitCtrl + F5 的两个步骤。在构建完成后,我们同样可以在 build/Debug 目录下找到生成的可执行文件 OneAPIDemo.exe。在调试时,VSCode 的快捷方式更加方便,但在 CI/CD 等场景下,命令行构建则更加适用,因此我们有必要了解其背后具体执行的命令。

准备 Level Zero 依赖

这里我们采用 Git Submodule 的方式引入 Level Zero 依赖。在项目根目录下执行:

git init
git submodule add https://github.com/oneapi-src/level-zero
git submodule add https://github.com/intel/level-zero-npu-extensions

这样一来,整个项目变成了自包含的形式,即项目自带所有的依赖库,其他人也因此可以快速构建项目,而不必担心依赖库的安装问题。下载完依赖后,我们需要在 CMakeLists.txt 中添加以下内容:

cmake_minimum_required(VERSION 3.10)
project(OneAPIDemo)

set(CMAKE_CXX_STANDARD 17)
add_subdirectory(./level-zero EXCLUDE_FROM_ALL)

add_executable(OneAPIDemo src/main.cpp)
target_include_directories(OneAPIDemo PUBLIC ./level-zero/include)
target_include_directories(OneAPIDemo PUBLIC ./level-zero-npu-extensions)
target_link_libraries(OneAPIDemo ze_loader)
cmake_minimum_required(VERSION 3.10)
project(OneAPIDemo)

set(CMAKE_CXX_STANDARD 17)

add_executable(OneAPIDemo src/main.cpp)
cmake_minimum_required(VERSION 3.10)
project(OneAPIDemo)

set(CMAKE_CXX_STANDARD 17)
add_subdirectory(./level-zero EXCLUDE_FROM_ALL)

add_executable(OneAPIDemo src/main.cpp)
target_include_directories(OneAPIDemo PUBLIC ./level-zero/include)
target_include_directories(OneAPIDemo PUBLIC ./level-zero-npu-extensions)
target_link_libraries(OneAPIDemo ze_loader)

测试程序

为测试 Level Zero 是否能正常工作,我们修改 src/main.cpp

#include <iostream>
#include <ze_api.h>

// 一个辅助检测返回值的宏
#define CHECK(result) \
do { \
auto _result = result; \
if (_result != ZE_RESULT_SUCCESS) { \
std::cerr << "failed at " << __FILE__ << ":" << __LINE__ \
<< "Code: " << _result << std::endl; \
exit(1); \
} \
} while (0)

int main() {
CHECK(zeInit(ZE_INIT_FLAG_VPU_ONLY | ZE_INIT_FLAG_GPU_ONLY));
uint32_t driverCount = 0;
CHECK(zeDriverGet(&driverCount, nullptr));
std::cout << "Driver count: " << driverCount << std::endl;
return 0;
}

如果看到输出 Driver count: 2,则说明 Level Zero 调用成功了!在这个程序中,我们调用了 zeInitzeDriverGet 两个函数。前者用于初始化 Level Zero,后者用于获取当前系统中的驱动数量。由于 Thinkbook 14p 2024 同时搭载了 GPU 和 NPU,Level Zero 能检测到的驱动数为 2

总结

这一章介绍了如何在 VS Code 中搭建 Level-Zero 开发环境,并通过一个简单的程序测试了 Level Zero 的调用。在下一章中,我们将继续介绍 Level Zero 的设计架构和 API,进一步观察 NPU 的功能使用。


折腾 NPU·第0章 —— Intel NPU 概述与 Level-Zero

最近入了台 Thinkbook 14p 2024,CPU 是 Intel Ultra5 125H,内有一枚 3720 NPU 芯片,可用于实现低功耗模型推理。藉此机会我深入了解了 NPU 开发的相关知识,以此系列为记。

NPU vs GPU

NPU 全称为 Neural Processing Unit 即神经处理单元,顾名思义即可优化加速神经网络运算的处理器芯片。在intel-npu-acceleration-library 的文档 中有一小章专门介绍了 NPU 的架构:

The Intel NPU is an AI accelerator integrated into Intel Core Ultra processors, characterized by a unique architecture comprising compute acceleration and data transfer capabilities. Its compute acceleration is facilitated by Neural Compute Engines, which consist of hardware acceleration blocks for AI operations like Matrix Multiplication and Convolution, alongside Streaming Hybrid Architecture Vector Engines for general computing tasks.

此处加粗的部分便是 NPU 相较于其他芯片的亮点,即将神经网络中常见的算子(如矩阵乘法、卷积等)特化至硬件中,从而以较低的功耗高效执行相关运算。

与 GPU 相比,NPU 的特化程度更高,计算通用性更低。开发者可以借助 CUDA 或 SPIRV 为 GPU 编写算子,从而让 GPU 执行任意代码。相比之下,使用 NPU 更像是在搭积木。NPU 预设好了一套基础算子,通过堆叠这些算子开发者得以构建多样化的模型。但 NPU 不支持自定义算子,因此并不是所有模型都能转换成 NPU 上可运行的版本。

NPU 侧重于 AI 模型的推理,而 GPU 即可用于推理也可用于训练。Ultra5 的 NPU 原生支持 FP16 乃至 int8/int4 等低精度运算,从而能够运行量化后的模型,契合当下模型推理的实践。而另一方面,NPU 缺乏对 FP32 的有效支持,在模型训练方面不如 GPU。

NPU 技术栈

如果在网上查找 NPU 编程的相关资料,你会看到各种各样的库或概念,如 OpenVINO、DirectML、ONNX Runtime、Level-Zero、OneAPI 等等。它们分别是什么呢?

我们可以借助上图理解,图中把 NPU 的技术栈分为了三层:

  • 内核态驱动(KMD Driver) 这是离用户最远,也是最接近硬件的部分,运行在操作系统内核态直接与硬件打交道,可以不必深入了解。

  • 用户态驱动(UMD Driver) 这是承上启下的一部分。上层用户通过调用 UMD Driver 提供的 API,可以查询 NPU 硬件的相关信息以及运行模型。对于 Intel NPU 而言,UMD Driver 提供的 API 叫 OneAPI,实现了 OneAPI 的库叫做 Level-Zero。Level-Zero 是用户可访问的最底层的库,它所提供的 API 也是最原始的。

  • 用户库(AI Libraries) 直接使用 Level-Zero 编程虽然可行,但是非常繁琐。因此,各方推出了一些 high-level 的 AI 库,以方便 AI 研究者开发。它们都有如下特点:

    • 支持多种编程语言 大多支持 C++ 和 Python,以方便不同背景的开发者;
    • 统筹多种硬件 提供统一的接口调用 CPU、GPU 和 NPU 资源,并有自己的模型中间语言(IR),从而实现异构计算;
    • 从通用深度学习库编译模型 提供从 PyTorch、Tensorflow 到自己 IR 的模型编译器,以方便训练好的模型在自己生态系统中的推理部署。

    常见的一些库有:

    • DirectML Microsoft 推出的,仅支持 Windows 平台,但是支持多种厂商的硬件;
    • ONNX Runtime ONNX 是一种标准的 AI 模型格式,它的运行时目前也支持了 NPU;
    • OpenVINO Intel 推出的,跨操作系统,但仅支持 Intel 自己的硬件。
                                                          +--> [DirectML]
|
[NPU HW] <-> [KMD/UMD Driver] <-> [Level-Zero] <--OneAPI--+--> [OpenVINO]
|
+--> [ONNX Runtime]

Hacking with Level-Zero

上图总结了 NPU 常见库的相互联系。作为 AI 研究者,了解顶层 AI 库的一种即可满足日常使用。但本系列将深入挖掘更底层的 OneAPI,直接与 Level-Zero 交互。通过这么做我们将探索三个问题:

  1. 直接访问 Level-Zero 是否能构建更轻量级的程序? 我们知道 OpenVINO 等库包含了诸多与实际推理无关的功能,如 PyTorch 到 IR 的编译器、为统筹异构硬件所创建的抽象层等。这些部分方便了日常开发调试,但在程序分发时却成为了负担。一是打包时会增大程序体积或有依赖问题,二是会增大程序首次启动的开销。如果用户只是想在自己特定的 NPU 上运行一个特定的模型,不考虑异构也不考虑硬件版本问题,直接访问 Level-Zero 是否可以进一步优化我们的程序?
  2. Level-Zero 是否有上层 AI 库访问不到的隐藏功能? NPU 及相关生态目前还处于发展早期,部分功能可能处于测试阶段,因而没有在上层暴露出来。比如在目前,并没有 Intel 之外的人成功将 Stable Diffusion 通过 OpenVINO 转换成 NPU 可运行的格式,而在 openvino-ai-plugins-gimp 一库中,Intel 也仅以黑盒的方式提供了这一权重。由此可见,OpenVINO 所能处理的格式与 NPU 真正能运行的格式存在一定差距。
  3. 是否能让 llama.cpp/ggml 使用 NPU? llama.cpp 及其背后的 ggml 将大模型优化到了极致,实现了在低端硬件上的大模型推理,但目前它们尚不支持 NPU。

由于 Level-Zero 靠近底层,网上能找到的文档和例子都很少,因此在后续文章中,我会通过逆向 OpenVINO 的代码来了解 Level-Zero 相关的编程。


新增域名 monad.run

本站将新增一个域名 monad.run,此后旧域名 i.hsfzxjy.site 也将继续保留,与新域名共存,二者皆可用于访问。

新增一个辅助域名,主要考虑到旧域名没有元音不好拼读,不利于读者记忆与传播。此前一位 Reddit 用户 u/Aliics 曾就“hsfzxjy”一名评论道

So many people have problems remembering “xkcd”, but you’ve ramped that up to another level with your username.

就是说,我的用户名如著名的漫画网站“xkcd”一样难记。这当然是戏谑之言,但事实确如此。除了不方便记忆,原域名甚至可能让杀软产生误报。如另一位 Reddit 用户 u/sheepdog69 所说,hsfzxjy.site 被 Bitdefender 列为了钓鱼网站。下面有人追评认为此种行为与域名直接相关,但也只是推测。无论如何,这种潜在的影响也是不利的。

那新域名 monad.run 是怎么选取的呢?我有两方面的考虑。其一当然是经济性。域名说到底只是一个工具,为一个自用的域名花太多钱,有些本末倒置。.run 域名价格普遍在每年100元左右,相对较合理。其二是与站点主题相关。本站发布的多是技术内容,域名多少也要和技术相关。而 Monad 是函数式编程中的一个重要概念,了解 Haskell 的读者应该较为熟悉。run 又有“运行”之意,monad.run 便是一个不错的选择。哪怕是不熟悉 Haskell 或是非技术领域的读者,monad 的拼写也与其预期读音高度一致,从而易于记忆,其也不算过于常见的单词,不致落入俗套。

在部署方面,目前仅是将 monad.run 302 跳转为 i.hsfzxjy.site。本来最理想的情况是将二者皆 CNAME 到 GitHub Pages,奈何 GitHub Pages 不支持多重 CNAME,只好用此折衷方案。未来若有更好的解决方案,将会更新这一架构。


CSS 中为特定字符设置不同字体

为追求更好的阅读体验,本站的中文文章采用了混合字体的排版方式。例如在 Windows 环境中,中文字符以 Microsoft Yahei 渲染,而英文字符则使用 Open Sans 渲染。由于 Microsoft Yahei 中也包含英文字符的字形,其在 font-family 中的优先级需排在 Open Sans 后面,从而保证 Open Sans 能够正确渲染英文字符。大致的代码如下:

[lang="zh"] {
font-family: "Open Sans", "Microsoft Yahei", var(--font-fallback);
}

但这种方式有一个问题,即标点符号的字形比较难看。具体而言是弯引号 U+201C U+201D ,它们的字形无论在 Microsoft Yahei 或是 Open Sans 中都是半角宽度,且头部和尾部粗细区分不明显,难以辨别前后。反观宋体 SimSun 的呈现则更为清晰,更符合中文排版的习惯。

Read More

Arbitary Lifetime Transmutation via Rust Unsoundness

Do you believe that one can write a function in safe Rust which can arbitrarily change the lifetime of a reference? For clarity, try to implement the following function without using unsafe:

fn transmute_lifetime<'a, 'b>(x: &'a u64) -> &'b u64 { todo!() }

Such intention seems to violate the fundamental principle of Rust’s lifetime system, which is to prevent dangling references and data. However, it is possible to write such a function in safe Rust, and it is not even that hard:

trait Trait {
type Output;
}

impl<T: ?Sized> Trait for T {
type Output = &'static u64;
}

fn foo<'a, T: ?Sized>(x: <T as Trait>::Output) -> &'a u64 { x }

fn transmute_lifetime<'a, 'b>(x: &'a u64) -> &'b u64 {
foo::<dyn Trait<Output=&'a u64>>(x)
}
Read More

Dijkstra 算法的延伸

我们知道 Dijkstra 算法是一个高效的单源最短路径(SSSP)算法,本文将不再赘述他的细节。但同时,Dijkstra 也是一个动态规划算法。Dijkstra 算法的正确性源自无负边权图的若干性质。如果一个问题本身也满足这些性质,那么即使它不是一个图论最短路径问题,也可以使用 Dijkstra 算法解决。那么,这些性质是什么呢?

Read More

Manacher 回文计数算法

以下假设字符串下标从 $0$ 开始,子串记号 $s[i..j]$ 左闭右闭。

给定长度为 $n$ 的字符串 $s$,Manacher 算法可以在 $O(n)$ 的时间复杂度内找到 $s$ 的所有回文子串。

我们先以寻找长度为奇数的子串为例。首先需要明确的是,如果 $s$ 中以第 $i$ 个字符为中心的最长回文子串长度为 $d=2p+1$,则以下皆为 $s$ 的回文子串:

$$s[i-(p-1)..i+(p-1)],\ldots, s[i-1..i+1], s[i..i]$$

因此,我们只需对所有下标 $i$ 求解出以 $s[i]$ 为中心的最长回文子串长度 $2p_i+1$,即可知道 $s$ 的所有回文子串。

Read More

硬卧

我醒了,闹钟仍未响,窗外却已见鱼肚白。从帽子里掏出手机,才六点出头。

硬卧的床仿佛是量身定制的,不宽不窄,恰能容下一个我。我睡前戴上了连衣的帽子,将手机放在帽子里,既防止手机从上铺跌落,也不怕错过了闹钟——现在看来是多余了。我小心翼翼地侧过身,对面的人还在熟睡。

应该快到站了。十三个小时前的傍晚,我才乘高铁来到上海,来看一场期待已久的演唱会。十三个小时后的清晨,我又乘着绿皮车,离开上海回到这里。计划本就是如此,无意多作停留。

Read More

Go Fact: Zero-sized Field at the Rear of a Struct Has Non-zero Size

There’s a concept in Golang called zero-sized type (or ZST), namely, a type whose variables take up zero bit of memory. One of them is the famous struct{}. People often use map[string]struct{} to efficiently emulate a set structure. Others include zero-length arrays such as [0]int, albeit not very common, are adopted to enforce some properties of a customized type.

Read More

Display *big.Rat Losslessly and Smartly in Golang

Floating-point numbers, as we know, are notorious for losing precision when their values become too large or too small. They are also bad at representing decimals accurately, yielding confusions like 0.1 + 0.2 != 0.3 for every beginner in their programming 101.

Albeit being imprecise, floats are good enough for most daily scenarios. For those not, however, Golang provides *big.Rat to the rescue. Rats are designed to represent rational numbers with arbitary precision, addressing most flaws of floats, yet at a cost of much slower computation speed and bigger memory footprint. For example, we are confident to compare 0.1 + 0.2 to 0.3 using Rats without caring about tolerance:

Read More