|
opencl-api-cpp
Better OpenCL API C++ bindings
|
**–==Warning: WIP - for testing and evaluation only! ==–**
This is a header-only library of C++ bindings/wrappers for the C-language API of the OpenCL execution ecosystem.
It is an unofficial alternative to the Khronos Consortium's own CLHPP C++ bindings. It is intended to make working with OpenCL be less error-prone, more intuitive and consistent (both to write and read), and requiring less memorizing and less familiarity with idiosyncracies. This is achieved by using modern C++ language capabilities, programming idioms and recommended practices; see Motivation below for details.
Note: It is not the library's intention for users to write "higher-level" or more "abstract" code; these bindings are merely a modern-C++-arrangement of the OpenCL API itself.
This library is characterized by:
getInfo()mechanisms to get the attributes of your objects.impl/ subdirectory - with all those definitions and details.Why even bother writing new C++ bindings for OpenCL? After all, these already exist - and are official to boot: Khronos' CLHPP.
Well, I have used them on occasion. But - oh man! There are just too many damn problems with them, that I just give up and usually just fall back to using the C APIs. Let me point out some of what I've found to be so grating:
cl_int* error parameters. Sometimes they don't even have an implicit default. Wanna launch a kernel? Not so fast. First spend a command defining a cl_int (why do I even have to know about this type?), and then hand over a reference to it.what() string are typically just one or two words long. Usually it's just the C API call which failed. No contextual description, or "story", is provided, to help with your diagnostics.what() string - which typically gives you the API action which failed, but does not include the actuall error, i.e. the string interpretation of the error code - nor even the error code itself!clGetFooInfo(), which takes many arguments; and you have to run it twice - once for size, once for the data; and you have to memorize the exact enum value. Ugh. Now, CLHPP does make this somewhat easier, but it's still my_foo.getInfo<CL_FOO_CONTEXT>() - and of course that gives you the raw value, not something nicely wrapped. Why do I still have to bear this punishment?namespace detail code which the user should be able to ignore. A single header file is more convenient to deploy - but that could be generated by amalgamting the separate header and offering the result as a release artifact.Context, default CommandQueue, default lots-of-stuff. That annoys me both usability-wise and class-design-wise: There aren't such defaults in OpenCL. Why make them up? Just because we're using C++? No. This warps the user's perception of the OpenCL API. Moreover - I don't like this mechanism being complected by force into the basic class for the OpenCL entity. Want to have defaults? Fine, put them somewhere else.cl::Device() work? Am I running the device? Invoking the device? It must obviously have some kind of disengaged semantic... but - that means I have to be suspicious of every cl::Device instance I'm getting, anywhere in the code! A cl::Device might not be a real device, but rather a "null device", "disengaged value". And the same goes for essentially all classes. That undue burden is pretty much the same as having to make sure my cl_device_id is not null, whenever I receive one. I don't want to live in that world :-(operator(). What is that? Am I running my platforms? Invoking my contexts? Nope. Well, those just yield ther raw handle (cl_platform_id, cl_context_id etc.). That's not how I want it to be accessible.flags and size in clCreateBuffer(). I would expect C++ bindings to safeguard me from that: Either not having to specify both of them, or using not-implicitly-convertible types. But - CLHPP keeps that problem: Have a look at cl::Buffer::Buffer() - it takes exactly those two paramstd::vector's? std::vector, especially with the default allocator, is a notoriously unwieldy class: You can wrap storage in a vector, nor can you release the storage and use it for something else. So, if you have contiguous information somewhere, and you need to pass it to a CLHPP class - though luck, you're going to have to create a new vector for it. No templating nor even the use of span<T>'s.cl::Buffer has 22 separate combinations of parameter types usable to construct it! (8 constructor definitions, 7 of which have 2 default parameter values each)... and some of them involve enqueuing copy operations on some command queue.Having spent quite a while polishing my CUDA Modern-C++ API wrappers library, I was sure it could be done differently; and that if I put my nose to the grindstone, I could make it happen.
You may have noticed this list reads like the opposite of the key features, listed above: The idea is to make this library overcome and rectify these deficiencies as much as possible.
For CMake, you have several alternatives for obtaining the library to use in your project:
FetchContent module to have CMake itself obtain the project source code and make it part of your own project's build, e.g.: Now that you have the package, in your project's CMakeLists.txt, you write:
This will let you use the target: opencl-api-cpp::api as a dependency for your own targets. Example:
Use without CMake:
Since this is a header-only library, you can simply add the src/ subdirectory as one of your project's include directories. However, if you do this, it will be up to you to make sure and have the OpenCL headers include directory in your include path as well, and to link against the relevant OpenCL libraries.
This library is intended to cover the OpenCL API, sans graphics-related interoperability functions (for OpenGL, DirectX, Direct3D etc.).
This goal is close to being achieved, but we're not all the way there. You can find remaining omissions as issues tagged with "core-api-coverage". Perhaps the most prominent omission at this time is execution graphs, i.e. 'queues' which may execute out-of-order, subject to defined dependencies.
Efforts will be made to support Khronos-defined extensions, but - this is less of a priority. Such extensions can be found via issues tagged with "extension-coverage", further development work may take longer. The most prominent unsupported extension is probably Command Buffers.
Vendor-specific extensions may or may not be supported, arbitrarily, with no promises made that point.
Let's start with the very first lines of the example program in the CLHPP documentation's root page:
| Khronos CLHPP | opencl-api-cpp |
|---|---|
std::vector<cl::Platform> platforms;
cl::Platform::get(&platforms);
// ..snip...
for (auto &p : platforms) {
|
for (auto &p : opencl::platforms()) {
|
When we let go of the C-way of writing code and get rid of the out-parameter, we...
std::vectorplatforms() uses.Let's continue: The program must now try and obtain a platform supporting OpenCL 2.0 or later.
| Khronos CLHPP | opencl-api-cpp |
|---|---|
cl::Platform plat;
for (auto &p : platforms) {
std::string platver = p.getInfo<CL_PLATFORM_VERSION>();
if (platver.find("OpenCL 2.") != std::string::npos ||
platver.find("OpenCL 3.") != std::string::npos) {
plat = p;
}
}
if (plat() == 0) {
std::cout << "No OpenCL 2.0 or newer platform found.\n";
return -1;
}
|
auto platform = [] {
auto is_acceptable = [](auto const & p) { return p.version().major >= 2; };
auto platforms = opencl::platforms();
auto iter = std::ranges::find_if(platforms, is_acceptable);
(iter < platforms.end()) or die("No OpenCL 2.0 or newer platform found.");
return *iter;
}();
|
The die() function is just a simple hack (that's not part of this library):
you'll also notice that:
platform variable ever hold an invalid value.find_if() line would need the begin() and end() iterators; a little more verbose, but would work fine.for, if, or while. Remember Sean Parent's 2013 C++ Seasonings talk?Our last comparison of code segments regards building a program from a couple of kernel source code strings:
| Khronos CLHPP | opencl-api-cpp |
|---|---|
std::vector<std::string> programStrings;
programStrings.push_back(kernel1);
programStrings.push_back(kernel2);cl::Program vectorAddProgram(programStrings);
try {
vectorAddProgram.build("-cl-std=CL2.0");
}
catch (...) {
// Print build info for all devices
cl_int buildErr = CL_SUCCESS;
auto buildInfo = vectorAddProgram
.getBuildInfo<CL_PROGRAM_BUILD_LOG>(&buildErr);
for (auto &pair : buildInfo) {
std::cerr << pair.second << std::endl << std::endl;
}
return 1;
}
|
auto sources = { kernel1, kernel2 };
auto sources_ = opencl::program::source::create(context, sources);
auto options = opencl::program::compilation::options::create();
options.language_version = opencl::make_version("2.0");
auto build_result = opencl::program::build_(sources_, options);
if (not build_result.succeeded()) {
std::cerr << "Build failed.\n";
for (auto const& target_build_info : build_result.info()) {
auto target = target_build_info.device();
std::cerr << "Build log for device " << target.name() << ":\n\n";
std::cerr << target_build_info.log()<< std::endl;
}
exit(EXIT_FAILURE);
}
|
Some points to note here:
build_result object can describe either a successful build with resulting artifacts, or a failure, in which case one can loog into the reason through the object.CL_OUT_OF_HOST_MEMORY or CL_COMPILER_NOT_AVAILABLE or other such errors.context.devices() when we don't specify targets explicitly; doing so, we could then obtain individual target build info with build_result.info_for(target_device);std::initializer_lists's).More details about these will be added under the examples directory.