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);
@niklas-ourmachinery
Copy link

This doesn't seem that different from what we are doing. You are passing allocator_handle * and alloc() to submodules in module_import, which is exactly what we are doing, except we have them wrapped up in a struct:

typedef struct tm_allocator_i
{
    tm_allocator_o *inst;
    void *(*realloc)(tm_allocator_o *inst, void *ptr, uint64_t old_size, uint64_t new_size,
        const char *file, uint32_t line, uint32_t scope);
} tm_allocator_i;

Then in your my_alloc you have a switch that looks at data in the allocator_handle and calls out to different actual allocator functions based on the type in the allocator handle. We could do that too in our realloc() implementation of course, but I don't see what you get from that extra level of indirection. If you want to use the trace allocator (for instance), why not just do:

struct module_import im;
im.persistent_allocator = trace_allocator_handle;
im.alloc = my_trace_alloc;
im.free = my_trace_free;
// ...

The only drawback I see here is that if you want to pass multiple allocators (temp_allocator, pers_allocator) you now have to pass separate alloc functions too, for the different allocators, so there's more typing:

im.persistant_allocator = trace_allocator_handle;
im.persistant_alloc = my_trace_alloc;
im.temporary_allocator = stack_allocator_handle;
im.temporary_alloc = my_stack_alloc;
// ...

But don't you kind of have to do that anyway? Because what if your temp_allocator wants to use the system alloc function and the pers_allocator wants to use your user implemented my_alloc().

And the drawback can be fixed by wrapping the handle and the function(s) up in a struct so you can pass them as a single argument. You could call this struct something like tm_allocator_i ;)

im.persistant_allocator = my_trace_allocator;
im.temporary_allocator = system_stack_allocator;

@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