Purely Technical Discussion: Memory-Safe Compilation Mode Inspired by Fil-C

I would love to watch this weekend’s videos specifically, but I looked at Filip’s YouTube videos. They were all a few years old but here’s a podcast interview https://m.youtube.com/watch?v=6Maoe-GMynM&pp=0gcJCWQCo7VqN5tD&ra=m that starts with Filip defining exactly what he means by memory safety. (My interpretation of the first few minutes is that for him it boils down to “can an attacker rewrite your program in memory”.)

Couple other points from skimming the thread (I’ll definitely be reading it more carefully later though, this is great stuff): Somebody asked where the 1-6x perf hit came from, I was the first to mention it in this thread and that came from how Andrew wrote it in the new Zig issue. Another bit, I’m not sure Filip would agree with the “and then you finish testing and build an efficient version” either based on what I’ve seen so far. Also, I would add caveats like “as seen in the existing C implementation, but not yet determined to be in the Zig implementation” to lots of these comments, this is an opportunity to do something new.

Anyway, the video’s neat, and at least the beginning is easy to follow.

I think that’s a problem closely related the the halting problem, which Turing showed to be impossible.

2 Likes

Because compile time allows fast feedback loop, why would not we achieve it if it is possible?

If I know the technical detail I would have already write it, you can also just share idea and then the other people or maintainers that has more skill may know how to achieve it

But I am creating custom language with lifetime label, like this

const data: i32 vec = vec[1, 2, 3] until my_lifetime

const ptr: ptr i32 vec = &data until my_lifetime

const data2: i32 vec = vec[1, 2, 3] until my_lifetime

close my_lifetime // this free all heap that has lifetime named my_lifetime, it also close all pointer that has same lifetime so it prevents use after free

// then my compiler return compile error if forgot to close and double close, it prevents forgot to free and double free

It is also parameterable

.task: @str lifetime, ptr i32 vec data {
    for data as val {
        println("val: @val")
    }

    close @lifetime
}

const lifetime_name: @str = "my_lifetime2"
const data: i32 vec = vec[1, 2, 3] until @lifetime_name

task(lifetime_name, &data)

That has 0 runtime overhead. The @ means it is compile time reflection value

Now responding to the rest. Arc overhead is smaller than Fil-C overhead, because its heap allocation is only once in its creation and you can group multi value behind single Arc, where Fil-C allocates new heap for every heap value afaik. Atomic counter is fast, Zig already uses it in its internal and STD. The more concern is the heap alloc since its incredibly slower than atomic, but its one time cost at its creation, where Fil-C does more frequent heap alloc. And second consern is CPU cache invalidation if multi threads access the counter. Do you mean Rust? If yes, Rust has a way to safely share data to thread without counter and dangling pointer risk using thread scope, but lets not continue to revive what we already trying to avoid a pointless discussion. The idea is morely about the compile time approach, any kind of method as long as it is compile time, and highligh the downside of Fil-C approach so we are aware before really taking it, no need to write unrelated thing again like before

If you do not fully know what you are creating, can be beginner or simply new to the project type despite having experience in different kind of project, there is chance of you do not know what are all the kind of dangerous access can be happen, if you do not know that you also do not know how to write the test to catch it

1 Like

The proposal already say it is optional mode. So is the compile time approach to fullfill the same goal. You can always deactivate it and do the thing like before again

To stave off potential misunderstanding from people not very familiar with Rust, it absolutely does not require wrapping your stuff in Arc/Mutex soup the moment you touch threads.

It does require writing the code in such a way that the “proof of correctness” is obvious to the compiler, and that is a skill related to, but distinct from just writing sound code. While Rust type system doesn’t allow expressing literally every pattern, it is quite a bit more flexible than one could naively assume.

For example, if there’s a clear owner of data which is guaranteed to outlive parallel processing, you don’t need an Arc. If you don’t mutate data, you don’t need Mutex. If you do mutate data, but the data are atomics or a lock-free data structure, built out of atomics, you likewise don’t need Mutex. If you have two-phase processing where first you create a data structure in a single thread, and then share results across multiple thread such that they only read, you don’t need mutex.

If you have many-phase processing of alternating single-threaded and multi-threaded mutation, you need mutex only for the multi-threaded part, and that’s also perfectly fine to express in Rust.

See Two Beautiful Rust Programs for a somewhat artificial, but illustrative example of expressing fine-grained concurrent reasoning in a form understandable by the Rust compiler.

(with async + threads combo, there’s arguably is a semantic gap where the language/library type system forces extra synchronization onto you, and gives up on certain reasoning capabilities it has with threads).

EDIT: perhaps a better example is this:

That’s a ray tracer that renders a scene in parallel, using a shared accelerator data structure, no arcs or mutexes in sight.

8 Likes

I suspect that any useful Zig implementation must solve this, or else something as simple as “use an arena” will not check any individual objects. I don’t know how they will solve this, but I am assuming either this is solved - or this whole new mode doesn’t happen at all.

1 Like

Purely Technical Discussion

please

3 Likes

Your example can be solved easily with my lifetime I said above + Mutex if you want multi threads can read and write them concurrently, or you write your lock free game state using atomic

You can also use channel but channel has more atomic operation that Arc, but it has advantage maybe better cache locality. Where Rc is just integer counter

Edit: it is safe in my current understanding, if anyone can point out there is problem please correct me. It is safe if the pointer points to the ArrayList variable, the lifetime will return compile error if the stack end but there is still unclosed pointer that points to the ArrayList’s stack metadata. The dangling risk is if it point to the individual value directly, right now I do not have solution for this one

The contract approach like in Spark Ada and the new C++ 26 combined with formal verification combined wih the lifetime can provide more complete compile time safety

2 Likes

My understanding of the 1-6x perf hit from fil-C is that it’s mostly to do with the additional runtime overhead of bounds checking rather than anything to do with GC

Given that zig manages bounds checking at high performance already (assuming the zig code is using idiomatic slices), then I imagine any hybrid zig → fil-c toolchain should see a much reduced perf overhead - better than 1-6x anyway.

It’s also my understanding that the memory safety gain from fil-c comes more from object capability tracking than anything else … again nothing to do with GC

It’s extremely hard to theorise about how all this may translate to real world apps with any real accuracy anyway

I think it would be great if we (we as in zig core team + filip himself) could lock down a rough agreement on what the ABI should mostly look like, and then knock out a quick and dirty PoC. It doesn’t have to be perfect on the first shot - just correct enough to be able to build some existing working zig projects, and get some data to play with.

See what works, what doesn’t, and where the low-hanging fruit is for reducing the assumed 1-6x perf loss.

You never know - you might even find that just focusing on capability restrictions yields interesting new opportunities (ie - like Pony, which is almost entirely built as a language around CR)

4 Likes

While I think that having Fil-C as an ABI is useful (if not important) to have to link against existing (and maybe badly tested) C code, I don’t really get what pure Zig code would get out of it.

That’s actually a problem I have with the proposal on codeberg: It mixes ABI and compilation mode in a way that I’m not sure what the goal is: Is the goal to introduce an additional ABI (like e.g. glibc’s) or is it to add a different mode (like e.g. ReleaseSafe)?
Well, the comments under the proposal (including by Andrew himself) seem to mean ABI, not compilation mode.

As others pointed out with the intra-object example, this itself can be a memory safety issue which Fil-C isn’t able to detect (imagine if the struct has after the array a position field of the head of a CNC machine; or the flag in question is an “is_admin” boolean).
Btw, here an artificial example program with that which runs completely fine with Fil-C, even with -Wall -Wextra -Weverything (the latter creates some other warnings, but they have nothing to do with the problem here like stdout being a recursive macro or bool being not available pre C23 (which compiling in C23 mode)):

#include <stdio.h>

struct T {
        char c[4];
        bool b;
};

static void printT(const struct T* t) {
        printf("T{ .c = .{ %c, %c, %c, %c }, .b = %s }\n", t->c[0], t->c[1], t->c[2], t->c[3], t->b ? "true" : "false");
}

int main() {
        struct T t = {
                .c = { '1', '1', '1', '1' },
                .b = false,
        };
        printT(&t);
        // simple off by one as an example
        for (int i = 0; i <= 4; ++i) {
                t.c[i] += 1;
        }
        printT(&t);
        fflush(stdout);
}

Then there’s the problem with how to wrangle this up with allocators. After all one of Zig’s greatest pushes is to go away from the “one global allocator” thinking of the past, but Fil-C relies on that old kind of thinking.
If one only does it via mmap wrapping, then that leaves out (custom) allocators and Zig would need to extensively document on how to make an allocator implementation work (including how to set up the invisicaps etc.).
That means every allocator implementation needs to internally do an if (abi == .fil) { ... } if they don’t want things to break immediately as soon as that allocator gets combined with the fil ABI.

Ok, so, what would pure Zig code get? From what I can tell:

  • finding illegal casts (which will get caught anyway by #2414)
  • badly dealing with multi-item pointers instead of slices (using multi-item pointers directly is a code smell imo anyway, but could be worth it for the rare cases where you need them)
  • figuring out when you mess up with direct slice manipulation (like slice.len = 5; again, code smell imo, but you sometimes need it)
  • maybe more, would be nice if others could chime in here
4 Likes

I think there’s two interesting angles to this discussion: an actually memory safe ABI and bringing stronger guarantees to Zig.

A fil-abi with broad support is kind of unprecedented. I don’t think there’s a realistic way to publish a cross language library in binary form that has memory safe properties without incurring huge IPC/VM performance/ergonomic overhead. Similar to the 0.16 IO changes, I feel like Zig is starting to take on a lot of the hard problems left by operating systems that have mostly languished since the 90s. No one really has the appetite to challenge the C-ABIs, and the zig (hostile) compatibility approach seems like a good path forward. This is orthogonal to the intra-binary safety debate–I don’t think exposing lifetimes is feasible in a binary/dynamic format. Even folks who are heavily invested in other languages should be interested in this as it could be a way to freedom from thorny C-FFIs if you want to use existing libraries or publish a dynamic library. I’m disappointed that this seems to have been mostly overlooked by the parts of the internet I’m exposed to.


I’m hesitant to bring up the intra-binary safety aspect, because formalism / static analysis is a rabbit hole and extremely nuanced. You get a lot of folks with strong opinions, who don’t really have the background to discuss things effectively. I’ve taken a few grad classes and spent >100hrs using theorem provers to formalize properties of programs and still have to invest a lot of time and check myself thoroughly. Then someone will just drop drive-by comments without digesting what you wrote or otherwise engaging in good faith (because it’s really hard). But I think it’s important enough of a subject that I’ll spend the time here. I’ll try to provide a bit of background and what I’d like to see.

The thing that most people don’t want to wrestle with is that undecidability means that there is no winning. You have to draw the line somewhere. Even seemingly trivial properties are impossible to prove about generic programs. You have to restrict what can be expressed if you want to automatically prove interesting properties of programs. End of story. This has been proven.

The trick here we don’t have to reason about programs in general. If you insert dynamic bounds checks everywhere, you’ve restricted the possible programs to those that can’t access buffers out of bounds by construction. The proof is so trivial, I’ve never seen anyone write it down. If you restrict your language to only have a single owner for each variable at a time, you can automatically prove the absence of UAF, race conditions, etc. (Fil-C is essentially the dynamic version of what affine types / borrow checkers prove). There are trade offs here. Every memory safe language that I’m aware of dynamically checks buffer boundaries because the language designers decided it would be too restrictive to force every valid program to statically reason about buffer indices.

We could likely express a huge subset of programs with PRFs and rest easy knowing that they’ll halt, not perform out of bounds accesses, and prove a lot of other properties automatically. But we don’t. We don’t even widely use functional languages.

Even if you can prove literally everything you wanted about your program, you can’t prove that what you’re trying to prove is what you wanted to prove (see the monkey’s paw or Djinn myths for how old this problem is).


Personally, I don’t really care to argue about where to draw the line in the sand. Memory safety is probably something that is worth proving for a lot of programs, but so are a lot of other things. Though, what I find compelling about Zig is being able to ergonomically express what modern computers can do. Not the absence of being able to express things I don’t want them to do.

Dynamic capability checking provides a really ergonomic way to get “memory safety”. I have a fuzzy picture of being able to use Zig’s comptime features to build refinement types/proofs to elide dynamic checks in hot paths or paths tainted by IO or ABI boundaries. You could likely just write good code that wouldn’t need extra hints to optimize.

I’m all for this proposal, but as someone who writes a lot of bare metal code, I’d just like to see the panic situation improve just a little bit. (I’ve had some ideas about abstracting over signals/interrupts that could roll up panic handlers that I’ve been meaning to explore. That may overlap with IO a bit, so I’ve kind of just been waiting to see how that plays out).

Any work on comptime proofs might open the door to more than just memory safety guarantees without the heinous baggage of ADA syntax (sorry SPARK). As I understand the theorem provers that use Curry-Howard isomorphism don’t really have a huge amount of complexity in the base language, moreso in the environment and convenience tools. So, it might be feasible to bite off small chunks or give the community the building blocks.

11 Likes

I think this is really interesting feature. Zig would be the first toolchain to implement fil-c ABI, and being able to compile to memory safe ABI for platforms that support it when the perf is not important is really handy option to have. It’s sad the issue become flow of comments not understanding it’s not a language feature but rather than ABI target, and mostly only needing support in std.

I think the only thing that might be a problem is pointer provenance. As I understood pointers in fil-c ABI carry capabilities, so you can’t cast them to integers and back for example.

1 Like

For freestanding-fil ABI, you would have to implement the fil runtime https://fil-c.org/runtime.

1 Like

I believe you can. Once you cast it back the machine code will still check that the memory location has the right capability behind the pointer (e.g. to check that you didn’t cast the pointer to the wrong type).

from the filc docs:

The ptr intval. This is the raw integer value of the pointer as visible to the C program. When the C program reasons about the value of the pointer (using arithmetic, casts, printing the pointer, comparing the pointer to other pointers, etc), it only ever sees the intval.

3 Likes

It seems to me like Fil-C is a bit overkill for zig.
In C it makes a lot of sense because there’s exactly 1 pointer type, however I think in zig we can achieve memory safety without that x6 perf hit.

If I talk out of my ass here, someone please correct me - I don’t write mission critical software, so this is a learning opportunity for me. (but over the past year worked on a ~12K loc zig codebase, and I never had memory issues without even trying that much, the only memory bugs I had would not have been prevented by any memory safety mechanisms I know of, qurious if people that work on safety critical stuff feel like ReleaseSafe is not enough for them).

I think a list like below would be pretty close to forcing memory safety.

  1. GC for allocations (or no GC and all allocations leak)
  2. Only pointers allowed become * and [], [*] become disallowed.
  3. * can convert to [] with length 1.
  4. Only way to mutate slices becomes with slicing syntax, editing .len and .ptr raw becomes illegal.
  5. For pointer casting, the resulting memory “viewed” by the pointer has to be the same. Example, *usize can convert to a slice u8 of len 4. Slice of a struct with size 1, can become slice of u8. @ptrFromInt becomes illegal.
  6. I don’t know how easy this is, but a thorough mechanism to prevent returning dangling pointers from the stack. I think this shouldn’t be too hard to do, maybe it would require a restriction.
  7. Some rules around function pointers, I think stopping @ptrCast for them and having a default value in the space of undefined would make this memory safe.

I may be missing some cases (like @ptrCast ing a pointer into a struct that has pointers, but there could be a rule that @ptrCast is disallowed on such structs, or also for structs that have function pointers. Probably something like that for extern unions too), but I think a list like this would quickly allow someone to write memory safe zig without getting a x6 performance hit.

The obvious downside is that you can’t reuse code not written with these rules (std.arrayList for example would be illegal), where I think the idea with fil-C is you change nothing in your codebase, and get memory safety for free at the cost of performance. So I guess an idea like this would be orthogonal with fil-c.

I don’t have a conclusion, a bit scattered thoughts, I apologize.

1 Like

Related video for people that have not watched (huge respect for the maiden poster)

1 Like

If someone could explain one thing to me.

I know Fil-C can represent things like tagged pointers, or even storing pointers as ints, because it does it’s checks when the int is actually cast to pointer. That means some overhead of tagged pointers vs just using bare pointers to begin with. This was my biggest worry, but it all works, it just translates to runtime costs.

But wouldn’t this require restricting the language, to disallow things like inline assembly or calling to external code? Because I can’t see how runtime could detect invalid memory access in inline assembly. And without inline assembly, many things do not work, e.g. Zig would have to gain SIMD intrisics.

My understanding is that it’s both an ABI and a build mode.

The C/C++ code must go though a special compilation process that lowers everything to the restricted versions of all pointer operations, and links against the filc libc.

The Zig code has to go through the same process (but specific to Zig), and finally there’s the linking stage in which C/C++ object files must be linked together, which involves having some kind of ABI.

Andrew’s comment is about the fact that this ABI can be “arbitrarily” chosen by Zig, or it could be a standard that would allow you to compile C/C++ libraries outside of zig cc (but still going through the Fil-C specific motions) and still be able to link with Zig code built in “fil-zig” mode.

1 Like

There are docs around all of this, the TLDR (as understood by me) is that external calls have extra checks around them (although this is mostly a non-issue for Zig as you would normally want to build the C code yourself as well, which would give you the opportunity to target the “Fil-C ABI”), while the inline assembly supported today is x86_64 only and only a subset that is guaranteed to be safe, with plans to extend support to more inline assembly operations decorated with runtime safety checks.

4 Likes