Vulkan Tutorial

1-1 - Device List

Vulkan is a low-level, low-overhead API for 3D graphics and computing. Currently, it is probably the most important APIs in its area.

Vulkan history and design

Vulkan 1.0 was released in 2016. It is almost 25 years after the first version of OpenGL. On the beginning of 90', graphics cards were - if we simplify it - only a piece of memory with monitor output. Whatever was written to the memory appeared on the screen. Today in 2024, a main stream graphics card is programmable, massively parallel compute unit. When comparing computing power, it might outperform tens, hundreds, or even thousands of traditional processors in extreme cases. Vulkan is designed with the focus on effective use of the performance potential that is hidden in the graphics cards of today.

The other view: 25 years before Vulkan 1.0, almost all computers had only one processor capable of executing a single thread at a time. This corresponds to a single active OpenGL context per thread. This was logical design for these old times, but it does not fit well in our reality. Today, standard computer contains multi-core processor capable of executing many threads simultaneously. Executing of tens of threads in parallel is not an exception. No wonder that Vulkan is designed to be able to take advantage of multi-threaded programming and parallel processing by many cores of the processor. And not only multi-core processing is in Vulkan design focus, but also multiple graphics cards can be handled by Vulkan natively.

OpenGL has high overhead for some operations and its driver is very complex. On the other side, Vulkan is low-level API with low overhead and relatively simple driver. Simple driver usually results in much less driver bugs and better driver and system stability.

OpenGL is platform neutral, but faced difficulties anyway. On macOS, it was always number of versions behind the standard. On mobile devices, usually only OpenGL ES was supported. When looking on Vulkan, it is supported on macOS (through MoltenVk) and majority of modern tablets and mobile phones.

Vulkan is amazing by its low-level approach, because we can work very efficiently with underlying hardware and optimize our code in many details. However, it has also its downsides. Few lines of OpenGL code might need tens or even hundreds lines of code when using Vulkan. Low-level Vulkan code brings new flexibility. But many programmers might feel unprepared to deal with such amount of low-level code and to understand it. And exactly to this situation comes this tutorial - to make an attempt to help and to introduce a programmer into basics of Vulkan programming.

In our tutorial, we will use modern C++20 or newer. Normally, we would use Vulkan C++ binding called Vulkan-Hpp. However, Vulkan-Hpp has some design limitations which advanced users might struggle with from time to time, but most importantly, the current version 1.3.283 takes about quarter of million lines of code that must be included and processed by the compiler. It takes roughly about 3-4 seconds to process such large headers by a modern processor of today. Another Vulkan-Hpp limitation is memory overhead of Unique_* and raii classes. Both mentioned issues are solved by vkg Vulkan binding. Vkg means Vulkan generated. Being generated, its another advantage is that not used parts of the API can be switched off and omitted from vkg.h and vkg.cpp. This results in a smaller API, a little smaller output binary, and usually less function pointers which, in turn, results in a little better cache effectivity.

Now a little controversial thing: C++ exceptions. While not being expert on C++ effectivity, my experiments show that using exceptions results in higher application performance and make code shorter and more readable. The reason seems to be simple: The use of exceptions removes much of the error handling logic from our code. The error status does not need to be returned from subrutine to current rutine and from the current rutine to parent rutine. And each routine level might require some condition logic to handle error conditions, incuring extra overhead even if no error was returned. Having exceptions, all this error condition logic and overhead might be removed, making the code faster and more readable. Instead of the error handling code, the task is handled by exceptions. Many compilers implement them by tables that control the process of exception handling. In other words, the error handling functionality is moved from our code into the tables, making our code faster and cleaner. The tables might make executables a little bigger, some say by 20%, but what usually matters is performance and speed of development.

Other people might argue that raising of an exception is very slow, thus exceptions shall not be used. My opinion is following: exception is related to the word exceptional. So, it is raised in exceptional situations like write failed because disk is full or Vulkan command failed because operating system restarted GPU driver. Such situations may be handled by showing message dialog and by terminating of the application. Who cares that the message dialog appeared few microseconds later? The system will probably wait couple of seconds anyway before the user clicks Ok. Or, who cares that an application termination took couple of microseconds more? What usually interests us is application performance and code readability, not the handling of exceptional circumstances that are often handled by application termination or other serious actions.

The first application: Instance and listing of physical devices

The code for all the examples can be downloaded from git repository https://github.com/Vulkan-FIT/VulkanTutorial.

Our first Vulkan application will print names of all Vulkan physical devices installed in the given system. We will do it in five steps:

    vk::loadLib() - loads Vulkan library vk::createInstance() - creates global Vulkan instance object vk::enumeratePhysicalDevices() - gets list of physical devices vk::getPhysicalDeviceProperties() - gets properties of each physical device print device name

And we have to wrap our code to catch exceptions if one is raised:

int main(int, char**)
{
	// catch exceptions
	// (vk functions throw if they fail)
	try {

		// load Vulkan library
		vk::loadLib();

		// Vulkan instance
		vk::createInstance(
			vk::InstanceCreateInfo{
				.sType = vk::STRUCTURE_TYPE_LOADER_INSTANCE_CREATE_INFO,
				.pNext = nullptr,
				.flags = 0,
				.pApplicationInfo =
					&(const vk::ApplicationInfo&)vk::ApplicationInfo{
						.sType = vk::STRUCTURE_TYPE_APPLICATION_INFO,
						.pNext = nullptr,
						.pApplicationName = "1-1-DeviceList",
						.applicationVersion = 0,
						.pEngineName = nullptr,
						.engineVersion = 0,
						.apiVersion = vk::apiVersion1_0,
					},
				.enabledLayerCount = 0,
				.ppEnabledLayerNames = nullptr,
				.enabledExtensionCount = 0,
				.ppEnabledExtensionNames = nullptr,
			});

		// print device list
		vk::Vector<vk::PhysicalDevice> deviceList = vk::enumeratePhysicalDevices();
		cout << "Physical devices:" << endl;
		for(size_t i=0; i<deviceList.size(); i++) {
			vk::PhysicalDeviceProperties p = vk::getPhysicalDeviceProperties(deviceList[i]);
			cout << "   " << p.deviceName << endl;
		}

	// catch exceptions
	} catch(vk::Error& e) {
		cout << "Failed because of Vulkan exception: " << e.what() << endl;
	} catch(exception& e) {
		cout << "Failed because of exception: " << e.what() << endl;
	} catch(...) {
		cout << "Failed because of unspecified exception." << endl;
	}

	return 0;
}
#include 
#include 

using namespace std;


int main(int, char**)
{
	// catch exceptions
	// (vulkan.hpp functions throw if they fail)
	try {

		// Vulkan instance
		vk::UniqueInstance instance(
			vk::createInstanceUnique(
				vk::InstanceCreateInfo{
					vk::InstanceCreateFlags(),  // flags
					&(const vk::ApplicationInfo&)vk::ApplicationInfo{
						"1-1-DeviceList",         // application name
						VK_MAKE_VERSION(0,0,0),  // application version
						nullptr,                 // engine name
						VK_MAKE_VERSION(0,0,0),  // engine version
						VK_API_VERSION_1_0,      // api version
					},
					0,        // enabled layer count
					nullptr,  // enabled layer names
					0,        // enabled extension count
					nullptr,  // enabled extension names
				}));

		// print device list
		vector deviceList = instance->enumeratePhysicalDevices();
		cout << "Physical devices:" << endl;
		for(vk::PhysicalDevice pd : deviceList) {
			vk::PhysicalDeviceProperties p = pd.getProperties();
			cout << "   " << p.deviceName << endl;
		}

	// catch exceptions
	} catch(vk::Error& e) {
		cout << "Failed because of Vulkan exception: " << e.what() << endl;
	} catch(exception& e) {
		cout << "Failed because of exception: " << e.what() << endl;
	} catch(...) {
		cout << "Failed because of unspecified exception." << endl;
	}

	return 0;
}

The first thing that might catch our eyes in the code is large initialization of structures vk::InstanceCreateInfo and vk::ApplicationInfo. And that is the way Vulkan uses to communicate most of the data: through structures. We fill the data into the structures and pass them through Vulkan functions. Most of the structures have sType (structure type) member indicating type of the structure and pNext member. The pNext member must be null or it must point to another valid Vulkan structure which might be a structure from a new extension or a kind of a new functionality.

The strange type cast &(const vk::ApplicationInfo&)vk::ApplicationInfo{ just avoids the harmless warning about using pointer to temporary variable.

Documentation to all the structures and the whole Vulkan can be found on Khronos website. I recommend the latest Vulkan specification while there are three versions - core API only, core + ratified extensions and core + all published extensions. I recommend core + ratified extensions, or core only specification. Core + all published extensions contains much of content related to various vendor extensions which might be misleading and might make it more difficult to understand Vulkan.

Detailed code description

vk::loadLib() - loads Vulkan library. The function loads vulkan-1.dll on Windows and libvulkan.so.1 on Linux. Then, it initializes some global Vulkan function pointers.

vk::createInstance() - creates Vulkan instance. Instance stores all global per-application state. Before we can use Vulkan, Vulkan Instance must be created. There are only few functions that can be used before creating Vulkan Instance. Once it is created, we can retrive it using vk::instance() function.

To create instance, we need to fill vk::InstanceCreateInfo structure with data. We have to set sType and pNext members. The flags member is empty. The first interesting member is pApplicationInfo. It is pointer to vk::ApplicationInfo structure. So, we create vk::ApplicationInfo and fill it with the data as well.

Again, we initialize sType and pNext members. The pApplicationName is the name of our application. Together with applicationVersion it can be used to optimize the driver for particular application or application version, or even to workaround problems with particular application or its version. We provide application name but we will supply 0 for the application version.

The same idea stands behind pEngineName and engineVersion. The driver might turn on optimizations or workarounds for particular engine or engine version. We do not use any engine, so we pass null and zero.

The last member is apiVersion. It is the highest version that our application might use. To put it in other words, our application makes promise here to not use higher version that it specifies here. The driver might not support so high version, but this does not result in error. The driver supported version is reported elsewhere. Here it is about the promise of the application to not use functionality of higher version of Vulkan than it specifies in apiVersion member.

Now, we return back to vk::InstanceCreateVersion and to its last four members. They are related to layers and extensions. We will speak about layers and extensions on other occasions. For now, because we are not using any layers and any extensions, we just set them to null and their count to zero.

vk::enumeratePhysicalDevices() - returns the list of physical devices available in the system. Each physical device is represented by vk::PhysicalDevice handle.

We can download the source code, configure it by CMake, compile it and run it. I recommend CMake 3.21 or newer. The code was tested with g++ compiler on Linux and Microsoft Visual C++ 2022. We also need Vulkan drivers installed on the target system.

When we run the code we might see the following output:

Physical devices:
   NVIDIA GeForce RTX 4090
   AMD Radeon RX 7900 XTX
   Intel(R) UHD Graphics 630
   llvmpipe (LLVM 15.0.7, 256 bits)

We can see number of graphics cards. Depending on our system, we will usually see one or two devices. Here, we see Nvidia and AMD graphics as first two graphics cards. So, the user is probably developer who wants to test his applications on various graphics cards and various vendors. There is also integrated Intel graphics. The last one is llvmpipe that I often see on Linux. It is software implementation of Vulkan. It is useful on some special circumstances.

If you see some error instead of graphics card list, there might be some problem on the computer. We shall fix it in the next article.