Packed array of enum

I’m trying to do more DoD, but struggling with some ergonomic issues.

I have a const Spec = enum(u4) {...} and I want to store 8 of it. What is the best option ?
[8]Spec seems to defeat the purpose of packing stuff in a u4 in the first place,
@Vector(Spec, 8) doesn’t compile because Spec isn’t an int.

So I wrote this ugly code:

pub const Array = packed struct {
    _0: Spec,
    _1: Spec,
    _2: Spec,
    _3: Spec,
    _4: Spec,
    _5: Spec,
    _6: Spec,
    _7: Spec,

    pub const unknown: Array = splat(.unknown);
    const MAX_RANK = 8;
    const Vec = @Vector(MAX_RANK, u4);

    pub fn init(specs: []const Spec) Array {
        var res: Array = unknown;
        for (0.., specs) |i, spec| {
            res = res.set(i, spec);
        }
        return res;
    }

    pub fn replicated(rank_: usize) Array {
        std.debug.assert(rank_ <= MAX_RANK);
        const full_replicated: Vec = @splat(@intFromEnum(Spec.replicated));
        const mask = std.simd.iota(u4, 8) < @as(Vec, @splat(@truncate(rank_)));
        return @bitCast(@select(u4, mask, full_replicated, @as(Vec, @bitCast(unknown))));
    }

    pub fn splat(spec: Spec) Array {
        const vec: Vec = @splat(@intFromEnum(spec));
        return @bitCast(vec);
    }

    pub fn get(array: Array, ax: usize) Spec {
        std.debug.assert(ax < MAX_RANK);
        const pack: u32 = @bitCast(array);
        const shift: u5 = @intCast(4 * ax);
        return @enumFromInt(@as(u4, @truncate(pack >> shift)));
    }

    pub fn set(array: Array, ax: usize, spec: Spec) Array {
        std.debug.assert(ax < MAX_RANK);
        const pack: u32 = @bitCast(array);
        const shift: u5 = @intCast(4 * ax);
        const mask = @as(u32, 0xf) << shift;
        return @bitCast((pack & ~mask) | (@as(u32, @intFromEnum(spec)) << shift));
    }

and I’m not even including the “insert” method.

Is there smth better ? what would you recommend here ?

I use this to represent arrays of a bitpacked type (which I use A LOT for mmio types)

/// Represents a bitpacked array of `n` elements of `T`.
/// Stored in native endian.
///
/// A `BitpackedArray` is stored from LSb (0) to MSb (n - 1).
pub fn BitpackedArray(comptime T: type, comptime n: usize) type {
    const total_bit_size = @bitSizeOf(T) * n;
    const ArrayInt = @Int(.unsigned, total_bit_size);
    const ElementInt = @Int(.unsigned, @bitSizeOf(T));

    return packed struct(ArrayInt) {
        pub const Int = ArrayInt;
        pub const elem_mask: ArrayInt = std.math.maxInt(ElementInt);

        raw: ArrayInt,

        pub fn init(value: [n]T) Self {
            // NOTE: Cannot be `undefined`, any `undefined` bits make the entire value `undefined`.
            var bt: Self = std.mem.zeroes(Self);
            inline for (0..n) |i| bt.set(i, value[i]);
            return bt;
        }

        pub fn splat(value: T) Self {
            // NOTE: Cannot be `undefined`, any `undefined` bits make the entire value `undefined`.
            var bt: Self = std.mem.zeroes(Self);
            inline for (0..n) |i| bt.set(i, value);
            return bt;
        }

        pub fn slice(bt: Self, index: usize, comptime len: usize) BitpackedArray(T, len) {
            std.debug.assert(index + len <= n);

            const NewBitpacked = BitpackedArray(T, len);
            const bt_int: Int = @bitCast(bt);
            const new_bt_int: NewBitpacked.Int = @truncate(bt_int >> (index * @bitSizeOf(T)));
            return @bitCast(new_bt_int);
        }

        pub fn get(bt: Self, index: usize) T {
            std.debug.assert(index < n);
            // TODO: @fromBackingInt 0.17.0
            const value: ElementInt = @truncate((@as(ArrayInt, @bitCast(bt)) >> @intCast(index * @bitSizeOf(T))) & elem_mask);

            return switch (@typeInfo(T)) {
                .@"enum" => @enumFromInt(value),
                else => @bitCast(value),
            };
        }

        pub fn copyWith(bt: Self, index: usize, value: T) Self {
            std.debug.assert(index < n);
            // TODO: @fromBackingInt 0.17.0
            const int: ElementInt = switch (@typeInfo(T)) {
                .@"enum" => @intFromEnum(value),
                else => @bitCast(value),
            };

            return @bitCast((@as(ArrayInt, @bitCast(bt)) & ~(elem_mask << @intCast(index * @bitSizeOf(T)))) | (@as(ArrayInt, int) << @intCast(index * @bitSizeOf(T))));
        }

        pub fn set(bt: anytype, index: usize, value: T) void {
            comptime std.debug.assert(@typeInfo(@TypeOf(bt)) == .pointer);
            comptime std.debug.assert(!@typeInfo(@TypeOf(bt)).pointer.is_const);
            comptime std.debug.assert(@typeInfo(@TypeOf(bt)).pointer.child == Self);
            std.debug.assert(index < n);

            bt.* = bt.copyWith(index, value);
        }

        pub fn format(bt: Self, w: *std.Io.Writer) std.Io.Writer.Error!void {
            try w.writeAll("{ ");
            for (0..n) |i| {
                try w.print(if (std.meta.hasFn(T, "format")) "{f}" else "{any}", .{bt.get(i)});
                if (i != n - 1) try w.writeAll(", ");
            }
            try w.writeAll(" }");
        }

        const Self = @This();
    };
}

test BitpackedArray {
    const Thing = enum(u1) { foo, bar };
    const ThingArray = BitpackedArray(Thing, 4);

    var bt: ThingArray = .splat(.foo);

    bt.set(3, .bar);

    try testing.expect(bt.get(3) == .bar);

    bt.set(3, .foo);

    try testing.expect(bt.get(3) == .foo);

    bt.set(0, .bar);

    try testing.expect(bt.get(0) == .bar);
}

somehow I feel this should be part of the language or at least the std.
Thanks for sharing

I think in 0.17 you can make use of @bitCast to make the implementation smaller, because bitcast will look at the “logical bit layout” instead of the actual size

The following seems to work:


const std = @import("std");

const Spec = enum (u4) {
    A,
    B,
};

fn BitPackArr(T: type, N: comptime_int) type {
    const totalBits = @bitSizeOf(T) * N;
    const BackingInt = @Int(.unsigned, totalBits);

    return packed struct {
        num: BackingInt,


        pub fn init(arr: [N]T) @This() {
            return @bitCast(arr);
        }

        pub fn array(self: @This()) [N]T {
            return @bitCast(self);
        }

        pub fn get(self: @This(), index: usize) T {
            return self.array()[index];
        }

        pub fn set(self: *@This(), index: usize, val: T) void {
            std.debug.assert(index < N);
            const shift = @bitSizeOf(T) * index;
            const baseSeed: BackingInt = comptime (1 << N) - 1;

            const clearMask: BackingInt = ~(baseSeed << @intCast(shift));
            self.num &= clearMask;
            self.num |= @as(BackingInt, @as(@Int(.unsigned, @bitSizeOf(T)), @bitCast(val))) << @intCast(shift);
        }

    };
}

pub fn main() !void {
    const arr: [42]Spec = @splat(.A);
    var bp: BitPackArr(Spec, 42) = .init(arr);
    bp.set(3, .B);

    for (0..42) |i| {
        std.debug.print("{}", .{bp.get(i)});
    }
}

Another idea I played with was to also have a struct where each member is accessed by @“number” so first element would be accessed by @“0”, I don’t see any real uses for it but here it is


    const fieldNames: [N][]const u8 = comptime D: {
        var ans: [N][]const u8 = undefined;
        @setEvalBranchQuota(10000);
        for (0..N) |i| {
            ans[i] = std.fmt.comptimePrint("{}", .{i});
        }
        break :D ans;
    };

    

    return packed union {
        num: BackingInt,
        structType: StructType,

    pub const StructType = @Struct(.@"packed", null, &fieldNames, &@splat(T), &@splat(.{}));

// Rest of the code is the same

Then this main works


pub fn main() !void {
    const arr: [42]Spec = @splat(.A);
    var bp: BitPackArr(Spec, 42) = .init(arr);
    bp.structType.@"3" = .B;
    
    for (0..42) |i| {
        std.debug.print("{}", .{bp.get(i)});
    }
}

I think a “packed [N]T” type would be nice, where the language guaranteed packs it for you, it wouldn’t add any keywords to the language and it’s clear what it does.

can you explain the std.mem.zeroes in splat and init ?

If you meant why I did so instead of initializing it to undefined, it’s because undefined is infectious; any undefined bit leaves the whole value undefined. Zig doesn’t track it to the bit-level so, for example:

const Foo = packed struct(u8) { x: u1, y: u7 };

const foo: Foo = undefined;
foo.y = 20; // The value will still be undefined, you must modify `foo` as a whole.

This is because packed struct field access is just syntax sugar for reading, ANDing/ORing and shifting the bits in the integer and writing. Removing that syntax sugar would make it look like this:

var foo: u8 = undefined;
foo = (foo & 0x80) | 20; // which is basically `(undefined & 0x80) | 20`

Hopefully that shows why :slightly_smiling_face:

Thanks, so the issue is LLVM tracking undefined at the byte level only. So having undefinded bits, may trigger optimizations that discard all the content of the packed struct.