Initializing constant structs containing pointers to mutable structs

a simple example whose output surprised me:

const std = @import("std");
const print = std.log.info;

const CA = struct {
    s: *S,
};

const CB = struct {
    s: *S,
    s2: *S2,
};

const S = struct {
    x: u32 = 10,
};

const S2 = struct {
    y: u32 = 5,
};

fn create(T: type) *T {
    return &struct {
        var o = blk: {
            break :blk std.mem.zeroInit(T, .{});
        };
    }.o;
}

const ca = CA{
    .s = create(S),
};
const cb = CB{
    .s = create(S),
    .s2 = create(S2),
};

pub fn main() void {
    ca.s.x = 20;
    print("ca.s.x = {d}", .{ca.s.x});
    print("cb.s.x = {d}", .{cb.s.x});
    print("cb.s2.y = {d}", .{cb.s2.y});
}

the output values are 20,20,5; i would have expected 20,10,5

[[ FWIW: my original solution had @constCast(&std.mem.zeroInit(S, .{})) as the initializer for .s… but i’m trying to avoid @constCast (for good reason) and hence embraced the idiom in my create function ]]

to me, there seems to be an (unintentional) case of aliasing here… for whatever reason, ca.s and cb.s point to the same mutable instance of struct S – which is NOT what i want…

setting aside any possible “workarounds” (suggested in this earlier post), am i wrong to think this simple example should output 20,10,5???

For the example posted above, yes - there are two places that should resolve to the same instance:

.s = create(S)

This resolves to the same thing for both cases because the information is not unique to that function call (and further, the arguments must be utilized within the function itself in someway).

The case where it gets more complex is in your other examples where the compiler throws away arguments that aren’t used even though it appears to be different at the call site. I can certainly understand that being confusing.

Essentially, we want the compiler to be smart about this sort of thing because if I do something like so:

const y = sqr(f32, x);

And then later I do:

const z = sqr(f32, y);

I don’t want two implementations generated for sqr. Instead, we’re attempting to get the compiler to see that the function implementations are genuinely unique one to another. An example of that is:

fn foo(comptime n: usize) ... {
    if (n < 10) {
         // do something...
    } else {
         // do something else...
    }
}

We can even see that the compiler can compress two functions of different names and internally dispatch to another one if the functions are similar enough. You can play around in godbolt to see how robust that is on something like ReleaseSmall.

Trying to get a guarantee of spawning a unique function is tricky and can even depend on optimization levels. I think of this as an issue somewhat independent of aliasing because the aliasing occurs when the two functions are determined to be the same (when they’re not, no aliasing issue occurs). The result is the same, but how we got there isn’t. The aliasing issue that most people are referring to is when the compiler silently upgrades an instance to a pointer (commonly happens when passing function arguments). I think it’s somewhat helpful to distinguish between the two issues.

@biosbob, if you haven’t seen this talk, please do:

so this is an artifact of “memoization” then??? the compiler sees a “common sub-expression” which can be replaced by a single value…

the solution is to “trick” the compiler by making these call-sites unique… so passing create some unique value (which doesn’t get erased, etc) solves the problem…

perhaps some future @makeThisComptimeExpressionUnique internal would allow me to thwart memoization by actually letting the compiler know my intent…

Am I interpretting things correctly if I say that what you are going for is comptime “allocation” where you have a function that creates new static memory with each call? I have a feeling that in the general case this would need some global comptime state.

yes, i suppose… in very simple cases, i could write this:

var sa = S{};
const ca = CA{ .s = &sa };

container-level vars are “comptime allocated”… and obviously i can extend this pattern:

var sb = S{};
var s2b = S2{};
const cb = CB{  .s = &sb, .s2 = &s2b };

no question here that there’s no aliasing; each mutable S2 instance is truly distinct…

long story, but i have a collection of Cx type definitions whose multiple fields are of type Sy; in general, each of these Sy types are themselves synthesized through a handful of primitive functors…

my “user” specifies a set of Cx types… my job is then to initialize a container-level constant instance for each Cx, “knowing” that the Cx types have fields that point to statically-allocated mutable instances of types Sy…

i can clearly reflect upon each Cx type at comptime; i just need to figure out a solution to creating sa, sb, and s2b programmatically at comptime – as anonymous struct instances whose address can be bound to some Cx field…

It sounds like what you’re looking for is a way to programmatically create unique information at comptime. Unique being defined as information that will cause a function specialization when passed as an argument.

I’m thinking that you can rephrase this issue. Tell me what you think about this…

Let’s say you have an upper limit to how many instances of a type can be reasonably made… you could do something like this:

const std = @import("std");
const print = std.log.info;

const S = struct { x: u32 = 10, };

const N: usize = 100; // some default that makes sense

fn create(T: type) *T {

    const Battery = struct {
        var index: usize = 0;
        var data: [N]T = .{ undefined } ** N;
    };

    Battery.data[Battery.index] = std.mem.zeroInit(T, .{});
    defer Battery.index += 1;
    return &Battery.data[Battery.index];
}

pub fn main() void {
    const p1 = create(S);
    const p2 = create(S);
    p1.x = 25;
    p2.x = 30;
    print("p1.x - {}", .{ p1.x });
    print("p2.x - {}", .{ p2.x });
}

In this case, we want the compiler to combine the two instances because we’re dealing with an array of types instead of a single instance. This requires that you put a sensible upper limit, but I’m very confident this won’t cause an issue for you down the road.

Further still, you can make a metafunction that returns a create that allows the user to parameterize how many instances of a struct they want:

const std = @import("std");
const print = std.log.info;

const S = struct { x: u32 = 10, };

fn InitBattery(T: type, N: usize) fn (type) *T {
    return struct {
        var index: usize = 0;
        var data: [N]T = .{ undefined } ** N;
        fn create(_: type) *T {
            data[index] = std.mem.zeroInit(T, .{});
            defer index += 1;
            return &data[index];
        }
    }.create;

}

pub fn main() void {
    const create = InitBattery(S, 2);
    const p1 = create(S);
    const p2 = create(S);
    p1.x = 25;
    p2.x = 30;
    print("p1.x - {}", .{ p1.x });
    print("p2.x - {}", .{ p2.x });
}

Now you don’t need to guess, no aliasing problem, the user decides what is optimal, and you get your static data.

Sorry, one more post here… I’d also change that create function to just take no parameters… you end up with this instead:

const std = @import("std");
const print = std.log.info;

const S = struct { x: u32 = 10, };
const U = struct { x: u32 = 10, };

fn InitBattery(T: type, N: usize) fn () *T {
    return struct {
        var index: usize = 0;
        var data: [N]T = .{ undefined } ** N;
        fn create() *T {
            data[index] = std.mem.zeroInit(T, .{});
            defer index += 1;
            return &data[index];
        }
    }.create;

}

pub fn main() void {
    {
        const create = InitBattery(S, 2);
        const p1 = create();
        const p2 = create();
        p1.x = 25;
        p2.x = 30;
        print("p1.x - {}", .{ p1.x });
        print("p2.x - {}", .{ p2.x });
    }
    {
        const create = InitBattery(U, 2);
        const p1 = create();
        const p2 = create();
        p1.x = 25;
        p2.x = 30;
        print("p1.x - {}", .{ p1.x });
        print("p2.x - {}", .{ p2.x });
    }
}

interesting, indeed… but then this segues into another possibility – code generation via a separate upstream pass…

this harkens back to my Elevating meta-programming into upstream meta-programs post, where i found it easier to have meta-programs generate source code rather than use comptime meta-programming within a single pass…

generating code from a particular definition of some Cx is trivial; and including that code in the next downstream pass is also straightforward… @sze has been pushing for this approach all along…

perhaps when entropy takes over your comptime code, it’s time to consider whether an upstream meta-program is a better path… i already have two phases enroute to an optimized target program; perhaps a third (initial) pass will actually simplify my life further…

i’m able to solve the core issue by ensuring that each of my comptime-initialized Sy instances have a unique value set to a special id field they each define… my actual use-case is a bit more complex than the simple examples i’ve posted; but i was able to form a unique label using some hints y’all have provided in this and earlier topics…

and i did NOT have to reply upon std.mem.doNotOptimizeAway :wink:

let’s call it a day…

If your initialization code (where create gets called) is not in a loop, you can use @src() as your unique id, and it also has the upside of providing meaningful information when troubleshooting

@src() evaluates to an instance of std.builtin.SourceLocation (make sure to read what it contains, it’s necessary to understand the rest of my post). If you mark the argument as comptime then it should provide you with all you need.

So something along the lines of

fn create(comptime loc: std.builtin.SourceLocation, ...) {}

// usage
var foo = create(@src(), ...);

Note though that the uniqueness of @src() breaks if create is called in a loop, as multiple calls to it will share the same exact instance of SourceLocation. If you really must initialize something in a loop, as a workaround you could create a var instance of SourceLocation that you then modify to provide more uniqueness.

Here’s your script from the other thread changed to employ this technique (in other words I figured out how to make this work after failing initially):

const std = @import("std");
const print = std.log.info;

const CA = struct {
    s: *S,
};

const CB = struct {
    s: *S,
    s2: *S2,
};

const S = struct {
    x: u32 = 10,
};

const S2 = struct {
    y: u32 = 5,
};

fn create(comptime T: type, comptime loc: std.builtin.SourceLocation) *T {
    return &struct {
        pub const id = loc;
        pub var o = blk: {
            break :blk std.mem.zeroInit(T, .{});
        };
    }.o;
}

fn initC(CT: type, comptime unique: [:0]const u8) CT {
    comptime {
        var new_c: CT = undefined;
        const cti = @typeInfo(CT);
        for (cti.Struct.fields, 0..) |fld, idx| {
            const fti = @typeInfo(fld.type);
            const FT = fti.Pointer.child;
            var loc = @src();
            loc.fn_name = unique;
            loc.column = idx;
            const fval = create(FT, loc);
            @field(new_c, fld.name) = fval;
        }
        const res = new_c;
        return res;
    }
}

const ca = initC(CA, "unique-id-1");
const cb = initC(CB, "unique-id-2");

pub fn main() void {
    ca.s.x = 20;
    print("ca.s.x = {d}", .{ca.s.x});
    print("cb.s.x = {d}", .{cb.s.x});
    print("cb.2.y = {d}", .{cb.s2.y});
}

One thing to note about this code is that here there are two things that must be fixed to make the initialization code actually unique:

  1. the initC function is called from different locations so those locations need to provide a unique id of their own
  2. inside of its body, initC calls create in a loop, so that must also be rendered unique

To solve these problems I’ve repurposed SourceLocation.fn_name to be the unique invocation id, while SourceLocation.column to be the loop iteration index.

Depending on how much looping there is in your final code, you will need either more logic than this, or less if you don’t have as much looping. For example the simplified version of the program that you shared in this post would require less logic.

One thing that woudl have made this solution simpler would have been the ability to do this:

const ca = initC(CA, @src());
const cb = initC(CB, @src());

As the two calls to src would have been naturally unique since those are two different lines, but unfortunately Zig currently doesn’t allow calling src outside of a function context.

1 Like

I also just thought of one final thing.

As I was fixing the code from my previous post, I was thinking that the solution is kinda brittle because you get one unique id wrong, or you forget to increment a loc.column in a loop (or more likely introduce a loop above it unaware of the invariant you just broke) and your program silently breaks.

But the solution to this problem is kinda simple actually: add a build flag that makes it print a special line that shows the value of the SourceLocation every time you’re initializing something that must be unique and then test from another program that none of those lines is a duplicate. When a duplicate is detected you can print it (to help track down the mistake) and then exit with non-zero code.

This can all be orchestrated trivially via the zig build system so it could (I would even argue should) be part of what happens when you run zig build test.

@biosbob if you’re unfamiliar with how to setup this stuff feel free to open a dedicated topic, people here will be happy to help you get it setup.

3 Likes

Looks like abuse of global variables. The fix is simple: stop using global variables.

Small nitpick related to @AndrewCodeDev’s initial response: this isn’t about function call memoization! The compiler does do memoization, but it should be viewed entirely as an implementation detail: it’s done in such a way that language semantics should be precisely preserved without it (as of 0.12.0). Even if the call wasn’t memoized, you’d get the same behavior, because the real culprit here is that container types (struct, union, enum, opaque) are deduplicated based on their source location and the values they capture. What this means in practice is that if I write a function which just returns struct {}, it will always return the same struct type regardless of any arguments, because the types all live at the same source location (that return statement) and “capture” the same values from external scopes (in this case, none!). So in the original example, what’s going on is that the struct in create is effectively deduplicated based on the value of T: if I call create(u32) twice, even if the call wasn’t memoized, the struct types would still be identical, so there’s only one global variable. That’s why @kristoff’s example above needs the pub const id field in the created struct.

Everything else said here is fine, and I would echo Andrew’s recent comment – I just thought I’d mention this.

3 Likes

The plot thickens! So really, the issue here is about getting the compiler to see the two structs as separate beyond the memoization of the functions themselves. Makes sense and that explains the need for unique information per struct. Interesting stuff. And yes, it’s fun coding acrobatics but I’m somewhat skeptical of these approaches, too.