Revisiting the design approach to Zig
about.sourcegraph.com
about.sourcegraph.com
To talk about one of them, it makes sense that Zig ignores compile-time branches that aren't chosen because otherwise they wouldn't be a replacement for ifdefs. However, I wonder if some kind of abstract interpretation mode that checks both sides of a branch would make sense?
Rust’s tradeoff in providing safe and faster code is mentioned. Why not Zig’s?
Manual memory management gives obvious downsides which should be well known. Zig does this in the best way I have seen though.
I'm using Zig and C for a good amount of time now and i disagree. Zig has simpler concepts as C, but in C you can just ignore them.
Consider
char buf[4];
float * f = buf;
*f = 32;
Seems simple, compiles, and even executes. Everyone is happy. But that code will just explode randomly on non-x86 platforms as it violates alignment rules. In Zig, this code will give you a compile error, as []u8 does have alignment 1, while a []f32 will have alignment 4. This means you have to learn about alignment before accidently writing code that will randomly crash at runtime depending on the stack position of buf.And Zig has a lot of these things that require implicit knowledge in C as explicit knowledge implemented in the language itself. Same goes for integer casting, shifting more than sizeof() bits, and so on.
It moves the footguns from the code to the user, and so the user has to learn about all of these concepts before writing Zig code. In C, the code will silently compile and misbehave at runtime. This might feel like additional complexity in a lot of places, but the complexity is there in both languages. One just makes it explicit.
But this is obviously not true for all things in Zig. comptime is a whole new feature that wasn't seen in a lot of languages before and afaik not as it is used in Zig, so it's something new to learn even if you already can do perfect C. The same goes for more precise data types (struct vs extern struct) and so on.
One hard footgun in Zig right now is implicit aliasing of parameter types, which sometimes get passed as a reference and sometimes as a value. This is a known footgun is already on the table to be adressed by better semantics and more safety guards
For me, writing Zig feels way less complex than C because a lot of brain load that went into writing correct C was offloaded to the compiler, which is good. But it also increases the friction when writing code as the compiler forces you to think about alignment and such.
I assume zig has a finite bound on comptime_int, so we can in fact always know what is_odd_perfect_number() returns.
> we can't type-check zig libraries which contain generics. We can only type-check specific uses of those libraries.
I.e. you cannot say whether this compiles for all n, only for the instances you actually instantiate
- library users will find compilation errors in the library code (his is annoying, but when you get used to it, you can treat it as any kind of library bugs and submit a PR)
- library users are going to rely on accidental behaviors (that is “in the library author's mind this isn't been used this way”) and then you'll have breaking changes without noticing.
(I say that as someone currently working on a DSL for the train industry, and this language (started in 2012) has something really close to Zig's comptime and we're currently trying to move away from it, because of the aforementioned issues. And it's actually pretty hard because big chunks of the existing code don't actually compile unconditionally and only compile when a given set of compile-time conditions are met, which makes the migration process hard)
There is a lot of templatized c++ library code out there, yet in everyday practice I find it's very rare that I need to instantiate a template with a type that causes a compile error.
That's true. C++ templates are exactly like that too.
They are a bit arcane though, and many developers are afraid of them and use them very conservatively or even avoid them altogether so maybe this helps mitigate the issue.
Comptime (at least with the language I'm currently working on) are a lot less scary, and people use them quite a lot without thinking too much, so maybe it's the main difference. And I think Zig is closer to this than it is to C++ templates, but I can't be sure since I've never seen people writing Zig code.
Edit: it's late in here, and I completely missed your point. I'm personally not usually afraid with language features, but some requires more work than others to be accustomed to, and not everyone is willing to spend their spare time doing so.
I used to code in C++ and I had that problem quite often. Including in implementations of the STL.
And if you put that in you API and someone call foo with an odd perfect number, well it will fail to compile.
It's conditional compilation, exactly what C does with macros. Except here, you don't have to learn an awful and honestly, sometimes, incomprehensible parallel language.
Zig has some shortcomings but I fail to see how this is one.
That's a valid point indeed and there's an issue for that: https://github.com/ziglang/zig/issues/1268
I actually don't know what Andrew is getting at with that particular case though. If the situation is that the types are obvious, so the Zig isn't ever looking at the tags, the Rust won't look at the tags either. Worse, I don't think Zig has niche optimisation, so there are a bunch of cases where Zig has to choose, have tags (more space, slower, but same safety as Rust) or go without (same size as Rust, but less safety). Rust only guarantees the niche in certain narrow cases like Option<&T> (is the same size as &T) but it actually delivers a lot more than that, and this continues to improve.
I’m darn sick of reading articles that don’t consider tradeoffs. Engineering can be defined as “a search for the optimal set of tradeoffs”. But, it seems like every article I ever read about new tech only discussed pros, not cons. I don’t get it. The cons are just as important, if not more so. Unfortunately, for some reason, people associate cons with flaws/weaknesses/failures rather than just a “point on the tradeoff curve”. Often: no perfect solution exists and those claiming otherwise are being dishonest. I would love to see this culture change at least for engineering literature where we should know better.
All that said: I DO think that Zig has selected a reasonable point in the tradeoff curve. I just really want to understand that point better. How am I supposed to use it most effectively if I don’t understand it?
If your dependencies wrap more than 2 external libraries that have poor architecture, you're about to have a miserable time in Rust.
Rust creates a lot of friction if an external library has a poor architecture. And, by default, most libraries have a very poor architecture. This leads to things like:
"Giving up on wlroots-rs"--http://way-cooler.org/blog/2019/04/29/rewriting-way-cooler-i...
Talk about the issues of RLua--https://www.reddit.com/r/rust/comments/biq864/giving_up_on_w... "The thing that was SO tiring about writing this for me wasn't Rust, it was reasoning about the C library that I was binding. I'm just... tired of using C APIs! I'm tired of not having the compiler reason for me, I'm tired of having to second guess at what invariants the API expects and guess at what of a myriad of interesting non-local behavior a function call will do. I know that the Lua C API is a really, especially bad example, but I just don't know what the general solution is here."
Consequently, this is where you get "Rewrite All The Things In Rust!" What's the point of fighting with Rust over all these nice safety guarantees and then throwing them out at the API boundary? If you can keep everything in Rust, this simply isn't an issue.
This is where Zig shines. It catches some of the main classes of bugs but still lets you talk to libraries with crap architecture. Zig gets the fact that infrastructure and ecosystem matter--unlike everybody else operating in the C space (the cross compiling is phenomenal, for example, because they put a lot of work into it). Zig is doing some interesting things, but it's mostly some very solid choices with a lot of elbow grease applied. And that's worth a LOT.
As an aside, I will also say that Zig feels better for embedded programming than Rust. Rust feels like it has stalled in embedded areas (things like sized integers, bitfield packing, placement construction of data structures, statics and globals that don't have locking overhead, lack of do-while, etc. although I would like to point out in Rust's favor that Rust finally got label-break-value which is a godsend when porting embedded code with goto error handling) while Zig has explicitly targeted those from the beginning. That makes Zig feel better for embedded from an ergonomic perspective, to my (obviously quite subjective) taste.
That said, until someone works out how to properly integrate Zig or Nim or etc. into CMake, then there’s always going to be friction. Too much of the IDF world relies on it and it’s quirks.
In Nim land I get around this by --compileOnly to C sources, which works but is a little cumbersome. Rust is rebuilding the embedded world from scratch, which is a non starter for my work. I do have hope Zig will have a better story than both for this, but we will see!
Oh, interesting, I was wondering if this (breaking with both a label and a value) existed (in a different context), and I concluded it didn't exist and so probably nobody had wanted it, but turns out it's in the next version and I didn't hear about it.
I don't understand a desire for do-while, I can't imagine what I might write where that looks reasonable and the loop equivalent in Rust is not very good.
If all you wanted was nicer C then yeah, Zig is that and Rust isn't -- I just don't think "nicer C" is a reasonable goal any more.
It has to do with porting code, generally. If I'm writing original Rust, I rarely miss it. It's not a huge deal, but you bump into it juuuust often enough to (probably unfairly) curse Rust out when you hit it yet again.
Here's code from CMSIS DAP debug probe firmware from ARM: https://github.com/ARMmbed/DAPLink/blob/main/source/daplink/...
Note the nested do { do { } while (); if (err) break;} while(); if (err) break;
Should that code be rewritten? Most certainly it should, and it should be given a proper Error type. However, when you are first porting it, you need to match semantics or you get a bunch of off-by-one, missed error, or missed end of stream bugs. And, as you point out, the Rust loop{} equivalents suck.
We've all written suboptimal code, and we all live in a suboptimal world. :)
do { ... } while (((data & DAP_Data.transfer.match_mask) != match_value) && match_retry-- && !DAP_TransferAbort);
becomes something like loop {
...
if data & DAP_Data.transfer.match_mask) == match_value {
break;
}
match_retry -= 1;
if match_retry == 0 {
break;
}
if check_transfer_abort() { // Replaces DAP_TransferAbort
break;
}
}
I think that's actually more clear, and my mental focus is DAP_TransferAbort. I think it's just C programmers being C programmers, and we just want AtomicBool for this but I'm more comfortable wrapping it in this check_transfer_abort() function while I investigate whether anybody thinks this is a counter, or there's actually physical hardware backing it despite the way it looks like it's just a global variable.If we take comptime feature away, Zig is basically Modula-2 (1978) with friendlier syntax for folks with C background.
Everything else in terms of security Modula-2 did better (yes it did have null pointers, but supports null checking).
And the tagging mechanisms for use-after-free are anyway available for C and C++ compilers for at least 20 years (Insure++, PurifyPlus and VC++ 6.0 debug allocator being part of the first ones).
Other things that are made explicit such as specifying an allocator on every allocation is an inconvenience that you pay for that flexibility. The choice of safety being added rather than assumed and later satisfied or declared an unsafe area is vastly different with a tradeoff in how developers use the language. An example given was how the self-hosted complier can use cache optimized untagged unions with the tags in a separate array.
If I had to guess what the single biggest pattern of tradeoffs is, I'd say it's about being explicit rather than implicit. You gain fine-grained control at the cost of brevity/convenience.
compare [language] with C, fairly and accurately, if you want me to believe a word you say.
Rust has never been publicized as a solution to all problems. It happens to be my current favorite language for many tasks, but I don't think any serious Rust developer harbors the impression that Rust is designed to implement everything.
That being said, the community is much larger these days, so I assume that there are users who do not have a sufficient PL background to understand the tradeoffs made in the design of Rust.
Go was aiming to be a replacement for C as well, but they didn't even try to reach that goal because Go programs require an OS to run on; something must host the runtime, even though it's included in the binary. I love Go and I dislike C quite a bit.
Zig vs. C seems fair to me, because there are no Zig zealots (or very few, at least not yet) and there are no C zealots who have lots of experience with C.
> Zig has a global allocator that you’re encouraged to use
wat
so the word 'default' may not necessarily apply the way it doesn't apply wrt to 'default pointer' in C++ because it offers both unique_ptr and shared_ptr with very different behaviors and semantics.
The stdlib then provides a variety of allocator implementations that can be plugged into those allocator arguments.
We didn’t use Zig for our firmware, but we did come up with a similar approach in Nim (basically a neat type that takes allocators/frees/reallocs and handles managing the ref count so you can use the variable as any other Nim variable. It’s not perfect, but close enough for our purposes)
Zig provides a global allocator when running tests that will check for memory leaks and double frees. This allocator is only available in the test suite tho, if you want to compile an executable, one has to chose between several allocators and instantiate them explicitly.
There are three allocators that are globally available in the standard library: - `std.heap.c_allocator`, a wrapper around malloc/free. only available when linking libc - `std.heap.page_allocator`, a wrapper around VirtualAlloc/mmap. Can only allocate in OS page size granularity and is quite slow. A good backing for other allocators tho. - `std.testing.failing_allocator`, an allocator that will always yield OutOfMemory.
For the use in an application, there's the std.heap.GeneralPurposeAllocator type that needs to be explicitly instantiated, and it provides a good portion of debugging features right now. High-speed implementations are planned for release modes
> By convention, Zig has a global allocator that you’re encouraged to use for all your unit tests
If you read sentences in bits and pieces by themselves, you'll find that it does lead to a lot of misinterpretation.
> Zig is faster than C. This is partly a result of Andrew’s data-oriented design approach that led to performance-enhancing changes in Zig programming that would not be possible in other languages.
Can anyone comment on this claim? It's quite bold given that the competition usually aims to get close to the speed of C; getting faster would be an amazing feat regardless of syntax used.
About the speed of the generated code, I have not seen many benchmarks, but I expect to see minimal differences with C.
With better semantics around pointer aliasing and parallel-friendly loops they could produce faster machine code than C, but likely not by a huge margin.
It could be true though, because compilers aren't perfect, and for instance it actually happens with scientific code written in Fortran: C has restrict but because it's not widely used, C compilers don't make use of it as much as they could for optimization, which means that a language like Fortran that values the non-aliasing guarantee can have a compiler which optimizes it more than what C compilers do.
That being said, there's nothing preventing C compilers to add the same level of aliasing-based optimizations in theory, it's just that they don't do it yet in practice (Rust is pushing the boundaries on that for LLVM though, because noalias optimizations is regarded as a promising performance enhancement for Rust).
There's also an important lesson here about optimization BTW. Everyone thinks of optimizing algorithms in terms of big-O stuff, and that's great, but sometimes data size or layout or frequency of pointer-chasing or mispredicted branches can actually be the real difference makers. Cache-friendly vs. cache-hostile code can be particularly hard to spot, because it often doesn't show up even in most kinds of profiling. It presents only as a sort of general performance degradation across the board unless you're specifically looking for it. I've implemented significant performance gains just by rearranging code and scheduling to make better use of a processor's L1 I-cache. The gains to be had from doing the same for data in L2 and L3 caches are even greater. I appreciate that Zig is being designed with these issues in mind, because it seems like a lot of other newer languages are being designed with the specific goal of defeating modern computers' performance-enhancement techniques.
If you limit it to actual C and don't include assembly, it's possible the statement is true but I would expect it to also be false depending on the circumstances.
zig is going to have more opportunities to optimize but may be paying a cost in terms of offered features that C doesn't have.
But in general, yes, zig is very fast and competes with C in terms of performance.
There is no myth here, it's a simple statement of fact.
The Zig compiler does things C compilers can't. C developers can get access to those same techniques by bypassing C compilers to express them directly in assembly.
This is not arguable, it simply is.
parser for unicode, go. I'll wait over here.
I didn't wrote that every developer out there was writing 100% of business applications in Assembly, and I guess I don't need to explain the difference between ∃ x and ∀ x.
Except for QBasic, all of them compile to machine code and have features that can't be represented in idiomatic C, but are often trivial to implement on the underlying hardware.
You started talking about speed, now you're talking about "expressiveness", which is a wholly different thing.
Stay on topic.
I wouldn't expect an assembler try to apply any further optimizations to my assembly code, that would be indeed quite pointless.
For any specific problem, a human writing assembly code will almost always be able to exploit weird loopholes in a specific CPU instruction set that a compiler for a high level general programming language won't be able to (unless we're going into the realm of superoptimization via brute-force trial-and-error where an optimizer may eventually arrive at the same tricks that an anarchist assembly programmer would do).
I can't think of a major C compiler that does not allow for this, can you? The only thing I could imagine coming close is something like compiling to a very specific target such as GPU shaders, but even that's a bad example considering shaders have been standardized.
My point still remains but I take your point about its limitations. My post was more a caveat on the original point anyways, I wouldn't expect most people to drop down to assembly except in very specific circumstances.
You can do it in any other systems language, of course, but the Zig syntax is very convenient so arguably it's more likely to be used.
https://zig.news/kristoff/struct-of-arrays-soa-in-zig-easy-i...
Why would it be obviously false? C is not that low-level of a language, for example it has no native way of doing SIMD - so Rust, C++ (and I guess Zig as well?) all have programs that are simply non-expressible in C (inline assembly is not C) and are possibly orders of magnitude faster. But there are million other things “hard-coded” into C where with more control faster execution could be achieved - e.g. one might want to have better control over stack usage.
Andrew has been using data oriented approaches to make the self hosted compiler fast. And the article mentions an example where he's separated (presumably arrays of) tagged unions into distinct arrays of tags and payloads. Depending on the access pattern, this can definitely speed things up on modern CPUs.
The article then goes on to say that this is impossible in Rust, which is probably a fair statement. You may be able to get something quite close with really horrible, unsafe code but no sane person would try.
But it's blatantly untrue that you can't do this in C. I mean, C doesn't even have tagged unions so you need to code the tags explicitly if you want them. Separating them into a different array is trivial.
I guess it's possible that one could argue, say, Turing idiomatic Zig into highly optimised Zig is easier than the equivalent in C so, in practice, equivalent code is faster. Or some such. I have no idea if anyone's actually shown that quantitively though.
As it happens, a crate was just released that does exactly this, using a derive macro [0]. (I wouldn't recommend using it quite yet, though; it still has a few kinks to be worked out.)
But Zig might have some features which might theoretically allow the compiler to generate slightly more performant code (compared to portable standard C code). E.g. since function arguments are read only, the compiler might be able to avoid a copy on the stack when passing structs > 16 bytes by value.
Also since Zig programs are (AFAIK) generally compiled as a unity build, the compiler has more inlining opportunities compared to a conventionally linked C program (without LTO).
Etc... etc.. so theoretically it might be possible, but I'd still take such statements with a grain of salt until demonstrated on real world code.
> Andrew: I claim that Zig is faster than C....
> I can give micro benchmarks of messing around with integers...
> I would make argument that based on principles of the language, the conventions that we have, the organization of standard library, there are also results in practice that Zig application tend to be faster...
> In C signed integer addition overflow is undefined and unsigned integer overflow is wrapping. In Zig it's consistent and signed and unsigned overflow is illegal behavior.
People still compare to C because (1) it is easier to beat, (2) you are less likely to be accused of cooking the comparison by coding the reference badly, and (3) you don't provoke the question, "why not just code in that, then?"