A defer-safe way to allocate nested arrays

Use case: I’m generating MIP levels of a texture. Type hierarchy:

const Texture = struct {
    levels: []Level,
};
const Level = struct {
    width: u16,
    height: u16,
    pixels: []Color,
};
const Color = struct {
    r: u8,
    g: u8,
    b: u8,
};

I can allocate levels in my init:

const levels = try allocator.alloc(Level, n_levels);
errdefer allocator.free(levels);

But how do I allocate levels themselves such that everything gets freed if I fail in the middle of the process?

for (0..n_levels) |i| {
    const level_size = some.math();
    levels[i] = try allocator.alloc(Color, level_size);
    // cannot errdefer here, will go out of scope before doing anything
    populate(levels[i]);
}

One workaround that comes to mind is

const levels = try allocator.alloc(Level, n_levels); // returns uninitialized
// initialize each item such that it is safe to free
for (levels) |*level| {
    level.* = .{
        .width = 0,
        .height = 0,
        .pixels = &.{},
    };
}
errdefer freeLevels(allocator, levels);

...

fn freeLevels(allocator: std.mem.Allocator, levels: []Level) {
    for (levels) |level| {
        allocator.free(level.pixels);
    }
    allocator.free(levels);
}

It seems like this should work, but that’s a lot of boilerplate which only becomes more convoluted with depth. Is there a better way?

I think you should first try to use defer, in some situations it can be used instead of errdefer; there are situations where errdefer is really needed, but when defer suffices then the code usually becomes a lot simpler. (because both erroring and successfull code paths end up using the same code to handle the resources)

const Texture = struct {
    levels: []Level,
};
const Level = struct {
    width: u16,
    height: u16,
    pixels: []Color,

    pub const empty: Level = .{
        .width = 0,
        .height = 0,
        .pixels = &.{},
    };
};
const Color = struct {
    r: u8,
    g: u8,
    b: u8,
};

fn example(allocator: std.mem.Allocator, n_levels: u8) !void {
    const levels = try allocator.alloc(Level, n_levels);
    defer allocator.free(levels);
    @memset(levels, .empty);
    defer for (levels) |*level| allocator.free(level.pixels);

    for (levels, 0..) |*level, size| {
        const s: u16 = @intCast(size);
        level.* = .{
            .width = s,
            .height = s,
            .pixels = try allocator.alloc(Color, size * size),
        };
    }
}

test Level {
    const length: u8 = 255;
    const allocator = std.testing.allocator;
    try std.testing.checkAllAllocationFailures(allocator, example, .{length});
}

const std = @import("std");

Also remember to write tests that use std.testing.checkAllAllocationFailures to make sure you have handled all possible failure states. (Like for example half initialized state that is being deinitialized, which is easy to get wrong when using errdefer)

2 Likes

Multiple ways:
A: you could have arena that you store in the texture so you can free everything at one go by deiniting the arena
B:

var n_levels_allocated: usize = 0;
errdefer freeLevels(allocator, levels[0...n_levels_allocated]);
for (0..n_levels) |i| {
    const level_size = some.math();
    levels[i] = try allocator.alloc(Color, level_size);
    // cannot errdefer here, will go out of scope before doing anything
    populate(levels[i]);
    n_levels_allocated += 1;
}

C: Allocate the levels + all the memory needed to hold pixel data as single interleaved chunk
D: Make levels not dynamic since it’s probably low number of elements anyhow like 3-5, you still would need a cleanup strategy for the pixels.

4 Likes

You do not need to define a function. Use a block.

const levels = try allocator.alloc(Level, n_levels);

for (levels) |*level| level.pixels = &.{};

errdefer {
    for (levels) |level| allocator.free(level.pixels);
    allocator.free(levels);
}
1 Like

So, since mip levels are half of the width and height of the previous level, you don’t really need to have the Level struct store width and height; the index of the current mip level allows you to derive its width and height.
In a similar vein, the index of the current mip level can also allow you to derive its offset into the pixel buffer.
The result might end up looking like this:

const Texture = struct{
	resolution_x: u16,
	resolution_y: u16,
	pixels: [*]Color,
	miplevel_count: u16,
	
	/// Get the slice of the pixel buffer representing a given miplevel.
	/// Prefer calling .deinit() instead of freeing the returned slice.
	pub fn miplevel(self: Texture, miplevel_index: usize) []Color {
		var i_start: usize = 0;
		var current_res_x: usize = @as(usize, self.resolution_x);
		var current_res_y: usize = @as(usize, self.resolution_y);
		for(0..miplevel_index + 1) |_| {
			i_start += current_res_x * current_res_y;
			current_res_x = @divExact(current_res_x, 2);
			current_res_y = @divExact(current_res_y, 2);
		}
		return self.pixels[i_start..i_start + current_res_x * current_res_y];
	}
	
	/// The total number of pixels across all mip levels.
	/// Useful for allocating and freeing the texture's pixel value.
	pub fn pixel_count(self: Texture) usize {
		var out: usize = 0;
		var current_res_x: usize = @as(usize, self.resolution_x);
		var current_res_y: usize = @as(usize, self.resolution_y);
		for(0..self.miplevel_count + 1) |_| {
			out += current_res_x * current_res_y;
			current_res_x = @divExact(current_res_x, 2);
			current_res_y = @divExact(current_res_y, 2);
		}
		return out;
	}
	
	pub fn init(
		alc: std.mem.Allocator,
		resolution_x: u16, resolution_y: u16,
		miplevel_count: u16,
	) error{OutOfMemory}!Texture {
		var out: Texture = .{
			.resolution_x = resolution_x,
			.resolution_y = resolution_y,
			.pixels = undefined,
			.miplevel_count = miplevel_count,
		};
		const pixels = try alc.alloc(Color, out.pixel_count());
		out.pixels = pixels.ptr;
		return out;
	}
	
	pub fn deinit(self: Texture, alc: std.mem.Allocator) void {
		alc.free(self.pixels[0..self.pixel_count()]);
	}
};

test Texture {
	var texture: Texture = try .init(
		std.testing.allocator,
		1024, 1024,
		3,
	);
	defer texture.deinit(std.testing.allocator);
	
	for(0..3) |miplevel| {
		const miplevel_slice = texture.miplevel(miplevel);
		std.debug.print(
			\\MIPLEVEL {d}
			\\BEGIN: 0x{X}
			\\LEN: 0x{X}
			\\
		, .{
			miplevel,
			@as(usize, @intFromPtr(miplevel_slice.ptr)) -
			@as(usize, @intFromPtr(texture.pixels))
			,
			miplevel_slice.len,
		});
	}
}

Assuming that textures will be largely static, you could probably improve performance by storing pixel_count and/or a buffer of miplevel pixel offsets as a struct field.

1 Like