And yes, of course you would allocate your nodes from a linear contiguous (but possibly chunked) fix size bump allocator first.
if(this->size >= 0){
return lastLinkedSlab[this->size--];
} else {
auto old = lastLinkedSlab;
lastLinkedSlab = lastLinkedSlab->next;
delete old;
if(lastLinkedSlab == nullptr){
lastLinkedSlab = new LinkedSlab();
}
this->size = 16384 - 1;
return lastLinkedSlab->heap[this->size--];
}
Or something like that. I'm not really seeing how this scheme incurs many indirect pointer references.> And yes, of course you would allocate your nodes from a linear contiguous (but possibly chunked) fix size bump allocator first.
Hmm, that's probably a bit more efficient, but probably not a big deal because its a "once ever" optimization. You usually want an optimization to loop over itself a few times...
struct node { node* next; };
static node * root;
void * alloc() {
node * result = root; // 1 load
if (result) { // fast path
root = result->next; // 1 load + 1 store
return result;
} else {
//slow path
}
}
There is a single load in the critical path (loading the content of result); the additional load of result next and the store are not in the critical path as the result does not depend on them. Also loading the content of result->next will touch a cacheline that will be accessed very soon by the caller of alloc anyway.In the queue case you have:
struct queue { void ** data; int size; }
static queue q;
void * alloc() {
int sz = q.size -1; // 1 ld [a] + 1 sub
if (sz >= 0) { // fast path
size = sz; // 1 store
void ** data = q.data; // 1 ld [b]
return data[sz]; // 1 ld [c]
} else { /* slow path */ }
}
In this case the chain b->c is in the critical path (a can be in parallel with b, and the sub has minimal latency); the store is not in the critical path. So as you can see there is an additional dereference. Also it will touch the additional cacheline pointed by data+sz.If you are doing many allocations in a very tight loop, then the store in the linked list example suddenly becomes part of the critical path and can become a problem, while the compiler can extract some parallelism from the size computation in the queue example. If that scenario is important for you then the queue can be preferable.
struct Node{
char rep[64]; // 64-bytes per node
};
struct StackLink {
struct StackLink* prev;
Node* nodePtrs[4096];
};
static StackLink* last; // Points to nullptr when empty
static int stackLinkSize;
It'd be a stack, not a queue. So I changed the name to "stack" to better represent the strategy. stackLinkSize doesn't need to be part of the link structure.
That's why the fast path is simply: return root->nodePtrs[stackLinkSize--];
This basically breaks down into: dereference(root + offsetOf(nodePtrs) + stackLinkSize * sizeof(Node))
Just one dereference, if I'm counting correctly.I think I agree with you that "size" is dereferenced (for dereference #2), but since its "Hot" I don't think it would be the bottleneck for anything. L1 references are very fast.
The stack case will touch three cachelines (root, nodeptrs and the final block). The linked list only two (there is no nodeptrs).
TBH it would be very hard to design a nonncompletely artificial benchmark were une desig make a difference compared to the other. Only way is test on an actual application and check what fits best.
I think I can agree to that.
I should note that I designed this allocator to be used on the GPU. "Size" is shared between a wavefront / SIMD unit. popcnt(exec_mask) determines how many items to alloc at a time. (if 200 threads call alloc, then they will collectively grab 200 pointers all at once, by doing size -= popcount(exec_mask)).
Given the higher latency on linked lists, and the relative simplicity of calculating the stack-size needed, it doesn't seem like going through the linked-list traversal is the best plan to me.
If you repeatedly need extra space for "additional slabs", I suggest an unrolled linked list (https://en.wikipedia.org/wiki/Unrolled_linked_list). Each 1MB slab with 64B blocks gets associated with a new std::array<void*, 16384>. This keeps the relative simplicity of a linked list (O(1) push / pop operations), while keeping the cache-friendliness of a normal array.