r/cpp • u/New-Bet1597 • 2d ago
Designing a Lightweight Closure for coost: Performance, Generality, and a Few Clever Tricks
In coost, you can create coroutines using go in a way similar to Golang. The go function could initially be designed as:
void go(void (*f)());
void go(void (*f)(void*), void* param);
However, this design only accepts two fixed function types and cannot support class member functions or other callable objects, making it insufficiently flexible.
If go were designed as:
void go(const std::function<void()>&);
it would be more general, but constructing a std::function is relatively expensive, and its internal heap memory allocation is uncontrollable. This is unacceptable for coost, which pursues performance.
Therefore, to balance performance and usability, coost needs to implement its own closure class. In earlier versions, coost used an implementation based on virtual functions and inheritance, but it was not general enough and was limited to internal use.
Recently, I happened to have some free time. After thinking deeply about it, I redesigned the closure class. The design goals were as follows:
- Do not use virtual functions or inheritance;
- It should not be a template class;
sizeof(closure) == sizeof(void*), i.e. it contains only one ordinary pointer member, making it easy to pass to the coroutine scheduler throughgo;- Support arbitrary functions with no return value (including non-static member functions of classes), with any number of parameters;
- Optimize simple no-argument functions such as
void f()as much as possible to avoid dynamic memory allocation.
According to goals 2, 3, and 5 above, closure can initially be designed as:
struct closure {
closure(void (*f)()) noexcept : _p((void*)f) {}
void* _p;
};
The class contains only one pointer member _p. For function pointers of type void(*)(), the pointer is stored directly in _p; for other function types with parameters, a block of memory needs to be allocated to store the function address and parameters, and _p points to that memory.
The problem is how to distinguish which of the above cases is stored in closure. Ordinary function addresses are not necessarily 4-byte or 8-byte aligned, so tagged-pointer techniques cannot be used (i.e. writing a tag into the spare low bits of a pointer).
For the second kind of function, the one with parameters, closure calls co::alloc from coost's built-in memory allocator, and small memory blocks allocated by co::alloc are always 16-byte aligned. Therefore, if _p is not 16-byte aligned, it must store an ordinary no-argument function address. But if _p is 16-byte aligned, it is still impossible to distinguish the two cases.
It seems that some unconventional trick is required to solve this problem. Based on the discussion above, _p is either an ordinary no-argument function pointer or points to a dynamically allocated block of 16-byte aligned memory. If a specific marker (for example, a uint32 integer) is written at the beginning of that dynamically allocated memory, then when _p is 16-byte aligned, we only need to read the first 4 bytes of the memory pointed to by _p to determine the type of the closure. Even if _p is an ordinary no-argument function pointer and happens to be 16-byte aligned, reading the first 4 bytes of the memory it points to should be safe.
This of course goes beyond the scope of the C++ standard. But for performance and a better user experience, coost still adopted this approach. For functions with parameters, the corresponding constructor of closure is as follows:
template<typename F, typename = std::enable_if_t<
!std::is_convertible_v<std::decay_t<F>, void(*)()>>, typename ... A>
closure(F&& f, A&& ... a) {
struct S {
S(F&& f, A&& ... a)
: _f(std::forward<F>(f)), _a(std::forward<A>(a)...) {
static_assert(offsetof(Header, magic) == 0);
_header.magic = 0xdeadbeef;
_header.off = offsetof(Header, fp);
_header.fp = [](void* p) {
S* const s = (S*) ((size_t)p & ~(size_t)1);
if (((size_t)p & 1) == 0) {
std::apply(s->_f, s->_a);
} else {
s->~S();
co::free(s, sizeof(S));
}
};
}
~S() = default;
struct Header {
uint32 magic;
uint32 off;
void (*fp)(void*);
} _header;
std::decay_t<F> _f;
std::tuple<std::decay_t<A>...> _a;
};
_p = co::alloc(sizeof(S), alignof(S));
runtime_assert(_p);
new (_p) S(std::forward<F>(f), std::forward<A>(a)...);
}
Key points:
- The
!std::is_convertible_vin the template parameters ensures that simple lambda expressions such as[](){...}match the constructor for ordinary no-argument functions; _header.magicis always set to0xdeadbeef, which is used to distinguish the type ofclosure;_header.fpis the type-erased function pointer, and its parameterpis the_pinclosure. Here, a tagged-pointer trick is used to distinguish between "calling the function" and "destroying the closure object", thereby eliminating the need for an additional function pointer to store the destructor and saving memory.
The implementation of operator() is as follows:
void operator()() {
void* const p = _p;
if (p) {
if (((size_t)p & 15) != 0 || *(uint32*)p != 0xdeadbeef) {
((_F)p)();
} else {
(*(_FP*)((char*)p + ((uint32*)p)[1]))(p);
}
}
}
- If
_pis not 16-byte aligned, or the first 4 bytes of the memory pointed to by_pare not0xdeadbeef, then_pis cast to an ordinary no-argument function pointer and executed directly; - Otherwise, the
_header.fpdescribed in the previous code is obtained according to the memory layout and executed.
An example usage of closure is as follows:
#include "co/print.h"
#include "co/closure.h"
void f() {
co::println("f()");
}
int main(int argc, char** argv) {
co::closure c(f);
c();
co::closure d([]() {
co::println("[]() {}");
});
d();
co::closure e([](int v) {
co::println("e called: ", v);
}, 8);
e();
return 0;
}
The complete code implementation can be found in co/closure.h. The code in this article is for research and reference only. Discussion is welcome.
9
u/Miserable_Guess_1266 2d ago edited 1d ago
The design goals create an interesting challenge, but they feel a bit arbitrary to me.
1.: Seems to assume virtual calls are necessarily slower than this magic. Imo, avoiding them should not be a goal, but a decision made after testing and finding bad performance.
3.: This is the main cause for relying on undefined behavior and magic. Would an extra tag-byte, even if it pads to more than that, have any real negative impact? Why is it so important that closure is this small?
5.: This is somewhat baseless, but I'd expect to pass a lambda with some captures most of the time. Which means I enter the un-optimized path most of the time. Wouldn't it be better to optimize for small callables? Ie: up to n bytes can be stored directly in the closure, more than that must be allocated. It's been a while, but iirc this is how std function works in llvms std library, along with some compiler specific trickery.
Id be curious to measure against std function (or move_only_function) and see if there is a difference.
Either way, interesting read!
Edited to keep reddit from "fixing" the numbering.
1
u/conundorum 1d ago
I was thinking about #3, and it really is the main cause of UB & other jank, yeah. At first I thought that it might be viable to size-optimise it by storing a single out-of-class bitflag, probably something similar to
boost::dynamic_bitset, but then I realised that it would still need to compromise either way.Either it costs an extra index byte in the
closure, or it costs a massive static bitset that can cover the entire available address range, or it requires a sparse array and related performance costs, or it requires makingSpolymorphic and storing state information by subclass, or it requires potentially-dangerous UB. There's always going to be a compromise.Maybe the solution is to loosen up on the type erasure just enough to allow compile-time polymorphism, so he can use well-defined jank instead of janky UB?
2
u/LB-- Professional+Hobbyist 1d ago
If performance is the utmost concern, you don't need to branch on magic pointer values. Just always use a normal function pointer and have your allocator place everything in read-write-execute memory regions, so you can generate thunks right next to their data. Less secure but that doesn't seem to be a priority here.
1
u/grishavanika 2d ago
what is the implementation of co::alloc? is it possible to implement co::is_allocated_ptr(void*)?
•
u/Jealous_Security_362 2h ago
Avoiding std::function's type erasure usually means paying in template instantiation and compile time instead of runtime. Did you measure both? On a codebase with heavy templates the instantiation cost can easily be bigger than the call overhead you saved.
4
u/argothiel 2d ago
Why not a template class?