Layouts and dealloc: An unfortunate aspect of Rust’s allocation API

Note: This assumes you have familiarity with Rust, memory allocation, etc.

Overview

Rust allocation requests are made using a type called Layout, which is basically a struct containing both size and alignment. Broadly speaking, I like this. It allows for consolidating boilerplate like the arithmetic needed to allocate storage for multiple objects using a single request (as we’ll see later).

Rust allocator design then boils down to something like this:

unsafe fn alloc(layout: Layout) -> *mut u8;
unsafe fn dealloc(ptr: *mut u8, layout: Layout);
// Note: everything I'm about to say for `dealloc` applies to `realloc` too.

(Note: there’s a redesign in the pipeline, but fundamentally it doesn’t change what I’m about to talk about, yet).

Of these, dealloc is the one I’d like to talk about, as alloc’s issues are largely being dealt with (… probably anyway. I haven’t really been paying close attention but I did at least see discussion around the issues).

Specifically, dealloc takes a Layout as it’s argument. Further, it’s undefined behavior to provide dealloc with a Layout other than the one that was used to request it.

This is a design mistake, although it took me a while for me to figure out why I feel this way.

Problem 1: It’s not necessary

First off, the vast majority of memory allocators in existence don’t actually need this information to free a pointer. You can just pass it to their free function and be done with it.

That said, some allocators do need this. I guess the allocator Rust uses on Windows does, which I assume is the root cause of this.

I’ve looked around and couldn’t find others, but it’s not that hard to imagine they exist somewhere – once case I could imagine is using a different (and incompatible) allocation API depending on alignment, and maybe you could even imagine one that does the same thing base on size, although that sounds pretty dodgy if you ask me.

So, I’ve just disproven my argument right? Well, no. It’s fairly trivial to adapt any allocator which needs this into one that doesn’t, by stashing the layout somewhere. To put my money where my mouth is, I’ve provided an implementation of this in the appendix.

Problem 2: Complicates unsafe code, easy source of UB

So why is it bad as opposed to merely neutral? This is pretty cut and dry for me: Providing the wrong value for that argument is undefined behavior. Any parameter like this should have to to justify its existence rather than the other way around.

In this case, given that most allocators don’t need it, and the few that do can be easily shimmed… It’s hard for me to see the justification here.

But maybe you disagree, or think “okay but what’s the big deal, it’s easy to compute”. Well, it can be pretty involved to compute (at least prior to the stabilization of some of the methods on Layout), and often authors use different code paths for computing it for allocation vs deallocation, as the latter can use unchecked arithmetic and from_size_align_unchecked.

Additionally, if you use realloc, now you need to fabricate a Layout to use, as realloc’s signature doesn’t even take a second layout, just the first. Why? Well… implementing realloc by alloc() and then dealloc() is common, after all.

Problem 3: Prevents valid use cases

Okay, I’m going to cut this one short since I think the other 2 are enough, and because last time I brought stuff up like this, I got some very pedantic comments about why what I wanted to do was bad code (Please, keep rule 4 of the Rust code of conduct in mind).

But quickly, a few examples, because I didn’t learn my lesson I guess.

  1. Sometimes, you want a Box<[T]> or Vec<T> with alignment stricter than the alignement of the items inside it, either to use aligned reads / SIMD operations, etc. This is very disappointing when it comes up since there’s no satisfying answer TBH, and often leads to people just assuming the allocator will always provide >= 16 alignment.

  2. You want to reuse the memory of a Vec<T> when producing a Vec<U>. You could even imagine certain FromIterator specializations here, although that seems tricky and I mostly mean producing an empty Vec<T> from a Vec<U> similar to how you might use with_capacity (example).

  3. You’re interacting with a C library which allows you to provide a callback for freeing your type’s memory, but it doesn’t provide enough information to produce the correct Layout without allocating extra space for it.

  4. Similarly, one of the reasons Box<std::ffi::CStr> cannot be soundly made to be a single pointer, is that deallocation would use the wrong Layout. There may be other reasons here, but this stopped me from investigating further when I tried.

Closing thoughts

Anyway… That’s basically it. I don’t think the Layout parameter would close to justifying it’s existence unless it were ubiquitous in existing allocators, or difficult/impossible to implement without substantial overhead. As it is, it’s rarely needed, and easily shimmed. I did a bit of looking, and this discussion hadn’t come up yet anywhere I could find on allocator-wg, but I also didn’t look too hard.

This was just something that I had to deal with today, and figured I should channel my annoyance into a blog post.

Appendix: How to wrap allocators which need Layout info in dealloc

Some caveats: this is lazy about error handling, and could be done more efficiently, and perhaps obviously, it’s not a complete wrapper (realloc / alloc_zeroed both exist).

use std::mem::size_of;
use std::alloc::{
    Layout,
    alloc as underlying_alloc,
    dealloc as underlying_dealloc,
};

// This is basically a struct holding the arguments
// we need to pass to `dealloc`. I think it is possible
// to do this while storing less, but this is trivial to
// implement and proves the point.
#[derive(Copy, Clone)]
struct AllocInfo {
    layout: Layout,
    ptr: *mut u8,
}

unsafe fn wrapped_alloc(layout: Layout) -> *mut u8 {
    // Compute a layout sufficient to store `AllocInfo`
    // immediately before it.
    let header_layout = Layout::new::<AllocInfo>();

    let (to_request, offset) = header_layout.extend(layout)
        .expect("real code should probably return null");

    let orig_ptr = underlying_alloc(to_request);
    if orig_ptr.is_null() {
        return orig_ptr;
    }

    let result_ptr = orig_ptr.add(offset);
    // Write `AllocInfo` immediately prior to the pointer we return.
    // This way, we always know where to get it for passing to
    // `underlying_dealloc`.
    let info_ptr = result_ptr.sub(size_of::<AllocInfo>()) as *mut AllocInfo;
    info_ptr.write_unaligned(AllocInfo {
        layout: to_request,
        ptr: orig_ptr,
    });
    result_ptr
}

unsafe fn wrapped_dealloc(ptr: *mut u8) {
    assert!(!ptr.is_null());
    // Simply read the AllocInfo we wrote in `alloc`, and pass it into dealloc.
    let info_ptr = ptr.sub(size_of::<AllocInfo>()) as *const AllocInfo;
    let info = info_ptr.read_unaligned();
    underlying_dealloc(info.ptr, info.layout);
}