Exploring the idea of a more modular alternative design to `std.mem.Allocator`

The thing that inspired this post:

I remember reading a blog post from matklad (IIRC that is, I can’t find it unfortunately), which introduced me to the concept of using arena v.s. gpa to manage the lifetime of internal allocations of functions.

Consider the following function:

pub fn doWork(...) struct {
    foo: []const u8,
    bar: *Thing, // might even contain references to other allocated data :O
} {
    ...
}

how would you manage the lifetime of .foo and .bar? In rust, doWork might receive a 'a lifetime and declare .foo and .bar to be under that lifetime, that is an okay solution, but there is an alternative in zig which I really like, by simply changing the signature of doWork to:

pub fn doWork(arena: Allocator, gpa: Allocator, ...) ...

where the returned .foo and .bar would be allocated under the arena, separated from other internal allocations under gpa.

What I really like about this approach is that, conventionally a gpa is an allocator capable of individual frees whereas an arena is not. By naming (we’ll get to that in a sec) the arguments this way, the function expresses that it will use the gpa to allocate data that are used internally, guaranteeing them to all be freed, whereas the arena will be used to allocate data that it is not responsible of freeing. This way the caller can confortably do something like:

// var arena_instance: std.heap.ArenaAllocator
// const arena = arena_instance.allocator()
{
    const foo, const bar = doWork(arena, gpa, ...);
    defer arena_instance.reset(...);

    // use foo and bar to do intersting stuff
    ...
}
// foo and bar and other inter-referenced memory are now freed
// carry on with other works
...

The issue:

Now despite how I love this pattern, an obvious point of awkwardness is that the differences between the arena and the gpa is only conveyed via their names. Imagine if the following is what you are presented with instead, it’d be not obvious at all how each allocators are expected to be used:

pub fn doWork(allocator_a: Allocator, allocator_b: Allocator, ...) ...
// or
pub const doWork: fn(Allocator, Allocator, ...) ... = ...;

obviously one should avoid irresponsible naming scheme like this, but on the other hand, I often find the zig’s standard library consisting of design choices that naturally encourage good programming patterns, maybe we can do better here as well.

Essentially what we want is a way to represent the capability of free isolated from allocate, via the type system.


Idea:

Before I stumbled uppon zig I was learning rust, and one idea I still really missed from the language is trait. Now I don’t intend to dive into the good and bad of how they implemented the system and whether should or shouldnot we add something simialr to zig, but I do like the abstract idea of separating each atomic properties of a type into a separate trait (e.g. Read + Write + Seek instead of a bigFile class).

Adapting that idea to std.mem.Allocator, what if instead of a single:

// std.mem
pub const Allocator = struct {
    ptr: *anyopaque,
    vtable: *const VTable,

    const VTable = struct {
        alloc: *const fn (...) ...,
        free: *const fn (...) ...,
        ...
    };
};

we split it up into:

// std.mem
pub const Allocate = struct {
    vfunc: *const fn (...) ...,
    pub fn create ...
    pub fn alloc ...
    ...
};
pub const Free = struct {
    vfunc: *const fn (...) ...,
    pub fn destroy ...
    pub fn free ...
};
// other capatibilities like remap and resize
...

Therefore, the doWork example function signature can be changed to something like:

pub fn doWork(
    // btw: I assume we should use intrusive interface
    // in this case, but I could be wrong.
    arena: *mem.Allocate,  
    gpa: struct { *mem.Allocate, *mem.Free },
    ...,
) ...

Just by looking at the signature, you can tell that doWork cannot free using the arena, and doWork will use the gpa to free!

Using it from the caller might look something like:

_ = doWork(
    &arena.interface.allocate,
    .{ &gpa.interface.allocate, &gpa.interface.free },
    ...,
);

or maybe if you are into something fancier

_ = doWork(
    arena.interface(.allocate),
    gpa.interface(.{ .allocate, .free }),
    ...,
);

We can extend the idea further, for example:

// std.array_list.Aligned

pub fn ensureTotalCapacity(
    self: *Self, 
    gpa: struct {
        in_place: ?*mem.resize.Expand, // optional
        relocating: *mem.Allocate,
    },
    new_capacity: usize
) ...

Notice the mem.resize.Expand. We could do mem.Resize, but I feel like there might be allocator implementations where an in-place shrink can be easier than an in-place expand, or vice versa.


Some potential issues/caveates:

  • With gpa: struct { *mem.Allocate, *mem.Free } it is totally valid to pass the two interfaces from two different allocators. Tho is this really a problem that concerns us? Since if a free frees a memory region not allocated by the allocator in debug/ReleaseSafe build, I would assume it’ll typically lead to a panic.
  • An allocator instance might have to effectively store multiple *const fn, as opposed to constructing a struct { ptr: *anyopaque, vtable: *const VTable }.
  • function signature and caller syntax can become more verbose
1 Like

Very pretty. I think I missed what concrete problem this proposal is solving, though?

I might have explained it rather poorly.

The thing is that the allocation pattern of gpa allocators and arena allocators can be very different. When interacting with third party libraries and even some of the std library functions, I often have to guess or investigate into internal implemenations to see if it is a good idea to give it an ArenaAllocator (or FixedBufferAllocator), without it using up too many memory due to failed frees (an examaple would be zon deserialization functions in std lib).

It’s fair to say that these (and the examples in the post) are just minor issues, but I still think it can be interesting to have a brainstorm about this idea I had :slight_smile:

Similarly it might be interesting to, for example, seperate std.Io.async and std.Io.concurrent into their own interfaces, and therefore a function signature would be enough to convey that whether it requires concurrency.

I haven’t worked with the new std.Io interface since its addition so am not familiar enough with it to provide more in depth thoughts, but I plan to return to zig programming, probably after 0.17 release :slight_smile:

This is already the case.

fn doWork(io: Io) Io.ConcurrentError!void;

Io.ConcurrentError has error.ConcurrencyUnavailable which lets the caller know that the function will fail if concurrency is unavailable. That’s more flexible than say

fn doWork(io: IoConcurrent) void;

because a function could have a synchronous or asynchronous fallback if concurrency is unavailable. e.g.

fn doWork(io: Io) void {
    _ = io.concurrent(doOtherWork, .{}) catch {
        // just do work here
    };
}

Oh, this is elegant!

std.heap.ArenaAllocator is able to free things, although only in a LIFO manner.

Calls to free an individual item only free the item if it was the most recent allocation, otherwise calls to free do nothing.

std.heap.ArenaAllocator docs
std.heap.ArenaAllocator.free

For optional concurrency I was more thinking about something like:

// both should be from the same io instance
fn doWork(io: struct { Io, ?Io.Concurrent }) void

But the idea of using Io.ConcurrentError to convey the requirement of concurrency is more neat than what I was imagining.

I am aware of that, the term “arena” I was using in the post is referring to a more abstract “arena allocation pattern” instead of our ArenaAllocator implementation.

Tho thanks for pointing it out still, I was going to clarify this but I guess I forgot :grinning_face_with_smiling_eyes:

1 Like

I do have a lot of functions that need a allocator for temp allocations as well as returning the result.

Currently I generally pass a single allocator that I use directly to allocate the result, and also to create a temp arena inside my function.

This is somewhat suboptimal though because I may end up with several arena on the call stack, and prevent reuse of the arena.

I could pass two allocators, but as you said it feels error prone. I’m also considering the pattern of passing a *Arena to function that could use one, but I don’t think it solves everything. Should the arena be assumed empty ? Is the caller or the callee reponsible to clean it? If it’s the callee how does it avoid freeing caller memory?

In the doWork example in my post the arena should only be for passing data out to the caller allocating data that are referenced by the function result, the function should ideally not allocate internal temporary data there, nor should the function reset the arena (therefore why later down the post the arena: Allocator can be replaced with arena: *mem.Allocate).

I have also written functions (I say functions but I think there’s really just one) which benefitted from a temp internal arena, but I allocate the arena within the gpa (the equivalent gpa in the doWork example), since the gpa is for any allocations (even allocations of allocators) that doesn’t outlive the function lifetime.