The Device 🟒¢

Resulting code: step010-next

A WebGPU device represents a context of use of the API. All the objects that we create (geometry, textures, etc.) are owned by the device.

The device is requested from an adapter by specifying the subset of limits and features that we are interesed in. Once the device is created, the adapter is generally no longer used: the only capabilities that matter to the rest of the application are the one of the device.

Device requestΒΆ

Helper functionΒΆ

Requesting the device looks a lot like requesting the adapter, so we will start from a similar function. The key differences lie in the device descriptor, which we detail below.

/**
 * Utility function to get a WebGPU device, so that
 *     WGPUDevice device = requestDeviceSync(adapter, options);
 * is roughly equivalent to
 *     const device = await adapter.requestDevice(descriptor);
 * It is very similar to requestAdapter
 */
WGPUDevice requestDeviceSync(WGPUInstance instance, WGPUAdapter adapter, WGPUDeviceDescriptor const * descriptor) {
    struct UserData {
        WGPUDevice device = nullptr;
        bool requestEnded = false;
    };
    UserData userData;

    // The callback
    auto onDeviceRequestEnded = [](
        WGPURequestDeviceStatus status,
        WGPUDevice device,
        WGPUStringView message,
        void* userdata1,
        void* /* userdata2 */
    ) {
        UserData& userData = *reinterpret_cast<UserData*>(userdata1);
        if (status == WGPURequestDeviceStatus_Success) {
            userData.device = device;
        } else {
            std::cerr << "Error while requesting device: " << toStdStringView(message) << std::endl;
        }
        userData.requestEnded = true;
    };

    // Build the callback info
    WGPURequestDeviceCallbackInfo callbackInfo = {
        /* nextInChain = */ nullptr,
        /* mode = */ WGPUCallbackMode_AllowProcessEvents,
        /* callback = */ onDeviceRequestEnded,
        /* userdata1 = */ &userData,
        /* userdata2 = */ nullptr
    };

    // Call to the WebGPU request adapter procedure
    wgpuAdapterRequestDevice(adapter, descriptor, callbackInfo);

    // Hand the execution to the WebGPU instance until the request ended
    wgpuInstanceProcessEvents(instance);
    while (!userData.requestEnded) {
#ifdef __EMSCRIPTEN__
        emscripten_sleep(200);
#else
        std::this_thread::sleep_for(std::chrono::milliseconds(200));
#endif

        wgpuInstanceProcessEvents(instance);
    }

    return userData.device;
}

In the accompanying code (step010-next), I move these utility functions into webgpu-utils.cpp. Unfold the following note to detail all the changes that this implies.

Note - Moving utilities to webgpu-utils.cpp

First, we declare our utility functions in a new header file webgpu-utils.h:

#pragma once

#include <webgpu/webgpu.h>

#include <string_view>

/**
 * Convert a WebGPU string view into a C++ std::string_view.
 */
std::string_view toStdStringView(WGPUStringView wgpuStringView);

/**
 * Convert a C++ std::string_view into a WebGPU string view.
 */
WGPUStringView toWgpuStringView(std::string_view stdStringView);

/**
 * Convert a C string into a WebGPU string view
 */
WGPUStringView toWgpuStringView(const char* cString);

/**
 * Utility function to get a WebGPU adapter, so that
 *     WGPUAdapter adapter = requestAdapter(options);
 * is roughly equivalent to
 *     const adapter = await navigator.gpu.requestAdapter(options);
 */
WGPUAdapter requestAdapterSync(WGPUInstance instance, WGPURequestAdapterOptions const * options);

/**
 * Utility function to get a WebGPU device, so that
 *     WGPUAdapter device = requestDevice(adapter, options);
 * is roughly equivalent to
 *     const device = await adapter.requestDevice(descriptor);
 * It is very similar to requestAdapter
 */
WGPUDevice requestDeviceSync(WGPUInstance instance, WGPUAdapter adapter, WGPUDeviceDescriptor const * descriptor);

/**
 * An example of how we can inspect the capabilities of the hardware through
 * the adapter object.
 */
void inspectAdapter(WGPUAdapter adapter);

Then, we move the β€œUtility functions” block in a new webgpu-utils.cpp file. Do not forget to copy relevant includes:

#include "webgpu-utils.h"

#include <iostream>
#include <vector>
#include <cassert>

#ifdef __EMSCRIPTEN__
#  include <emscripten.h>
#else // __EMSCRIPTEN__
#  include <thread>
#  include <chrono>
#endif // __EMSCRIPTEN__

{{Utility functions}}

We remove utility functions from main.cpp and include our new webgpu-utils.h in main.cpp instead:

#include "webgpu-utils.h"

In CMakeLists.txt, we now have multiple source files in our executable. We list all our source files; header files are optional, but including them helps IDEs display them correctly in the project’s structure:

main.cpp
webgpu-utils.h
webgpu-utils.cpp

These go in the call to add_executable that define our App target:

UsageΒΆ

In the main function, after getting the adapter, we can request the device:

std::cout << "Requesting device..." << std::endl;

WGPUDeviceDescriptor deviceDesc = WGPU_DEVICE_DESCRIPTOR_INIT;
{{Build device descriptor}}
WGPUDevice device = requestDeviceSync(instance, adapter, &deviceDesc);

std::cout << "Got device: " << device << std::endl;

Tip

I use here the WGPU_DEVICE_DESCRIPTOR_INIT macro defined in webgpu.h to assign default values to all fields of deviceDesc. Such an initializer macro is available for all structs of webgpu.h, I recommend using them!

wgpu-native

As of v24.0.0.2, wgpu-native does not support init macros yet. It should come shortly though.

And of course, we release the device when the program ends:

wgpuDeviceRelease(device);

Note

The adapter can be released before the device. Actually we often release it as soon as we have our device and never use it again.

// We no longer need to access the adapter once we have the device
{{Release adapter}}
wgpuDeviceRelease(device);
{{Release WebGPU instance}}

Device descriptorΒΆ

A lot goes in the device descriptor, so let us have a look at its definition:

// Definition of the WGPUDeviceDescriptor struct in webgpu.h
struct WGPUDeviceDescriptor {
    WGPUChainedStruct * nextInChain;
    WGPUStringView label;
    size_t requiredFeatureCount;
    WGPUFeatureName const * requiredFeatures;
    WGPU_NULLABLE WGPULimits const * requiredLimits;
    WGPUQueueDescriptor defaultQueue;
    WGPUDeviceLostCallbackInfo deviceLostCallbackInfo;
    WGPUUncapturedErrorCallbackInfo uncapturedErrorCallbackInfo;
};

First of all, we recognize the now usual nextInChain pointer that starts all such structures. We do not use any extension for now so we can leave it to nullptr, which the WGPU_DEVICE_DESCRIPTOR_INIT macro ensured.

// This is only needed if not using WGPU_DEVICE_DESCRIPTOR_INIT
deviceDesc.nextInChain = nullptr;

LabelΒΆ

Then comes the label, which is present in almost all descriptors as well. This is used to give a name to your WebGPU objects, so that error messages get easier to read.

// Any name works here, that's your call
deviceDesc.label = toWgpuStringView("My Device");

After this message will say something like β€œerror with device β€˜My Device’…”, which is not that important for devices because you will typically only have one, but when it comes to buffers or textures, it is very helpful to know which one is causing an issue!

FeaturesΒΆ

In the previous chapter, we saw that adapters can list features which may or may not be available. We can pick a subset of the list of available features and request the device to support them.

This kind of array argument is always specified through a pair of two fields in a C API like WebGPU: (a) the number of items and (b) the address in memory of the first item, the other one being expected to lie contiguously in memory.

In our case, we do not need any feature for now, so we can leave this to an empty array:

deviceDesc.requiredFeatureCount = 0;
deviceDesc.requiredFeatures = nullptr;

Note

When we will want to request for some feature, we will typically do it through a std::vector like this:

std::vector<WGPUFeatureName> features;
{{List required features}}
deviceDesc.requiredFeatureCount = features.size();
deviceDesc.requiredFeatures = features.data();
// Make sure 'features' lives until the call to wgpuAdapterRequestDevice!
// No required feature for now
#include <vector>

LimitsΒΆ

We may specify limits that we need the device to support through the requiredLimits field. Note that this is a pointer marked as WGPU_NULLABLE, because we can set it to nullptr to let limits to the default values.

deviceDesc.requiredLimits = nullptr;

Alternatively, we can specify the address of a WGPULimits object:

WGPULimits requiredLimits = WGPU_LIMITS_INIT;
{{Specify required limits}}
deviceDesc.requiredLimits = &requiredLimits;
// Make sure that the 'requiredLimits' variable lives until the call to wgpuAdapterRequestDevice!

Note

If you look at the actual values set by WGPU_LIMITS_INIT in webgpu.h, they seem to be different from the default values listed in the WebGPU specification and look like WGPU_LIMIT_U32_UNDEFINED. These special values mean β€œuse whatever the standard default is” to the WebGPU backend.

Let us use the default values of requiredLimits for now, I will try to mention in each chapter which limit it is related to so that we can progressively populate this.

// We leave 'requiredLimits' untouched for now

QueueΒΆ

The field defaultQueue is a substructure of the device descriptor, which is pretty minimal but may become in future version of WebGPU and/or through extensions:

// Definition of the WGPUQueueDescriptor struct in webgpu.h
struct WGPUQueueDescriptor {
    WGPUChainedStruct * nextInChain;
    WGPUStringView label;
};

The value of deviceDesc.defaultQueue.nextInChain was automatically initialized to nullptr when using WGPU_DEVICE_DESCRIPTOR_INIT, so all we may do is give a name to the queue (which is optional because here again we only have one queue):

deviceDesc.defaultQueue.label = toWgpuStringView("The Default Queue");

Device Lost CallbackΒΆ

The last two fields of the descriptor are callback info structures, like we have seen with adapter and device request functions.

The only thing that changes from one WGPUSomethingCallbackInfo to another is the type of the core callback field, so let us have a look at WGPUDeviceLostCallback and define a function that has exactly that signature:

auto onDeviceLost = [](
    WGPUDevice const * device,
    WGPUDeviceLostReason reason,
    struct WGPUStringView message,
    void* /* userdata1 */,
    void* /* userdata2 */
) {
    // All we do is display a message when the device is lost
    std::cout
        << "Device " << device << " was lost: reason " << reason
        << " (" << toStdStringView(message) << ")"
        << std::endl;
};

Note

I define this function using a lambda expression (like we did in requestDeviceSync) in order to place it close to the device descriptor definition, but it could be a regular function.

The possible reasons for a lost device are listed in webgpu.h:

enum WGPUDeviceLostReason {
    // This is probably suspicious:
    WGPUDeviceLostReason_Unknown = 0x00000001,
    // This is raised at the end of your program if you call
    // wgpuInstanceProcessEvents after releasing the device:
    WGPUDeviceLostReason_Destroyed = 0x00000002,
    // This happens when the instance got destroyed by the web browser or the
    // program terminates without processing events after the device was
    // destroyed:
    WGPUDeviceLostReason_InstanceDropped = 0x00000003,
    // This happens when the device could not even be created:
    WGPUDeviceLostReason_FailedCreation = 0x00000004,
    // Special value, never used:
    WGPUDeviceLostReason_Force32 = 0x7FFFFFFF
};

We set this callback in our deviceLostCallbackInfo, and set the mode to AllowProcessEvents like we did with other callbacks:

{{Device Lost Callback}}
deviceDesc.deviceLostCallbackInfo.callback = onDeviceLost;
deviceDesc.deviceLostCallbackInfo.mode = WGPUCallbackMode_AllowProcessEvents;

Uncaptured Error CallbackΒΆ

This last callback is very important, as it defines a function that will be invoked whenever something goes wrong with the API. It this is very likely to happen, and the information messages passed to this callback are very valuable to help debugging our application, so we must not overlook it!

Caution

This callback info does not have a mode field because contrary to other callbacks, this one is en event handler that may be called repeatedly (as opposed to a β€œfuture” handler that is invoked only once).

// Definition of the WGPUUncapturedErrorCallbackInfo struct in webgpu.h
struct WGPUUncapturedErrorCallbackInfo {
    WGPUChainedStruct * nextInChain;
    // No 'mode' field! Callback may be invoked at any time.
    WGPUUncapturedErrorCallback callback;
    WGPU_NULLABLE void* userdata1;
    WGPU_NULLABLE void* userdata2;
};

Here again, we define a callback that displays information about the device error:

auto onDeviceError = [](
    WGPUDevice const * device,
    WGPUErrorType type,
    struct WGPUStringView message,
    void* /* userdata1 */,
    void* /* userdata2 */
) {
    std::cout
        << "Uncaptured error in device " << device << ": type " << type
        << " (" << toStdStringView(message) << ")"
        << std::endl;
};
{{Device Error Callback}}
deviceDesc.uncapturedErrorCallbackInfo.callback = onDeviceError;

Inspecting the deviceΒΆ

All right, our descriptor is complete, we now have a device!

Like the adapter, the device has its own set of capabilities that we can inspect at any time.

Note

At this point of the code – where we just created the device – we know its capabilities and limits because when the creation succeeded the device corresponds to what we requested. Being able to inspect the device is useful later on, or when writing a library that receives a WGPUDevice object that was created somewhere else.

// We create a utility function to inspect the device:
void inspectDevice(WGPUDevice device) {
    
    WGPUSupportedFeatures features = WGPU_SUPPORTED_FEATURES_INIT;
    wgpuDeviceGetFeatures(device, &features);
    std::cout << "Device features:" << std::endl;
    std::cout << std::hex;
    for (size_t i = 0; i < features.featureCount; ++i) {
        std::cout << " - 0x" << features.features[i] << std::endl;
    }
    std::cout << std::dec;
    wgpuSupportedFeaturesFreeMembers(features);

    WGPULimits limits = WGPU_LIMITS_INIT;
    bool success = wgpuDeviceGetLimits(device, &limits) == WGPUStatus_Success;

    if (success) {
        std::cout << "Device limits:" << std::endl;
        std::cout << " - maxTextureDimension1D: " << limits.maxTextureDimension1D << std::endl;
        std::cout << " - maxTextureDimension2D: " << limits.maxTextureDimension2D << std::endl;
        std::cout << " - maxTextureDimension3D: " << limits.maxTextureDimension3D << std::endl;
        std::cout << " - maxTextureArrayLayers: " << limits.maxTextureArrayLayers << std::endl;
        {{Extra device limits}}
    }
}
std::cout << " - maxBindGroups: " << limits.maxBindGroups << std::endl;
std::cout << " - maxBindGroupsPlusVertexBuffers: " << limits.maxBindGroupsPlusVertexBuffers << std::endl;
std::cout << " - maxBindingsPerBindGroup: " << limits.maxBindingsPerBindGroup << std::endl;
std::cout << " - maxDynamicUniformBuffersPerPipelineLayout: " << limits.maxDynamicUniformBuffersPerPipelineLayout << std::endl;
std::cout << " - maxDynamicStorageBuffersPerPipelineLayout: " << limits.maxDynamicStorageBuffersPerPipelineLayout << std::endl;
std::cout << " - maxSampledTexturesPerShaderStage: " << limits.maxSampledTexturesPerShaderStage << std::endl;
std::cout << " - maxSamplersPerShaderStage: " << limits.maxSamplersPerShaderStage << std::endl;
std::cout << " - maxStorageBuffersPerShaderStage: " << limits.maxStorageBuffersPerShaderStage << std::endl;
std::cout << " - maxStorageTexturesPerShaderStage: " << limits.maxStorageTexturesPerShaderStage << std::endl;
std::cout << " - maxUniformBuffersPerShaderStage: " << limits.maxUniformBuffersPerShaderStage << std::endl;
std::cout << " - maxUniformBufferBindingSize: " << limits.maxUniformBufferBindingSize << std::endl;
std::cout << " - maxStorageBufferBindingSize: " << limits.maxStorageBufferBindingSize << std::endl;
std::cout << " - minUniformBufferOffsetAlignment: " << limits.minUniformBufferOffsetAlignment << std::endl;
std::cout << " - minStorageBufferOffsetAlignment: " << limits.minStorageBufferOffsetAlignment << std::endl;
std::cout << " - maxVertexBuffers: " << limits.maxVertexBuffers << std::endl;
std::cout << " - maxBufferSize: " << limits.maxBufferSize << std::endl;
std::cout << " - maxVertexAttributes: " << limits.maxVertexAttributes << std::endl;
std::cout << " - maxVertexBufferArrayStride: " << limits.maxVertexBufferArrayStride << std::endl;
std::cout << " - maxInterStageShaderVariables: " << limits.maxInterStageShaderVariables << std::endl;
std::cout << " - maxColorAttachments: " << limits.maxColorAttachments << std::endl;
std::cout << " - maxColorAttachmentBytesPerSample: " << limits.maxColorAttachmentBytesPerSample << std::endl;
std::cout << " - maxComputeWorkgroupStorageSize: " << limits.maxComputeWorkgroupStorageSize << std::endl;
std::cout << " - maxComputeInvocationsPerWorkgroup: " << limits.maxComputeInvocationsPerWorkgroup << std::endl;
std::cout << " - maxComputeWorkgroupSizeX: " << limits.maxComputeWorkgroupSizeX << std::endl;
std::cout << " - maxComputeWorkgroupSizeY: " << limits.maxComputeWorkgroupSizeY << std::endl;
std::cout << " - maxComputeWorkgroupSizeZ: " << limits.maxComputeWorkgroupSizeZ << std::endl;
std::cout << " - maxComputeWorkgroupsPerDimension: " << limits.maxComputeWorkgroupsPerDimension << std::endl;
std::cout << " - maxStorageBuffersInVertexStage: " << limits.maxStorageBuffersInVertexStage << std::endl;
std::cout << " - maxStorageTexturesInVertexStage: " << limits.maxStorageTexturesInVertexStage << std::endl;
std::cout << " - maxStorageBuffersInFragmentStage: " << limits.maxStorageBuffersInFragmentStage << std::endl;
std::cout << " - maxStorageTexturesInFragmentStage: " << limits.maxStorageTexturesInFragmentStage << std::endl;

If you define this function in webgpu-utils.cpp, do not forget to also declare it in webgpu-utils.h:

/**
 * Display information about a device
 */
void inspectDevice(WGPUDevice device);

And we call this after creating the device:

inspectDevice(device);

We can see that by default the device limits are not the same as what the adapter supports. Setting deviceDesc.requiredLimits to nullptr or using default limits from WGPU_LIMITS_INIT corresponded to ask for minimal limits:

Device limits:
 - maxTextureDimension1D: 8192
 - maxTextureDimension2D: 8192
 - maxTextureDimension3D: 2048
 - maxTextureArrayLayers: 256
 - ...

Note

One can also retrieve the adapter that was used to request the device using wgpuDeviceGetAdapter.

ConclusionΒΆ

  • We now have our device, from which we can create all other WebGPU objects.

  • Important: Once the device is created, the adapter should in general no longer be used. The only capabilities that matter to the application are the one of the device.

  • Default limits are minimal limits, rather than what the adapter supports. This helps ensuring consistency across devices.

  • The uncaptured error callback is where all of our issues will be reported, it is important to set it up.

We are now ready to send instructions and data to the device through the command queue!

Resulting code: step010-next