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.

Edit: I found it, but it’s not a blog post, and the pattern is slightly different than what I remembered: Allocators: Best Practices & Anti-Patterns - #5 by matklad

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
    };
}
5 Likes

Oh, this is elegant!

1 Like

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?

1 Like

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.

Ok I misread, but then the roles are just reversed wrt to what I said. In any case you have a temporary allocator and an out allocator. I’m not sure why it’s so important to you that the out allocator must only be an arena then.

I think you’re conceptually missing something in your descriptions of different types of allocator.

The purpose of the std.mem.Allocator generic type is to allow functions to be written without caring about about which type of allocator they’re using. The same code runs fine under a GPA, or an arena, or a fixed-buffer allocator. It’s down to the caller to determine the strategy for memory allocation. That’s part of the idea of writing code that can be reused in multiple scenarios.

As soon as you rely on an allocator being an arena, and therefore write code that never releases anything, you’ve placed a constraint on where your code can be used. I think your type tree takes you even further down this road by stating arena allocators must be explicit.

7 Likes

Except that it can be pretty difficult to make code play well with all kinds of allocators.

Sure, sometimes it’s easy, when the allocation pattern is simple (e.g. the most trivial case: a function that makes one allocation).
But if your code has to deal with a messed up web of object lifetimes, then it’s gonna be very difficult to have no leaks with a GPA, or on the other hand if it uses a container like an arraylist then it’s gonna use way more memory with an arena then with an allocator capable of freeing.

I wanted to bring up some examples of even the std lib having arena: Allocator function arguments, but looking at it now it seems the one I first remember running into is no longer there. Another one still is however, in std.process.Args: pub fn toSlice(a: Args, arena: Allocator) ToSliceError![]const [:0]const u8

Looking through the standard library though, it seems that most uses of that pattern are farther away from the user facing parts of the stdlib, so I’m assuming there might be an effort to move away from it?

2 Likes

I don’t think you get the idea. It is not dependency over the allocator being an ArenaAllocator, or FBA, or any of the other concrete allocator implementations.

It is simply splitting std.mem.Allocator into multiple modular interfaces, Allocate, Free and Resize, thus functions can clearly express that for a certain allocator argument, for example, it only need the allocate side of the allocator, not the free.

Just because something runs fine doesn’t mean the code is correct. E.g., in dynamic data structures like ArrayList, the allocator parameter is named gpa to hint to users not to use the bump arena allocator, as that can easily waste half the memory. But this kind of naming-only constraint is easy to overlook, leading developers to write code that seems to work but is actually quite suboptimal.

3 Likes

Here’s a random example of miss-use of an ArenaAllocator in the place of a gpa:

kostya/benchmarks/brainfuck/bf.zig#L190 (github)

I guess for this kinds of “language comparison” projects you usually don’t expect the best code quality :grinning_face_with_smiling_eyes:, but for example if let’s say ArenaAllocator is changed to only expose an Allocate interface and no Free, funny mistakes like this would be less likely to happen.

Something to consider is that std design has very different constraints from those in your own projects. In the std, being general when reasonable (so, taking Allocator in this case) is good because it works for more use cases. You however know your use cases, and so can easily impose constraints on use cases to simplify your code. For memory allocator, you can trivially pass around pointers to ArenaAllocator instead of the generic Allocator. If you don’t need or want a different allocator for that code, just constrain it as such.

4 Likes

I think that there is actually something wrong with your pattern. Really you shouldn’t be passing in two separate allocators. An arena is backed by another allocator, and you should be storing the arena inside of your struct. I’m really not sure what situations you are rubbing into where a struct has allocations that live one lifetime, but owned data last lives another. I would think of you were in a situation where you would need a field to persist beyond the lifetime of the struct that you would implement something such as toOwned where the recipient is returned the valid pointer and the originating data, and the struct sets either a null pointer or runs deinit.

I think that you are trying to solve a problem through the lenses that rust provides to you instead of the tools and patterns that full manual memory management provides to you.

1 Like

Is it though? Sure there are particular cases where gpa is better then arena performance wise, and there is a lot of nuance, but your comment to me seems to conflate performance with correctness. Allocators have several characteristics beyond Just performance that may make it the more correct choice, such as arenas modeling a particular lifetime of related data.