Possible auto layout inlining

I’ve been working on a lightweight database/kernel of sorts, and I’ve really come to enjoy the semantics of payload captures in switch statements on tagged unions. The only thing that makes me a bit sad is that I don’t think Zig supports like auto layout awareness outside the type’s scope so Cmd goes from ideal 16 bytes to 20, and Resp goes from ideal 8 bytes to 12.

I know that if the compiler tried to do this without any help, a new child struct that contains the parent would create a dependency mess and incremental compilation and complexity would blow up, but I was thinking, what if there was an inline keyword in the subfield to say “hey, this struct and its child are 1:1 correlated and optimize them together”, which keeps the problem scoped well.

I know this is a help request (I just joined Ziggit, so I don’t have Brainstorm perms), but I was just thinking that this would be super nice to have or maybe there is already some way to do it I’m not quite sure.

EDIT: We were discussing this on the Zig discord, and I wanted to clarify that tag is not the enum tag, it’s like inflight number used so I can support asynchrounous commands and batching, kinda like io_uring user_data field.

What I’m thinking is something along the lines of: action: inline union(enum(u8))

pub const Cmd = struct {
    tag: u8,
    action: union(enum(u8)) {
        alloc,
        alloc_mult: u16,
        free: mem.PageId,
        free_mult: mem.PageId,

        read: struct { table: Table, key: u32 },
        modify: struct { table: Table, key: u32, page: mem.PageId },
        append: struct { table: Table, page: mem.PageId },
    },
};

pub const Resp = struct {
    tag: u8,
    action: union(enum(u8)) {
        alloc: mem.PageId,
        alloc_mult: mem.PageId,
        free,
        free_mult,

        read: mem.PageId,
        modify,
        append: u32, // key
    },
};

comptime {
    @compileLog("", @sizeOf(Table)); // 1
    @compileLog("", @sizeOf(mem.PageId)); // 4
    @compileLog("", @sizeOf(Cmd)); // 20
    @compileLog("", @sizeOf(Resp)); // 12
}

Relying on hints for something you want explicit control over is sub optimal, more so if you can already control it.

For this situation you can have a separate tag and union fields (not tagged union). To get those captures, use a method to convert the type into a tagged union version of this thing. Consider using pointers to fields in that tagged union to avoid copies. This logic/typing could all be some comptime code to make it reusable and consistent with your assumptions.

This gives you the memory layout that you desire with the language features you want to leverage in your application logic.

1 Like

Ok, it ended up being not a problem at all after implementing it the way you said. Message being a comptime function that returns the type with an action() function that returns the union(Op), but generated layout is like id, tag, pad, then payload which is what I was looking for. And .init(id, .{.alloc_mult = 5}) looks cleaner too. I was worried I was gonna pay a small serialization deserialization copy tax, but looks like llvm optimizes all of it out in release mode so its super chill. Oh and at the switch its just .action() instead of .action, so no difference.

const Op = enum(u8) {
    alloc,
    alloc_mult,
    free,
    free_mult,
    read,
    modify,
    append,
};

pub const Cmd = Message(union(Op) {
    alloc,
    alloc_mult: u16,
    free: mem.PageId,
    free_mult: mem.PageId,
    read: extern struct { key: u32, table: Table },
    modify: extern struct { key: u32, page: mem.PageId, table: Table },
    append: extern struct { page: mem.PageId, table: Table },
});

pub const Resp = Message(union(Op) {
    alloc: mem.PageId,
    alloc_mult: mem.PageId,
    free,
    free_mult,
    read: extern struct { page: mem.PageId },
    modify,
    append: extern struct { key: u32 },
});
1 Like

this sure works, but it’s a bit sad that it is the “state of the art” solution for zig.
It’ common in my experience that the automatic tagged union layout isn’t quite the one you want.

I wish Zig would let us decide how the tag is stored. I also have the same problem with “union” backed by packed struct, where I want the whole thing to fit on one u64 including the tag.

The current implementation allows having a direct pointer to the tag, but no syntax exist in the language to get it.

I also have the similar problem with ?u64 fields taking a lot of room in a struct.
In general union and optional have nice language features that make me want to use them,
but their memory layout make me not want to use them.

1 Like

Just as a side note, maybe the OP already knows, std.meta.BareUnion can quickly create a bare union based on a tagged union.

I’m personally using bare unions more and more. This way of coding is okay for me

switch (op) {
    .alloc => {
        ...
    },
    .alloc_mut => {
        const alloc_mut = &action.alloc_mut;
        ...
    },
    ...
}

But I’d be really happy if we could improve the switch experience of bare unions in this way:

switch (op) on (action) {
    .alloc => {
        ...
    },
    .alloc_mut => |*alloc_mut| {
        ...
    },
    ...
}
2 Likes

Yea I have a similar thing where I want ?u32 with zero reserved for null so I can use the orelse syntax, but I don’t know if thats natively possible without wrapping it as an enum with none = 0 or something like that.

EDIT: while more specifically I have a wrapping structs PageId, and DiskPageId for type safety, so idk if something like

const ram: PageId = smth.getPage() orelse return false;

while still having getPage return a 32 bit value (i’m on 32 bit platform) so it only uses 1 register.
in release fast it gets inlined and dissapears, but I still want decent preformance and less register pressure in release small where it stays as a function.

It’s currently not possible in Zig except with the emum(u32) trick you mentionned.

There was optional type optimization · Issue #104 · ziglang/zig · GitHub but I see it’s not accepted anymore

If you have a specific use case motivated with real hardware it can be a good motivation for the team to take a decision about that.

The problem when you work on x86 is every problem is generally answered with “LLVM gonna handle that”