-
Notifications
You must be signed in to change notification settings - Fork 2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
discussion for STL handling #5
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,168 @@ | ||
# Summary | ||
|
||
Lots of libraries use allocating standard library types in their API and we need a method for dealing with: | ||
- `std::vector` | ||
- `std::string` | ||
|
||
The simplest way of dealing with these is to just copy the types entirely to C equivalents in the wrapper functions. This is the method currently proposed for handling strings. For example: | ||
```c++ | ||
int OIIO_ImageInput_geterror(const OIIO_ImageInput* self, char* _result_buffer_ptr, int _result_buffer_len) { | ||
const std::string result = to_cpp(self)->geterror(); | ||
safe_strcpy(_result_buffer_ptr, result, _result_buffer_len); | ||
return result.size(); | ||
} | ||
``` | ||
In this model, the caller allocates the memory and passes it in to the wrapper. The wrapper then copies from the string returned from C++ to the provided buffer, making sure not to exceed the user-provided length. | ||
|
||
This works fine for short arrays, such as error messages where we expect to be able to stack-allocate the buffer and adjust the program if we find it's not large enough, or allocate a reasonably large one once and reuse it throughout the program, but strings and vectors could really be any size. We need a different model. | ||
|
||
The crux of the issue is that `vector` and `string` allocate and manage their memory using RAII and provide no facility for releasing ownership of that memory. | ||
|
||
In the examples below we'll just use `std::vector`. There are three cases we need to consider: | ||
|
||
```c++ | ||
namespace foo { | ||
std::vector<float> returns(); | ||
void as_mut_param(std::vector<float>& vec); | ||
void as_const_param(const std::vector<float>& vec); | ||
} | ||
``` | ||
|
||
# Possible solutions | ||
|
||
## 1. Two-phase copy | ||
|
||
This is a modifcation of the error-message method above. For returning, we make two functions, say `len` and `get`. `len` is the function that actually gets the vector, then stashes it in a thread_local and returns its size, giving the caller an opportunity to allocate memory of the required size which it can pass to `get`, which will copy from the stashed vector: | ||
|
||
```c++ | ||
int foo_returns_len() { | ||
thread_local _stash = foo::returns(); | ||
return _stash.size(); | ||
} | ||
|
||
void foo_returns_get(float* buffer, int len) { | ||
anderslanglands marked this conversation as resolved.
Show resolved
Hide resolved
|
||
memcpy(buffer, _stash.data(), sizeof(float) * min(_stash.size(), len)); | ||
_stash = {}; | ||
} | ||
``` | ||
|
||
`const_param` just copies into a temporary vector: | ||
```c++ | ||
void foo_as_const_param(float* buffer, int len) { | ||
std::vector tmp{buffer, len}; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I assume this a sketch, since a tmp on the stack will bump up against stack size limits, in the case where an implement of vector manages an embedded array, instead of a heap managed block? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are there any instances where that's the case? That would require a custom allocator, which would change the type of the vector, and AFAICT all vectors in public APIs of the projects we care about use the default allocator? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. True. In my mind I was imagining more widespread use, which isn't necessarily the aim. In gamedev allocator replacement is very common; EASTL is architected around the notion that the allocator is externally supplied. |
||
foo::as_const_param(tmp); | ||
} | ||
``` | ||
|
||
`mut_param` is trickier since we potentially need to both pass data and return it. Easiest is probably just not to support passing data at all, in which case it reduces to the same as `returns`. | ||
|
||
### Pros | ||
- It's guaranteed to work and is safe | ||
- Caller is responsible for all allocations | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do you mean the caller is responsible for inducing all allocations? That's how I read the proposal. I don't read that the caller is responsible for allocations ~ which wouldn't make sense, given that the vector and string can cause allocations themselves independent of what the caller intended. All of these proposals need to consider allocation boundaries as well. On Windows for example, given Foo.dll and Bar.dll, each bound to unknown standard libraries, we can guarantee that Foo can freely allocate and free a std::string, and that Bar can access the bytes of the string, in a single threaded context. A string allocated by Foo, and deallocated by Bar drops into the realm of undefined behavior, and more typically simply corrupts Foo's heap, and Bar's heap by cross linking freelists. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes absolutely, this is one of the main things we're trying to work around here. In the first option yeah, vector absolutely will not work. |
||
### Cons | ||
- Slow | ||
- Slightly ugly API for the `returns` case. `len` actually getting the vector is surprising for callers. | ||
- Works for POD elements only (no vector<string>) | ||
|
||
## 2. Binding-side storage | ||
|
||
In this model we stash the vector in a global (thread_local) map, indexed by its data pointer so it can be found and deleted later: | ||
|
||
```c++ | ||
thread_local std::map<void*, std::any> _storage; | ||
|
||
foo_returns(float** buffer, int* len) { | ||
std::vector<float> vec = foo::returns(); | ||
*buffer = vec.data(); | ||
*len = vec.size(); | ||
_storage[vec.data()] = std::move(vec); | ||
} | ||
|
||
void foo_delete_storage(void* buffer) { | ||
_storage.erase(buffer); | ||
} | ||
``` | ||
|
||
`const_param` has to invoke copies, same as method 1 since we have no way of constructing a vector directly. Same goes for `mut_param`, and even though we might be tempted to reuse an existing buffer from a previous call to `foo_returns()`, that would be very bad in the case of the vector reallocating. | ||
|
||
### Pros | ||
- The `returns` case is nice | ||
|
||
### Cons | ||
- Still copies in the parameter case | ||
- Works for POD elements only (no vector<string>) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Another con is that it's really hard to make this pattern have a low time overhead, for many reasons, including cache misses on traversing the heap allocated nodes in the map There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah a flat_map or a vector would be better here, but this is just a sketch. Still your point is valid. |
||
|
||
## 3. opaquebytes wrapper type | ||
|
||
In this method we create an opaquebytes wrapper for a vector in order to pass between C and C++ and implement a custom interface over the held vector. | ||
|
||
```c++ | ||
|
||
// C++ | ||
using VecVariant = std::variant< | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm for this option but without the Variant wrapping all the types. By hand it would be pain, but if you're generating it, it doesn't My argument for this is, you'll get a little more safety (can't call get_float on a VecBool), and you'll be able to wrap vectors of non standard types, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah that's what I'm leaning towards too. I'll have to generate another source/header pair alongside the binding sources in order to create each type. Generating all these is going to be a project itself but I'll give it a go for the vector case now and see where I get to. |
||
std::vector<int>, | ||
std::vector<float>, | ||
std::vector<string>, | ||
>; | ||
|
||
class VectorWrapper { | ||
VecVariant _vec; | ||
|
||
public: | ||
VecVariant(){} | ||
|
||
<template typename T> | ||
VecVariant(std::vector<T>&& v) : _vec(std::move(v)) {} | ||
|
||
<template typename T> | ||
bool get(int i, T* value) const { | ||
if (auto* pvec = _vec.get_if<std::vector<T>>()) { | ||
*value = (*pvec)[i]; | ||
return true; | ||
} else { | ||
return false; | ||
} | ||
} | ||
|
||
int size() const { | ||
std::visit([](auto vec) -> size_t { | ||
return vec.size(); | ||
}, _vec); | ||
} | ||
|
||
}; | ||
|
||
void foo_returns(cppmm_VecWrapper* wrapper) { | ||
std::vector<float> vec = foo::returns(); | ||
// placement new move into the caller-allocated memory so as not to call | ||
// the destructor when this function returns. | ||
// I *think* this is safe. | ||
new (wrapper) VecWrapper(std::move(vec)); | ||
} | ||
|
||
// C | ||
void main(void) { | ||
VecWrapper vec_wrapper; | ||
foo_returns(&vec_wrapper); | ||
|
||
float element; | ||
cppmm_VecWrapper_get_float(&vec_wrapper, 0, &element) | ||
|
||
printf("size is %d and first element is %f\n", | ||
cppmm_VecWrapper_size(&vec_wrapper), element); | ||
|
||
cppmm_VecWrapper_destruct(&vec_wrapper); | ||
} | ||
|
||
``` | ||
|
||
### Pros | ||
- Zero copy | ||
- Can handle std::string elements as well (with appropriate functions on the wrapper) | ||
- Can ensure move-only in the Rust wrapper and represent as a slice | ||
|
||
### Cons | ||
- Adding new types just for the binding layer | ||
- Unsafe if wrapper object is copied in C then passed by value back to C++ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can't avoid this. Non trivial copies in C have to translate to explicit copy constructor calls in c++.
|
||
- List of types must be statically known and monomorphized in the binding layer (might be able to auto-generate this) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Enquiring minds wonder if
vector<string>
deserves its own discussion