Skip to content

Instantly share code, notes, and snippets.

@vurtun
Last active September 13, 2023 11:42
Show Gist options
  • Save vurtun/5ede1dc46f30e2ba499da3843792fdbb to your computer and use it in GitHub Desktop.
Save vurtun/5ede1dc46f30e2ba499da3843792fdbb to your computer and use it in GitHub Desktop.

This is a short post related to Opaque object representations from Our Machinery. I have to begin that my understanding of how Our Machineries tech is somewhat limit and it is currently rather late in the day, so please bear with me. As far as I understood how modules work is similar to how Quake 2 did their modules. A module has both a struct for export and one for import containing a number of function pointer. At load time the module gets a filled out import struct and fills out its own export struct with its own functions and returns it.

As for the allocators. I would have guessed that all engine specific allocators are defined internally in the engine inside the foundation library. On load time each module would request a number of alloactors (for example for different use cases). This could include just a functions pointer and void pointer or a handle/interface:

struct module_import im;
im.temporary_allocator = temp_allocator;
im.persistent_alloactor = pers_allocator;
// ...
struct module_export ex = load_module(&im);

Now the problem is that we want the user of the engine to also be able to define allocators. One way is to include callbacks in each allocator or have a vtable. My idea to have one function inside the library mapping from allocator type to alloc or free:

enum tm_allocator_type {
    TM_ALLOCATOR_TYPE_ENGINE = 0x100000
    TM_ALLACTOR_TYPE_ARENA,
    TM_ALLACTOR_TYPE_BLOCK,
    //....
};

struct tm_allocator_handle {
    int type;
}

void* tm_alloc(tm_allocator_handle* handle, size_t size)
{
    switch(handle->type) {
    case TM_ALLACTOR_TYPE_ARENA: {
      struct tm_arena_allocator =  (struct tm_arena_allocator)handle;
      return tm_arena_alloc(size);
    }
    break;
    //....
    }
}

This allocator handle and alloc functions can be passed to any module.

struct module_import im;
im.temporary_allocator = temp_allocator;
im.persistent_alloactor = pers_allocator;
im.alloc = tm_alloc;
im.free = tm_free;
// ...
struct module_export *ex = load_module(&im);

However so far it is limited to only those alloactor types that are defined inside the engine. However it is also possible for anyone else to define their own versions (all internal allocator type begin on an offet while user alloactor begin at 0):

enum my_allocator_type {
    MY_ALLOCATOR_STACK,
    MY_ALLCATOR_SYSTEM.
    MY_ALLCATOR_TRACE
};

void my_alloc(struct tm_allocator_handle* handle, size_t size)
{
    switch(handle->type) {
    default: return tm_alloc(handle, size);
    case MY_ALLOCATOR_STACK: {
        struct my_stack_allocator =  (struct my_stack_allocator)handle;
        return my_stack_alloc(size);
    } break;
    //....
    }
}

Now just like with our engine allocator tm_alloc and tm_free functions we can also pass our own my_alloc and my_free to any module that needs an allocator while being able to use either the engine or my own allocators

struct module_import im;
im.temporary_allocator = temp_allocator;
im.persistent_alloactor = pers_allocator;
im.alloc = my_alloc;
im.free = my_free;
// ...
struct module_export ex = load_module(&im);
@vurtun
Copy link
Author

vurtun commented Feb 13, 2019

I am not 100% sure if I am missing something. But in my example there always would only be one alloc and one free function passed to any module. Important is only that the external my_alloc functions can map to both engine specific allocator functions in the default case and all of its own allocator functions.

For example the important bits of an external trace allocator would just look like this:

enum my_allocator_type {
    MY_ALLCATOR_STACK,
    MY_ALLCATOR_TRACE
};

struct my_trace_allocator {
    struct tm_allocator_handle self;
    struct tm_allocator_handle *allocator;
    void (*alloc)(struct tm_allocator_handle* handle, size_t size);
    /* .... */
};

void my_alloc(struct tm_allocator_handle* handle, size_t size)
{
    switch(handle->type) {
    default: return tm_alloc(handle, size);
    case MY_ALLOCATOR_STACK: {
        struct my_stack_allocator *stk  =  (struct my_stack_allocator)handle;
        return my_stack_alloc(stk, size); // does not call any callbacks but directly allocates memory
    } break;
    case MY_TRACE_ALLOCATOR: {
        struct my_trace_allocator* trace = (struct my_trace_allocator*)handle;
        return my_trace_alloc(trace,  size); // calls `alloc` function pointer (my_alloc in this case) internally
    } break;
    }
}

struct tm_allocator_handle* arena_allocator_handle = tm_new_arena_allocator(...);
struct tm_allocator_handle* trace_allocator_handle = my_new_trace_allocator(arena_allocator_handle, my_alloc);
struct tm_allocator_handle* stack_allocator_handle = my_new_stack_allocator(...)

struct module_import im;
im.temporary_allocator = stack_allocator_handle;
im.persistent_allocator = trace_allocator_handle;
im.handle_allocator = arena_allocator_handle;
im.alloc = my_alloc;
im.free = my_free;
// ...

Sidenote: The trace allocator is a litle bit special since it has to be able to abstract away any other allocator, while other allocators (at least for me) often only call out to a specific alloactor type (for example my arena allocator only would call out into block allocator which in term would could out into a virtual page allocator)

The advantage for me of having these mapping functions is that there is less wrestling with vtables for both engine or external developers. Also object function pointer invalidation is not a problem since there is only one functions that will always be called.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment