Unexpected size of union

Hey!

The following code prints 2. I don’t understand why that happens.
I expected it to print 1, because the payloads are just 6 bits, meaning the discriminant / tag could be stored in the remaining 2 bits of the 8 bits in 1 byte.

const Node = union(enum) {
    Next,
    BranchYes: u6,
    BranchYes: u6,
    Terminal: u6,
};

print("{}",.{@sizeOf(Node)})

Can someone explain to me why this Zig does not make this optimization?

2 Likes

There is a proposal to make this for ?enum{}, maybe they will add it at some point in the future, for now if you absolutely need the optimization you can do this:

const NodeTag = enum(u2) {
    Next,
    BranchYes,
    Terminal,
}

const Node = packed struct(u8) {
    tag: NodeTag,
    payload: packed union(u6) {
        BranchYes: u6,
        Terminal: u6,
    }
};

You will have to do some stuff manually, but it is what it is.

2 Likes

Since the language and libraries are changing a lot, many optimizations mostly remain in the future. This optimization is a space optimization that will likely have code size and runtime performance implications, so it’s not a 100% win, depending on circumstances and your goals.

There are issues open for this and the similar case for optionals – so-called “niche optimization”, so this area is on the radar.

I expect this and other optimization will naturally become more of a focus after we have more native backends.

2 Likes

Is there a link to this proposal somewhere?

I don’t remember the exact name, it’s an old proposal on github if you want to find it, not in codeberg.

1 Like

If you can achieve it with packed struct and packed union, it doesn’t really make sense to apply the optimization to the general case, as you would expect non-packed ints to still take up for example u8 space?

You mean like this? Yeah, that works and makes sense, but I lose some union ergonomics

Niche optimization would not handle this case because as you hinted, it removes the ability to create a pointer to the tag value or to the payload value. If you want the tag to be bit packed, you have to use bitpack language features - that’s how you opt out of the requirement of being able to make a pointer into the fields of aggregates.

7 Likes

I think I’d question if the tag of a union has the same requirement of needing to have a separately addressable location like fields of a structure do. I think it falls under a different classification.

…but that’s a side point. I think it would be really nice if the way of opting-in/out/“all about” didn’t require the Zig code that accesses the type to be aware of the difference. That would be communicated through the type. That’s how it’s for a bit-packed struct, so it should be for a “bit-packed union”. Similar when you add alignment or padding.

2 Likes

It is the field which needs to be addressable not the tag. The problem is that when you are switching on the union and use a prong that captures by pointer then this pointer needs to address a single byte, but in the packed case this payload byte would also contain the 2 bits of the tag, so writing a new value to the payload would also clobber the tag value.

Or it would have to implement some special pointer type that does masked reads and writes to some bits of the location. Maybe these bit-aligned pointers could be that, but I don’t feel like I have enough information about those because they seem poorly documented.

Considering that the following works, I tend to agree that I don’t quite understand why the capture by pointer in a switch payload couldn’t be equivalent to the ptr in this example:

const std = @import("std");

const NodeTag = enum(u2) {
    Next,
    BranchYes,
    Terminal,
};

const Node = packed struct(u8) {
    tag: NodeTag,
    payload: packed union(u6) {
        BranchYes: u6,
        Terminal: u6,
    },
};

pub fn main() !void {
    var node: Node = .{
        .tag = .Terminal,
        .payload = .{ .Terminal = 5 },
    };

    const ptr = &node.payload.Terminal;

    const new_value: u6 = 13;
    ptr.* = new_value;

    std.debug.print("node: {}\n", .{node});
}

Where compileLog gives this pointer type for ptr: *align(1:2:1) u6

Are these bit-offset pointers documented somewhere?

1 Like

Ultimately I think it would be nice to have language support for this. Similarly ?Enum is weird. I often feel a conflict between bit packing to optimize memory storage and the niceties of the language like switch or optional branching

3 Likes

I think it would be nice if we could type “packed union(enum)” and it does the packing, because as a default if we would basically force the abnormally aligned payload pointer into every use case.

example

const Node = packed union(enum(u2)) {
    Next,
    BranchYes: u6,
    Terminal: u6,
};
switch (node) {
    .BranchYes => |*tmp| {}  // is not *u6, but aligned ptr 
    .Terminal => |tmp| {}, 
    .Next => {},
}
3 Likes