この記事は新歓ブログリレー2023 50日目の記事です。
こんにちは。19Bのkegraです。今回はVulkan API対応のデバイスドライバを作っていきます。Vulkan-LoaderからICDとして読み込めます。環境はWindowsです。
本当はFPGAとかでちゃんと動くGPUを自作してそれを使える感じにしたかったのですが、さすがにそんな技術ないので、ソフトウェア的にとりあえず読み込めるだけのドライバです。よしなに。
Vulkanって何
VulkanとはKhronos Groupの定めている低レイヤ用のグラフィックスAPIであり、OpenGLの後継です。Unityとかからも使われています。
なに、OpenGLしか使ったことない?遅れてるよキミ。最近のナウでヤングな若者はみんなVulkan使ってるから覚えておきな。
なに、DirectXしか知らない?貴様、Microsoftのイヌだな。衛兵、連れていけ!
ちなみにVulkanはグラフィックスAPIというかGPUの操作APIなので、最近流行りのAI関連でもそれなり利用されています。いわゆるGPGPUですね。
ん?おい貴様、「GPUの低レイヤ操作と言ったらCUDAじゃないの」みたいな目をしたな。このNVIDIA信者め、クロスプラットフォームの理想を理解しないヤツは徹底的に教育を施してやる。
Vulkan-LoaderとICDについて
Vulkan APIの実体としてはこれです。「こういうC言語関数があって、こういう挙動をする」というのが大量に規定されています。GPUを開発する各社はこれに従って自社GPUに対応したドライバを開発するわけですね。各社ったって2~3社くらいしかいねーじゃねーかアホ![1]
とにかくこれに従ってそのようなC言語関数を提供するライブラリを作ればとりあえず「Vulkan対応ドライバ」は名乗れる訳ですが、ここで1つ問題。1つのPCに異なる会社の複数のGPUが刺さっていた場合どうなるでしょう?
ある会社の開発したドライバがその会社の開発したGPUしか扱えないとするならば、最初にどのドライバを読み込むかによって使うGPUは決定されてしまいます。アプリケーション側で利用するGPUを選択するのは困難です。これでは困りますね。
そこに颯爽と現れるのがVulkan-Loader![2]
Vulkan-LoaderはICD(Installable Client Driver)という概念によって上記の問題を解決します。Vulkan-Loaderの提供するシナリオはこうです。
まず、アプリケーションは各社の提供するVulkanドライバを直接触るのではなく、Vulkan-Loaderを読み込む。各社はVulkan-Loaderから読み込める形式でドライバを提供する(これがICD)。Vulkan-Loaderはシステムにインストールされた各社のVulkan ICDドライバを読み込み、それらを合わせてアプリケーションに提供します。
例
例えば、Vulkanの基本的なAPIとしてvkEnumeratePhysicalDevices()
という関数があります。これはシステムで利用できるGPUを列挙するAPIです。例えばA社のGPUが1つ、N社のGPUが2つ刺さっているシステムがあるとしましょう[3]。このとき、A社のVulkanドライバでこの関数を呼べばA社のGPU1つの情報が返ってくるでしょうし、N社のVulkanドライバでこの関数を呼べばN社のGPU2つの情報が返ってくるでしょう。
Vulkan-Loaderはこの2社のVulkanドライバを同時に読み込み、アプリケーションは各社のVulkanドライバではなくVulkan-Loaderを読み込みます。アプリケーションがvkEnumeratePhysicalDevices()
を呼ぶとき、Vulkan-Loaderは2社のドライバの返す結果をミックスして返し、アプリケーションはA社とN社の計3つのGPUが認識できるはずです。
つまり何が言いたいかっていうと、Vulkan APIをICDの形式で提供することで他のデバイスと衝突せず平和に共存できるってワケ。つまりI(イカした)C(クールな)D(ドライバー)ってことさ。カッコいいだろ?
~Vulkan ICDドライバの作り方~
本題に入りましょう。Vulkan ICDドライバを作っていきます。以下は手順です。
1. Vulkan APIを完全理解する
仕様書がこちらにあるので、完全理解します。できましたか?私はできませんでした。
まあ大体理解できたので良いということにしましょう。
2. Vulkan-Loaderのドライバ作成時マニュアルを全部読む
これとこれ。こちらは頑張れば読める程度の分量なので読みましょう。
3. ドライバファイルを配置するディレクトリを作る
あとでレジストリにパスを指定するので多分場所はどこでも良いんだとは思いますが、一応Cドライブ直下にしてみます。
mkdir C:\test_vk_icd
4. マニフェストファイルを作る
JSONで書きます。低レイヤAPIなのに近代的ですね。[4]
/c/test_vk_icd/test_vk_icd.json{
"file_format_version" : "1.0.1",
"ICD": {
"library_path": ".\\test_vk_icd.dll",
"api_version": "1.2.205",
"library_arch": "64",
"is_portability_driver": false
}
}
library_path
に後で作るDLLのパスを書きます。ここではマニフェストファイルと同じディレクトリにDLLを入れる想定なので.\\
で始めています。
マニフェストファイルとDLLの組を複数用意すれば32ビット/64ビットの両対応などもできるようで、実際IntelのVulkan ICDドライバを見てみるとigvk64.dll
igvk64.json
とigvk32.dll
igvk32.json
の両方が入っています。
ここでは64ビットだけの対応で良いでしょう。今時32ビットで開発してるやついる?いねえよなあ!??
5. ドライバのバイナリを作る
Windowsの場合DLL形式です。
全てのVulkan API関数を実装するのは無茶なので、一部のAPI関数+ICDとして必須ないくつかの関数だけ実装します。
マニュアルによれば[5]、以下の2つの関数の実装が必須です。
vk_icdGetInstanceProcAddr
vk_icdNegotiateLoaderICDInterfaceVersion
今回のゴールは「ちゃんと動くGPU自作」ではなくあくまで「Vulkan-Loaderからデバイスとして認識されること」なので、細かい実装はかなり妥協します。
src/main.cpp#define VK_USE_PLATFORM_WIN32_KHR
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <vector>
#include <vulkan/vk_icd.h>
struct VkInstance_T {};
struct VkPhysicalDevice_T {
int id;
char devicename[32];
};
std::vector<const char *> instanceExtList = {};
std::vector<VkPhysicalDevice_T> physicalDeviceList = {VkPhysicalDevice_T{1, "My Vulkan Device"}};
VKAPI_ATTR VkResult VKAPI_CALL vkCreateInstance(const VkInstanceCreateInfo *pCreateInfo, const VkAllocationCallbacks *pAllocator, VkInstance *pInstance) {
auto p = new (std::nothrow) VkInstance_T;
if (p == NULL)
return VK_ERROR_INITIALIZATION_FAILED;
*pInstance = static_cast<VkInstance>(p);
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL vkDestroyInstance(VkInstance instance, const VkAllocationCallbacks *pAllocator) {
delete instance;
return;
}
VKAPI_ATTR VkResult VKAPI_CALL vkEnumerateInstanceExtensionProperties(const char *pLayerName, uint32_t *pPropertyCount, VkExtensionProperties *pProperties) {
if (pProperties == nullptr) {
*pPropertyCount = instanceExtList.size();
return VK_SUCCESS;
}
for (size_t i = 0; i < *pPropertyCount && i < instanceExtList.size(); i++) {
std::strcpy(pProperties[i].extensionName, instanceExtList[i]);
pProperties[i].specVersion = 1;
}
return VK_SUCCESS;
}
VKAPI_ATTR VkResult VKAPI_CALL vkEnumeratePhysicalDevices(VkInstance instance, uint32_t *pPhysicalDeviceCount, VkPhysicalDevice *pPhysicalDevices) {
if (pPhysicalDevices == nullptr) {
*pPhysicalDeviceCount = physicalDeviceList.size();
return VK_SUCCESS;
}
for (size_t i = 0; i < *pPhysicalDeviceCount && i < physicalDeviceList.size(); i++)
pPhysicalDevices[i] = &physicalDeviceList[i];
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL vkGetPhysicalDeviceProperties(VkPhysicalDevice physicalDevice, VkPhysicalDeviceProperties *pProperties) {
pProperties->apiVersion = VK_API_VERSION_1_2;
pProperties->driverVersion = VK_MAKE_VERSION(0, 1, 0);
pProperties->deviceID = physicalDevice->id;
pProperties->deviceType = VK_PHYSICAL_DEVICE_TYPE_VIRTUAL_GPU;
strcpy(pProperties->deviceName, physicalDevice->devicename);
}
VKAPI_ATTR VkResult VKAPI_CALL vkEnumerateInstanceVersion(uint32_t *pApiVersion) {
*pApiVersion = VK_API_VERSION_1_2;
return VK_SUCCESS;
}
VKAPI_ATTR VkResult VKAPI_CALL vk_icdNegotiateLoaderICDInterfaceVersion(uint32_t *pVersion) { return VK_SUCCESS; }
VKAPI_ATTR VkResult VKAPI_CALL vk_dummy() {
std::ofstream ofs("C:\\my_vk_icd\\log.txt", std::ios_base::app);
ofs << "dummy function called" << std::endl;
return VK_ERROR_UNKNOWN;
}
VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL vk_icdGetInstanceProcAddr(VkInstance instance, const char *pName) {
if (strcmp(pName, "vkCreateInstance") == 0)
return reinterpret_cast<PFN_vkVoidFunction>(vkCreateInstance);
if (strcmp(pName, "vkDestroyInstance") == 0)
return reinterpret_cast<PFN_vkVoidFunction>(vkDestroyInstance);
if (strcmp(pName, "vkEnumerateInstanceExtensionProperties") == 0)
return reinterpret_cast<PFN_vkVoidFunction>(vkEnumerateInstanceExtensionProperties);
if (strcmp(pName, "vkEnumeratePhysicalDevices") == 0)
return reinterpret_cast<PFN_vkVoidFunction>(vkEnumeratePhysicalDevices);
if (strcmp(pName, "vkGetPhysicalDeviceProperties") == 0)
return reinterpret_cast<PFN_vkVoidFunction>(vkGetPhysicalDeviceProperties);
if (strcmp(pName, "vkEnumerateInstanceVersion") == 0)
return reinterpret_cast<PFN_vkVoidFunction>(vkEnumerateInstanceVersion);
std::ofstream ofs("C:\\my_vk_icd\\log.txt", std::ios_base::app);
ofs << "unimplemented function: " << pName << std::endl;
return reinterpret_cast<PFN_vkVoidFunction>(vk_dummy);
}
VkInstance_T/VkPhysicalDevice_T
ドライバ自作して初めて知ったんですが、VulkanにおけるVkHogehoge
みたいな型ってVkHogehoge_T*
として定義されてるっぽいんですよね。構造体の中身はドライバ開発者が自由に決めれます。
vkCreateInstance/vkDestroyInstance
C++なのでnew
でメモリ確保してるわけですが、VulkanはC言語APIである以上外に例外を出すわけには行きません。そこでnew (std::nothrow)
を使いました。そうそう使わないですよねこれ。
まあ普通にmalloc
/free
でも良いと思います。
vkEnumerateInstanceExtensionProperties
ドライバがインスタンスレベルでサポートする拡張機能を返すAPIです。Mixed Driver Instance Extension Supportの項にある通り、Vulkan-Loaderはインスタンスレベル拡張にも対応しています。ここでは特に何も拡張機能をサポートしていないという情報を返しています。
色々動作確認してみた結果、インスタンスレベルだとVulkan Specificationsに名前の書いてある拡張機能以外は弾かれるようです。VK_HOGE_extension
みたいなオレオレ拡張機能をサポートするという情報を返してもアプリケーション側から見えませんでしたが、VK_NV_external_memory_capabilities
をサポートしているということにしたら有効な拡張機能として検出されました。オープンな規格としてそれでいーのか??(拡張機能は自由平等であるべきでは?)とちょっと思いますが。
vkEnumeratePhysicalDevices/vkGetPhysicalDeviceProperties
物理デバイスの情報を返すAPIです。My Vulkan Device
というデバイスをサポートしているという情報を返しています。
vk_icdNegotiateLoaderICDInterfaceVersion
Vulkan-Loaderから呼び出されてドライバのインターフェースバージョンに関する情報をやり取りする関数です。ICDとVulkan-Loaderの間のインタフェースにはv0~v7までのバージョンがあり、どれをサポートしているかに関する情報を前もって打ち合わせる形になっています。
詳しくはLoader and Driver Interface Negotiationを読みましょう。
まあ、端的に言えば「サポートしてないバージョンだったら戻り値とか引数の変数とかで教えてね!」というやつなのですが、めんどいんでなんもしてません。
vk_icdGetInstanceProcAddr
Vulkan-Loaderから呼び出されてAPI関数へのポインタを返す関数です。一番重要。
本当はvk_icdGetInstanceProcAddr
においてあらゆるVulkan API関数の関数ポインタを返さなければいけないのですが、実装していない関数はダミーの関数でお茶を濁しました。実際に呼び出されることが無ければ大丈夫なはずです。なんだこれは。
呼び出し規約は呼び出された側で後始末をする__stdcall
なので、多分実際に呼び出されたらがっつりスタックが破壊されて死ぬと思います。なんてでたらめなんだ。[6]
残りはビルドのためのこまごまとしたファイルです。
src/exports.defEXPORTS
vk_icdNegotiateLoaderICDInterfaceVersion
vk_icdGetInstanceProcAddr
CMakeLists.txtcmake_minimum_required(VERSION 3.26)
project(test_vk_icd)
find_package(Vulkan REQUIRED)
file(GLOB_RECURSE cppfiles RELATIVE ${PROJECT_SOURCE_DIR} ./src/*.cpp)
add_library(test_vk_icd SHARED ${cppfiles} src/exports.def)
target_include_directories(test_vk_icd PRIVATE ${Vulkan_INCLUDE_DIRS})
最近知ったんですが、.def
ファイルも普通にCMakeから使えるんですね...
6. ファイルを配置する
こんな感じで。
7. レジストリに登録する
マニュアルによれば[7]、\HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\Drivers
にDWORD値をぶっこめばいいらしいです。値のキー名にICDドライバのマニフェストファイルのパスを指定します。これでドライバとして認識されます。
DWORD値を0にするとドライバは有効、1にすると無効になります。
レジストリとかいう掃き溜めに手を突っ込むのヤなので環境変数で指定する方法[8]を先に試したのですが、なんか上手く行きませんでした。なんで??
自作したドライバの試用
こんな感じのコードで動作検証します。
#include <iostream>
#include <vector>
#include <vulkan/vulkan.h>
int main() {
VkInstance instance;
VkInstanceCreateInfo createInfo = {};
createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
VkApplicationInfo appInfo{};
appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO;
createInfo.pApplicationInfo = &appInfo;
VkResult result;
result = vkCreateInstance(&createInfo, nullptr, &instance);
if (result != VK_SUCCESS) {
std::cout << "failed to create instance" << std::endl;
return -1;
}
uint32_t count;
vkEnumeratePhysicalDevices(instance, &count, nullptr);
std::vector<VkPhysicalDevice> physicalDevices(count);
vkEnumeratePhysicalDevices(instance, &count, physicalDevices.data());
std::cout << "Physical Device Num: " << physicalDevices.size() << std::endl;
for (const auto &physicalDevice : physicalDevices) {
VkPhysicalDeviceProperties prop;
vkGetPhysicalDeviceProperties(physicalDevice, &prop);
std::cout << " " << prop.deviceName << std::endl;
}
vkDestroyInstance(instance, nullptr);
return 0;
}
こんな出力結果になりました。
Physical Device Num: 2
NVIDIA GeForce RTX 3060 Ti
My Vulkan Device
見事に「My Vulkan Device」の名前が認識されました!!!!きもてぃいいいいいいい
vkEnumeratePhysicalDevices
で自作ドライバによるデバイス情報がバッチリ読み込まれています!
NvidiaのGPUに並んで自作のデバイスが並んでるの最高ですね。めっっっちゃ興奮しないですか???ほぼイキかけました。
それはそれとして
認識されたのは良いんですが、当然中身は既に見たようにほぼ空っぽなので何にもできないです。ちゃんとして見えるのは外面だけ...みんなはこんな大人になっちゃだめだょ...[9]
今後の展望
とりあえずVulkanからデバイスとして認識されるという第一目標は達したので、ちょっとづつステップアップしてガチのGPUを作ってみたいですね。何年かかるか分かりませんが。
- 第二目標: Vulkan APIを実装してソフトウェア的にちゃんと動くGPUを作る
- 第三目標: 処理をFPGAで実装し、USBやPCIeなどでPCと繋ぎそれと通信する形にする
第四目標: ASIC化[10]
やることは割と明確なんですが道のりが長い!
あとがき
traPは(一応)メインはゲーム制作サークルです。自分もゲームを作ることを目的としてこのサークルに入りました。
まあ、ゲームエンジン沼にハマってしまったんですけどね。自分は「ゲームの動く仕組み」を放っておけない性質の人間だったようです。
かつて電子ゲームというのは、今よりももっとハードウェアと密接に結びついて切り離せないものでした。ファミコンのソフトはファミコンのソフト。スーファミのソフトはスーファミのソフト。移植には相応のコストがかかり、完全再現は土台無理なのが普通でした。
それから時代は流れ、少しずつアプリケーション開発者はハードウェアを気にしなくて良いようになっていきます。OS、あるいは仮想マシン、あるいはゲームエンジンなど、あの手この手で下の層は覆い隠されていきます。これはアプリケーション開発者にとっては朗報でした。機種の異なるハードでも、こうした抽象化レイヤによって対応されているハードならばどこでも自作のアプリが動くからです。
一方ハードウェア開発者にとってはそこまでの恩恵はなかったのではないでしょうか。なぜならば自作のハードが対応してもらえるかどうかはこうした抽象化レイヤの開発者のさじ加減だからです。抽象化レイヤを作る側に働きかけるか、あるいは参画するか、そうでなければ自作のハードの上にソフトウェアスタックを0から建ててしまうかしかありません。自力で対応させる手もありますが、世の中全てが自由ソフトウェアではないでしょう。
しかし今、ことGPUに関しては、Vulkan APIという共通のオープンなインターフェースがあります。そしてこの規格はどんどん版図を広げています。このインタフェースに従っていればどんなアプリもどんなデバイスの上でも動くのです。例えるならばこのWebサイトがHTTPとHTML/CSSという規格に従っているゆえにChromeでもFireFoxでもSafariでも見れるのと同じこと。それはとても素敵なことではないでしょうか。
しかし、私たち一般人の手元で使われるGPUを最前線で開発しているのはわずか数社だけです。僕にはそれがとても勿体ないことのように思えます。
自作のGPUの上で市販のゲームが動いたらめっちゃロマンがないですか?
興味がある人はぜひやってみましょう。というか仲間が欲しい...一人でやれる作業量じゃない...
この記事で書いたソースコード
こちらで公開しています。
https://github.com/n-kegra/test_vk_icd
明日は@Uzakiさんと@H1rono_Kさんの記事です! お楽しみに~
NVIDIAとAMD。おまけでIntel ↩︎
OSSとしてgithubで公開されている。https://github.com/KhronosGroup/Vulkan-Loader ↩︎
そんなシステムあるかボケって人は、型番末尾にFが付いていないI社のCPUとN社のGPUが刺さったシステムとかに脳内置換してください。お前のPCめっちゃ光ってそうだな。 ↩︎
最近はC/C++でも普通にJSON扱うライブラリがあるので、驚く話でもないのかもしれない ↩︎
https://github.com/KhronosGroup/Vulkan-Loader/blob/main/docs/LoaderDriverInterface.md#windows-linux-and-macos-driver-negotiation ↩︎
といいつつ、実際やってみたら呼ばれても動作してしまったので、どっかにフェールセーフ機構とかがあったりするのかもしれない ↩︎
https://github.com/KhronosGroup/Vulkan-Loader/blob/main/docs/LoaderDriverInterface.md#driver-discovery-on-windows ↩︎
同マニュアルによれば環境変数
VK_DRIVER_FILES
で指定すればいいらしい。認識されなかったが??? ↩︎でもね、外面がちゃんとしてないといくら中身があっても誰にも見てもらえないんだ!悲しいね! ↩︎
ASICとはApplication Specific Integrated Circuitの略。特定用途に特化したICの意。市販の機器のICは大体そう。書き換え可能な「柔らかいハードウェア」であるFPGAで試作し、それで設計が固まったらASICとして量産するというのが一般的。 ↩︎