Blogs of Bailey

My personal ramblings most will never read

Follow me on GitHub

So I tried github copilot for the first time…

Honestly, I think the chat/conversation feature is more valuable than the code suggestion. It helps you work through a problem like you would with another engineer except they are at an almost omnipotent level.

Transcript of my chat with copilot

Me

What is a good approach to VkPhysicalDevice selection

GitHub Copilot

The selection of a VkPhysicalDevice in a Vulkan application is a crucial step as it represents the GPU that will be used for rendering. The selection process typically involves the following steps:

  1. Enumerate the physical devices available on the system using vkEnumeratePhysicalDevices.
  2. For each physical device, check if it supports the features your application needs. This can include checking the type of the device (discrete GPU, integrated GPU, etc.), checking if it supports the required Vulkan version, checking if it supports the required Vulkan extensions, and checking if it has enough memory and compute units for your needs.
  3. Score each device based on your application’s specific needs. For example, you might prefer a discrete GPU over an integrated one, or a device that supports a higher Vulkan version, or a device with more memory, etc.
  4. Select the device with the highest score.

Here is a basic example of how you can implement this in C++:

VkPhysicalDevice selectPhysicalDevice(VkInstance instance) {
    uint32_t deviceCount = 0;
    vkEnumeratePhysicalDevices(instance, &deviceCount, nullptr);

    if (deviceCount == 0) {
        throw std::runtime_error("failed to find GPUs with Vulkan support!");
    }

    std::vector<VkPhysicalDevice> devices(deviceCount);
    vkEnumeratePhysicalDevices(instance, &deviceCount, devices.data());

    std::multimap<int, VkPhysicalDevice> candidates;

    for (const auto& device : devices) {
        int score = scoreDevice(device);
        candidates.insert(std::make_pair(score, device));
    }

    // Check if the best candidate is suitable at all
    if (candidates.rbegin()->first > 0) {
        return candidates.rbegin()->second;
    } else {
        throw std::runtime_error("failed to find a suitable GPU!");
    }
}

In this code, scoreDevice is a function that calculates a score for a device based on your application’s specific needs. You can modify this function to suit your needs.

Me

Can you design a composable functional approach to scoreDevice for me? An acceptable solution should allow for multiple implementations of a scoreDevice algorithm which are composable onto each other.

GitHub Copilot

Sure, a composable functional approach to scoreDevice can be achieved by creating a series of scoring functions that each handle a specific aspect of the scoring. These functions can then be composed together to create the final scoring function. Here’s a basic example in C++:

First, define a type for your scoring functions. These functions should take a VkPhysicalDevice and return an int:

using ScoringFunction = std::function<int(VkPhysicalDevice)>;

Next, define your individual scoring functions. For example, you might have one function for scoring based on the device type, and another for scoring based on whether the device supports a geometry shader:

ScoringFunction scoreDeviceType = [](VkPhysicalDevice device) {
    VkPhysicalDeviceProperties properties;
    vkGetPhysicalDeviceProperties(device, &properties);

    if (properties.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) {
        return 1000;
    } else {
        return 0;
    }
};

ScoringFunction scoreGeometryShader = [](VkPhysicalDevice device) {
    VkPhysicalDeviceFeatures features;
    vkGetPhysicalDeviceFeatures(device, &features);

    if (features.geometryShader) {
        return 500;
    } else {
        return 0;
    }
};

Finally, you can create a function to compose these scoring functions together. This function takes a list of scoring functions and returns a new scoring function that sums the scores from each individual function:

ScoringFunction composeScoringFunctions(const std::vector<ScoringFunction>& functions) {
    return [functions](VkPhysicalDevice device) {
        int totalScore = 0;
        for (const auto& function : functions) {
            totalScore += function(device);
        }
        return totalScore;
    };
}

You can now create a scoreDevice function by composing the individual scoring functions:

ScoringFunction scoreDevice = composeScoringFunctions({scoreDeviceType, scoreGeometryShader});

This scoreDevice function can now be used in the same way as your original scoreDevice function, but it has the advantage of being composed of smaller, simpler functions that can be tested and modified independently.

Me

This looks good, but what if I did not want to use lambdas?

GitHub Copilot

If you prefer not to use lambdas, you can define the scoring functions as regular functions or as methods in a class. Here’s an example of how you can do this:

First, define the type for your scoring functions:

using ScoringFunction = int(*)(VkPhysicalDevice);

Next, define your individual scoring functions as regular functions:

int scoreDeviceType(VkPhysicalDevice device) {
    VkPhysicalDeviceProperties properties;
    vkGetPhysicalDeviceProperties(device, &properties);

    if (properties.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) {
        return 1000;
    } else {
        return 0;
    }
}

int scoreGeometryShader(VkPhysicalDevice device) {
    VkPhysicalDeviceFeatures features;
    vkGetPhysicalDeviceFeatures(device, &features);

    if (features.geometryShader) {
        return 500;
    } else {
        return 0;
    }
}

Then, create a function to compose these scoring functions together:

ScoringFunction composeScoringFunctions(const std::vector<ScoringFunction>& functions) {
    return [functions](VkPhysicalDevice device) {
        int totalScore = 0;
        for (const auto& function : functions) {
            totalScore += function(device);
        }
        return totalScore;
    };
}

Finally, create a scoreDevice function by composing the individual scoring functions:

ScoringFunction scoreDevice = composeScoringFunctions({scoreDeviceType, scoreGeometryShader});

This scoreDevice function can now be used in the same way as your original scoreDevice function, but it has the advantage of being composed of smaller, simpler functions that can be tested and modified independently.

Me

is there a generic variety of this compose function we have created that is available in the STL?

GitHub Copilot

Yes, there is a generic way to compose functions in C++ using the STL, but it’s not as straightforward as having a single function that does it. You can use std::bind and std::function to create a composition of functions. However, this doesn’t work as cleanly as the custom compose function we created earlier, especially when you want to compose multiple functions together.

Here’s an example of how you can use std::bind and std::function to compose two functions together:

#include <functional>

int scoreDeviceType(VkPhysicalDevice device) {
    // scoring logic here
}

int scoreGeometryShader(VkPhysicalDevice device) {
    // scoring logic here
}

std::function<int(VkPhysicalDevice)> compose(std::function<int(VkPhysicalDevice)> f, std::function<int(VkPhysicalDevice)> g) {
    return [=](VkPhysicalDevice device) { return f(device) + g(device); };
}

int main() {
    auto scoreDevice = compose(scoreDeviceType, scoreGeometryShader);
    // Now you can use scoreDevice as a composed function
}

This will work for composing two functions, but if you want to compose more than two, you would need to nest calls to compose, which can get messy.

In C++20, there’s a proposal for std::compose which would make function composition as easy as it is in functional programming languages, but it’s not yet part of the standard library.

Me

You mentioned std::bind in your last example, but I do not see it being used anywhere.

GitHub Copilot

I apologize for the confusion. You’re correct, the example provided did not use std::bind. It was a mistake in the explanation. The std::bind function is used to bind one or more arguments to a function, and it returns a function object that can be called later. However, in the context of composing functions, std::bind is not typically used. Instead, function composition in C++ is often achieved through the use of lambdas, as shown in the previous examples.

Me

could we extend our new compose method by using templates, so that it may compose other types of functions together?

GitHub Copilot

Yes, you can use templates to create a more generic compose function that can handle different types of functions. Here’s an example of how you can do this:

template<typename Func>
auto compose(Func f) {
    return [f](auto... args) { return f(args...); };
}

template<typename Func1, typename Func2, typename... Funcs>
auto compose(Func1 f1, Func2 f2, Funcs... funcs) {
    return [f1, f2, funcs...](auto... args) {
        return f1(compose(f2, funcs...)(args...));
    };
}

In this code, the first compose function is a base case that simply returns the function f. The second compose function is a variadic template that takes at least two functions. It creates a lambda that calls the first function with the result of the composition of the rest of the functions.

This compose function can be used to compose functions of different types together. For example:

auto addOne = [](int x) { return x + 1; };
auto multiplyByTwo = [](int x) { return x * 2; };
auto subtractThree = [](int x) { return x - 3; };

auto composedFunction = compose(addOne, multiplyByTwo, subtractThree);

int result = composedFunction(5);  // result is 5

In this example, composedFunction is a function that first subtracts 3 from its argument, then multiplies the result by 2, and finally adds 1.

Me

I very much like this last solution, can you now apply it to our device selection use case with examples?

GitHub Copilot

Sure, let’s apply the generic compose function to the scoreDevice function in your Vulkan application.

First, we’ll need to modify the scoreDevice function to be a composition of smaller scoring functions. We’ll create two scoring functions: scoreDeviceType and scoreGeometryShader.

int scoreDeviceType(VkPhysicalDevice device) {
    VkPhysicalDeviceProperties properties;
    vkGetPhysicalDeviceProperties(device, &properties);

    if (properties.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) {
        return 1000;
    } else {
        return 0;
    }
}

int scoreGeometryShader(VkPhysicalDevice device) {
    VkPhysicalDeviceFeatures features;
    vkGetPhysicalDeviceFeatures(device, &features);

    if (features.geometryShader) {
        return 500;
    } else {
        return 0;
    }
}

Next, we’ll define the generic compose function using templates:

template<typename Func>
auto compose(Func f) {
    return [f](auto... args) { return f(args...); };
}

template<typename Func1, typename Func2, typename... Funcs>
auto compose(Func1 f1, Func2 f2, Funcs... funcs) {
    return [f1, f2, funcs...](auto... args) {
        return f1(compose(f2, funcs...)(args...));
    };
}

Now, we can create a scoreDevice function by composing the individual scoring functions:

auto scoreDevice = compose(scoreDeviceType, scoreGeometryShader);

Finally, we’ll need to modify the selectPhysicalDevice function to use the new scoreDevice function:

VkPhysicalDevice selectPhysicalDevice(VkInstance instance) {
    uint32_t deviceCount = 0;
    vkEnumeratePhysicalDevices(instance, &deviceCount, nullptr);

    if (deviceCount == 0) {
        throw std::runtime_error("failed to find GPUs with Vulkan support!");
    }

    std::vector<VkPhysicalDevice> devices(deviceCount);
    vkEnumeratePhysicalDevices(instance, &deviceCount, devices.data());

    std::multimap<int, VkPhysicalDevice> candidates;

    for (const auto& device : devices) {
        int score = scoreDevice(device);
        candidates.insert(std::make_pair(score, device));
    }

    // Check if the best candidate is suitable at all
    if (candidates.rbegin()->first > 0) {
        return candidates.rbegin()->second;
    } else {
        throw std::runtime_error("failed to find a suitable GPU!");
    }
}

This scoreDevice function can now be used in the same way as your original scoreDevice function, but it has the advantage of being composed of smaller, simpler functions that can be tested and modified independently.

Me

Awesome, this is almost perfect, but one last thing. Could you modify the selectPhysicalDevice method to be passed a scoreDevice method instead of using a top level defined method. Then, summarize our entire final solution for me.

GitHub Copilot

Sure, we can modify the selectPhysicalDevice method to accept a scoring function as a parameter. This will make the function more flexible and allow you to use different scoring functions if needed. Here’s how you can do it:

VkPhysicalDevice selectPhysicalDevice(VkInstance instance, std::function<int(VkPhysicalDevice)> scoreDevice) {
    uint32_t deviceCount = 0;
    vkEnumeratePhysicalDevices(instance, &deviceCount, nullptr);

    if (deviceCount == 0) {
        throw std::runtime_error("failed to find GPUs with Vulkan support!");
    }

    std::vector<VkPhysicalDevice> devices(deviceCount);
    vkEnumeratePhysicalDevices(instance, &deviceCount, devices.data());

    std::multimap<int, VkPhysicalDevice> candidates;

    for (const auto& device : devices) {
        int score = scoreDevice(device);
        candidates.insert(std::make_pair(score, device));
    }

    // Check if the best candidate is suitable at all
    if (candidates.rbegin()->first > 0) {
        return candidates.rbegin()->second;
    } else {
        throw std::runtime_error("failed to find a suitable GPU!");
    }
}

Now, you can pass the scoreDevice function when calling selectPhysicalDevice:

auto scoreDevice = compose(scoreDeviceType, scoreGeometryShader);
auto physicalDevice = selectPhysicalDevice(instance, scoreDevice);

To summarize, we have created a more flexible and modular solution for scoring and selecting a physical device in a Vulkan application. We have defined individual scoring functions (scoreDeviceType and scoreGeometryShader), and a generic compose function that can combine these scoring functions into a single function. We then pass this composed function to the selectPhysicalDevice function, which uses it to score and select the best physical device. This approach allows for easier testing and modification of the scoring logic, as each scoring function is independent and the composition is done dynamically at runtime.

Me

My apologies, summarize was the incorrect word, could you show me the entire final code with multiple usage examples?

GitHub Copilot

Sure, here is the final code with the changes we discussed:

#include <algorithm>
#include <iostream>
#include <optional>
#include <vector>
#include <vulkan/vulkan.h>
#include <functional>

int scoreDeviceType(VkPhysicalDevice device) {
    VkPhysicalDeviceProperties properties;
    vkGetPhysicalDeviceProperties(device, &properties);

    if (properties.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) {
        return 1000;
    } else {
        return 0;
    }
}

int scoreGeometryShader(VkPhysicalDevice device) {
    VkPhysicalDeviceFeatures features;
    vkGetPhysicalDeviceFeatures(device, &features);

    if (features.geometryShader) {
        return 500;
    } else {
        return 0;
    }
}

template<typename Func>
auto compose(Func f) {
    return [f](auto... args) { return f(args...); };
}

template<typename Func1, typename Func2, typename... Funcs>
auto compose(Func1 f1, Func2 f2, Funcs... funcs) {
    return [f1, f2, funcs...](auto... args) {
        return f1(compose(f2, funcs...)(args...));
    };
}

VkPhysicalDevice selectPhysicalDevice(VkInstance instance, std::function<int(VkPhysicalDevice)> scoreDevice) {
    uint32_t deviceCount = 0;
    vkEnumeratePhysicalDevices(instance, &deviceCount, nullptr);

    if (deviceCount == 0) {
        throw std::runtime_error("failed to find GPUs with Vulkan support!");
    }

    std::vector<VkPhysicalDevice> devices(deviceCount);
    vkEnumeratePhysicalDevices(instance, &deviceCount, devices.data());

    std::multimap<int, VkPhysicalDevice> candidates;

    for (const auto& device : devices) {
        int score = scoreDevice(device);
        candidates.insert(std::make_pair(score, device));
    }

    // Check if the best candidate is suitable at all
    if (candidates.rbegin()->first > 0) {
        return candidates.rbegin()->second;
    } else {
        throw std::runtime_error("failed to find a suitable GPU!");
    }
}

int main() {
    VkInstance instance = createInstance(); // Assuming createInstance() is defined elsewhere

    auto scoreDevice = compose(scoreDeviceType, scoreGeometryShader);
    VkPhysicalDevice physicalDevice = selectPhysicalDevice(instance, scoreDevice);

    // You can also create different scoring functions and use them
    auto scoreDevice2 = compose([](VkPhysicalDevice device) { /* scoring logic */ }, scoreGeometryShader);
    VkPhysicalDevice physicalDevice2 = selectPhysicalDevice(instance, scoreDevice2);
}

In this code, we first define the individual scoring functions scoreDeviceType and scoreGeometryShader. We then define a generic compose function that can combine these scoring functions into a single function. The selectPhysicalDevice function is modified to accept a scoring function as a parameter. In the main function, we create a scoreDevice function by composing scoreDeviceType and scoreGeometryShader, and pass this function to selectPhysicalDevice to select the best physical device. We also show an example of creating a different scoring function and using it to select a physical device.

Me

This looks great, but looking at the code it is not clear to me if our compose implementation is suming the return values of the composed methods as I would expect. Additionally, it seems like there is no way for one of the composed methods to short circuit the entire thing if it comes across a requirement that can not be satisfied. Would you agree with my assesment?

GitHub Copilot

Yes, you are correct in your assessment. The current implementation of the compose function does not sum the return values of the composed functions. Instead, it passes the return value of one function as the argument to the next function. This is a common pattern in function composition, but it may not be what you want in this case.

Additionally, you are correct that the current implementation does not provide a way for one of the composed functions to short-circuit the entire process. If one of the functions returns a value that indicates a requirement is not satisfied, the other functions will still be called.

If you want the compose function to sum the return values of the composed functions and to stop evaluating as soon as one of the functions returns a value that indicates a requirement is not satisfied, you would need to modify the compose function. Here’s an example of how you could do this:

template<typename Func>
auto compose(Func f) {
    return [f](auto... args) { return f(args...); };
}

template<typename Func1, typename Func2, typename... Funcs>
auto compose(Func1 f1, Func2 f2, Funcs... funcs) {
    return [f1, f2, funcs...](auto... args) {
        int result = compose(f2, funcs...)(args...);
        if (result == 0) {  // If a requirement is not satisfied
            return 0;
        }
        return f1(args...) + result;
    };
}

In this code, the compose function checks the result of the composed functions. If the result is 0, it returns 0 immediately, short-circuiting the rest of the evaluation. Otherwise, it adds the result of f1(args...) to the result and returns the sum.

Me

Awesome, this works great! I definately would have arrived to this conclussion on my own, but you saved me a few hours for sure, thanks.

GitHub Copilot

You’re welcome! I’m glad I could assist you. If you have any more questions or need further help in the future, don’t hesitate to ask. Happy coding!