Thoughts on Generating C
wingolog.org
wingolog.org
There's also the issue in that the following two things don't have the same semantics in C:
float v = a * b + c;
vs static_inline float get_thing(float a, float b) {
return a*b;
}
float v = get_thing(a, b) + c;
This is just a C-ism (floating point contraction) that can make extracting things into always inlined functions still be a big net performance negative. The C spec mandates it sadly!uintptr_t's don't actually have the same semantics as pointers either. Eg if you write:
void my_func(strong_type1* a, strong_type2* b);
a =/= b, and we can pull the underlying type out. However, if you write: void my_func(some_type_that_has_a_uintptr_t1 ap, some_type_that_has_a_uintptr_t2 bp) {
float* a = get(ap);
float* b = get(bp);
}
a could equal b. Semantically the uintptr_t version doesn't provide any aliasing semantics. Which may or may not be what you want depending on your higher level language semantics, but its worth keeping the distinction in mind because the compiler won't be able to optimise as well float v = (float) ((float) a) * ((float) b) + c;
Since v is float, the cast representing the return conversion can be omitted: float v = ((float) a) * ((float) b) + c;
Now, if a and b are already float, then it's equivalent. Otherwise not; if they are double or int, we get double or int multiplication in the original open code.Not necessarily! Floating-point contraction is allowable essentially within statements but not across them. By assigning the result of a * b into a value, you prohibit contraction from being able to contract with the addition into an FMA.
In practice, every compiler has fast-math flags which says stuff it and allows all of these optimizations to occur across statements and even across inline boundaries.
(Then there's also the issue of FLT_EVAL_METHOD, another area where what the standard says and what compilers actually do are fairly diametrically opposed.)
A floating expression may be contracted, that is, evaluated as though it were a single opera- tion, thereby omitting rounding errors implied by the source code and the expression evalua- tion method.86) The FP_CONTRACT pragma in <math.h> provides a way to disallow contracted expressions. Otherwise, whether and how expressions are contracted is implementation-defined.
If you're making a language that generates C, it's probably a good idea to pin down which C compilers are supported, and control the options passed to them. Then you can more or less maintain the upper hand on issues like this.
float a = /*expression*/
float b = /*expression*/
float c = a * b;
Contraction can only happen within the expressions on the right hand side, not mutually between a and b. This makes inlining, and even variable substitution not legally permitted by the C spec to be equivalent to the actually written out inlined version. Ie you can't transform: float a = v1 * v2;
float b = v3 * v4;
float c = a + b;
Into float c = (v1 * v2) + (v3 * v4);
As they aren't semantically equivalent, and compilers do follow these rules. The same applies for inline functions. Its one of the reasons why macros can sometimes perform better than force-inline functionsIn practice, the big 3 compilers all perform floating point contraction according to the spec, and this is a major issue if you want to get good gpgpu performance because this extends to OpenCL and C-style languages in general (like CUDA)
In the first `my_func`, there is the possiblity that `a` and `b` are equal if their struct layouts are equivalent (or one has a proper subset of the other's fields in the same order). To tell the compiler they don't overlap we would use `(strong_type1 *restrict a, strong_type2 *restrict b)`.
There's also the possibility that the pointers could point to the same address but be non-equal - eg if LAM/UAI/TBI are enabled, a simple pointer equality comparison is not sufficient because the high bits may not be equal. Or on platforms where memory access is always aligned, the low bits may be not equal. These bits are sometimes used to tag pointers with additional information.
Why not use "restrict" in this case?
I like compiler backends, but truth be told, I grow weary of compiler backends.
I have considered generating LLVM IR but it's too quirky and unstable. Given the Virgil wasm backend already has a shadow stack, it should now be possible for me to go back to square one and generate C code, but manage roots on the stack for a precise GC.
Hmm....
2 additional points,
1: The article mentions DWARF, even without it you can use #line directives to give line-numbers in your generated code (and this goes a very long way when debugging), the other part is local variables and their contents.
For variables one can get a good distance by using a C++ subset(a subset that doesn't affect compile time, so avoid any std:: namespaced includes) instead and f.ex. "root/gc/smart" ptr's,etc (depending on language semantics), since the variables will show up in a debugger when you have your #line directives (so "sane" name mangling of output variables is needed).
2: The real sore point of C as a backend is GC, the best GC's are intertwined with the regular stack-frame so normal stack-walking routines also gives everything needed for accuracte GC (required for any moving GC designs, even if more naive generation collectors are possible without it).
Now if you want accurate somewhat fast portable stack-scanning the most sane way currently is to maintain a shadow-stack, where you pass prev-frame ptrs in calls and the prev-frame ptr is a ptr to the end of a flat array that is pre-pended by a magic ptr and the previous prev-frame ptr (forming a linked list with the cost of a few writes, one extra argument with no cleanup cost).
Sadly, the performant linked shadow-stack will obfuscate all your pointers for debugging since they need to be clumped into one array instead of multiple named variables (and restricts you from on-stack complex objects).
Hopefully, one can use the new C++ reflection support for shadow-stacks without breaking compile times, but that's another story.
(though it is fine if GC can only happen inside a function call and the call takes the shadow stack as an argument)
You could put each stack frame into a struct, and have the first field be a pointer to a const static stack-map data structure or function that enumerates the pointers within the frame.
BTW, the passed pointer to this struct could also be used to implement access to the calling function's variables, for when you have nested functions and closures.
The GC-heap arguments to called functions with moveable pointers in the GC was indirected through the passed GC-chain ptr, ie you passed a pointer to the N:th element in the caller's shadow-stack that was then the 1:st heap based argument to the callee.
The benefit of that was the callee didn't need to copy over it's incoming arguments to a new shadow-stack (thus increasing a fixed costs of more arguments) but could just link the previous array and IFF there was any gc-action then pointers "visible" to both the caller(albeit expired) and callee would be correctly moved around.
I guess a "virtual" type as a head and members being an aliasing union could work well enough.
struct { StackVTab vtab; union { struct { TypeA *localA; TypeB *localB; ... }; void *rawPtrs[LOCALCOUNT]; }; } heapmembers;
As for closures that's trickier, locals visible to a nested function/closure needs to be moved to on-heap objects unless you do more or less recursive lifetime analysis (but that stuff quickly becomes expensive and/or indeterminate).This may be a stupid question, but if the function must tail, that's just a jump, no? Why not use goto?
I think emitting something like
#line 12 "source.wasm"
for each line of your source before the generated code for that line does something that GDB recognizes well enough.You can find all the possible tricks in making it debuggable by reading the y.tab.c
Including all the corner cases for odd compilers.
Re2c is a bit more modern if you don't need all the history of yacc.
But yes, you can put a line-oriented breakpoint on your action code and step through it.
In my case I had long-ish fragments, so I only put '#line' at the start of a fragment. The compiler counted subsequent lines itself.
It was a cross-platform project and worked equally well with GCC, Clang and MS Visual C++.
I was thinking about how to embed custom high level language into my backend application written in C++. Each individual script would compile to native shared lib loadable on demand so that the performance stays high. For this I was contemplating exactly this approach. Compile this high level custom language with very limited feature set to plain C and then have compiler that comes with Linux finish the job.
I’ve done a few such things for compiling ML models, but in the end I always regretted it.
Snark aside, the output targets of compilers need to be unsafe languages typically, since the point of a high level compiler in general is to verify difficult proofs, then emit constructs consistent with those proof results, but simplified so that they cannot be verified anymore, but can run fast since those proofs aren't needed at runtime anymore. (Incidentally this is both a strength and weakness of C, since it provides very little ability for the compiler to do proofs, the output is generally close to the input, while other languages typically have much more useful compilers since they do much more proof work at compile time to make runtime faster, while C just makes the programmer specify exactly what must be done, and leaves the proof of correctness up to the programmer)
Not easy, but there are compilers that do it.
Lobster [0] started out with automatic reference counting. It has inferred static typing, specialising functions based on type, reminiscent of how Javascript JIT compilers do it. Then the type inference engine was expanded to also specialise functions based on ownership/borrowing type of its arguments. RC is still done for variables that don't fit into the ownership system but the executed ops overall got greatly reduced. The trade-off is increased code size.
I have read a few older papers about eliding reference counting ops which seem to be resulting in similar elisions, except that those had not been expressed in terms of ownership/borrowing.
I think newer versions of the Swift compiler too infer lifetimes to some extent.
When emitting Rust you could now also use reference counting smart pointers, even with cycle detection [1]. Personally I'm interested in how ownership information could be used to optimise tracing GC.
[0]:https://aardappel.github.io/lobster/memory_management.html
[1]:https://www.semanticscholar.org/paper/Breadth-first-Cycle-Co...
Tangentially, i was experimenting with a runtime library to expose such "borrow-first" semantics, such "lents" can be easily copied on a new thread stack to access shared memory, and are not involved in RC . Race-conditions detection helps to share memory without any explicit move to a new thread. It seems to work well for simpler data-structures like sequence/vectors/strings/dictionary, but have not figured a proper way to handle recursive/dynamic data-structures!
I'd have to do an actual project to see how annoying it is to lower semantics to unsafe Rust to know for sure, but my guess is you'd be slightly better off because you don't have to work around implicit conversions in C, the more gratuitous UBs in C, and I think I'd prefer the slightly more complete intrinsic support in Rust over C.
If it's true that a C program doesn't have control of the stack, what does that mean for supporting the stack switching in Wastrel? Can you not reify the stack and replace it with another from a suspended async function? Do you need some kind of userland stack for all stacks once you support WASM stack switching?
Often times virtual functions are implemented in C to provide an interface (such as filesystem code in the Linux kernel) via function pointers—-just like C++ vtable lookups, these cannot be inlined at compile time.
What I wonder is whether code generated in C can be JIT-optimized by WASM runtimes with similar automatic inlining.
I found this Reddit thread that gives a bit more detail:
https://www.reddit.com/r/haskell/comments/1pbbon/c_as_a_proj...
and the project link:
Then, we have both a precise semantics and tools to help produce robust output.
The main reason you'd target C is for portability and free compiler optimisations. If you start inventing new intermediate languages or C dialects, what's the benefit of transpiling in the first place? You might as well just write your own compiler backends and output the machine code directly, with optimisations around your own language's semantics rather than C.
Imho, C89 is the strict subset that a compiler ought to target, assuming they want C's portability and free compiler optimisations. It's well understood, not overly complex, and will compile to fast, sensible machine code on any architecture from the past half century.
Say I am writing a transpiler to C, and I have to choose whether I will target C89, or some arbitrary subset of C23. When would I ever choose the latter?
The only benefit I could think of is where you're also planning to write a new C compiler, and this is simplified by the C being restricted in some way. But if you're doing this, you're just writing a frontend and backend, with an awkward and unnecessary middle-end coupling to some arbitrary subset of C. What's the benefit of C being involved at all in this scenario?
And say you realise this, and opt to replace C with some kind of more abstract, C-like IR. Aren't you now just writing an LLVM clone, with all the work that entails? When the original point of targetting C was to get its portability and backends for free?
Yet you trust it to generate the frame for this leviathan in the first place. Sometimes C is about writing quality code, apparently, sometimes it's about spending all day trying to outsmart the compiler rather than take advantage of it.
(My experience with "compile to C" is with cfront, the original C++ implementation that compiled to C. The generated code was just terrible to read.)
We used this in production with Nim for embedded firmware engineering at my previous job doing industrial IoT systems, which let us write a much nicer language than C (and much faster, much safer), with code-sharing between the network server (and its comms protocol) and the firmware code itself.
All can be done with C itself of course, but this let us achieve it faster and in a much nicer fashion
- standardized alloca()
- fine-grained control over struct layout
- some way to perform a guaranteed tailcall
- some way to do stuff like tagged pointers portably without triggering UB
Full VLA support is optional for C compilers though...
I really wish someone on the C language/compiler/linker level took a real look at the problem and actually tried to solve it in a way that isn't a pain to deal with for people that integrate with the code.
// a.c
int f( int x ) {
return x + 1;
}
// b.c
extern int f(int x );
int main() {
int y = f(41);
}
but if f() had been defined as static, you couldn't do this.Just because you can use the information you have to call a given function, doesn't mean you aren't violating an interface.
For most purposes, not being able to access something, and being able to access something not officially in an interface, where doing so introduces an unpredictable breaking dependency, the practical result is the same: You can't (actually/sensibly) do it.
The whole point of using "static" in that way is to prevent people from using it outside of the file.
If you need to call a static function (inline or otherwise) from outside of the compilation unit to use the API, then it's a bug in the API, not a problem with static.
I agree with you about pre-processor macros, though.
int Foo(int a, ...);
But in fact the types are restricted depending on a call type, set by 'a'. So I could add a wrapper for a specific variant: static inline FooVar1(char b, long c)
{
return Foo(FOO_VAR_1, b, c);
}
All the wrapper does is adds a bit of type control during compilation, otherwise it must be just a function call to 'Foo'. It is like a supermacro. It does make sense to put that into 'static inline' in a header.It also does not have to do anything with the language, because it IS possible to have FooVar1 as part of the interface. The library developer just didn't want that, likely because they do not want tons of slightly different functions in the interface that they would need to maintain, when they already exposed that functionality.
EDIT:
Concerning your earlier complaint, that you would need to
> have to redo subsequent version updates in your code.
, such a change would be ABI-incompatible anyway and demand a change in the major version number of the library. Neither your FFI, nor any program even when written in C, is going to work anymore. The latter, would just randomly crash, not sure about the former.
Note, that you also see here, where the real interface is. A change to Foo itself, would break programs written in C as well as your FFI, while a change to FooVar1 wouldn't matter at all. Both C programs and your FFI will continue to run just fine without any changes.
No. They do it because it avoids the overhead of dynamic dispatch. Nothing else.
So when I've seen these in reality in real library interfaces they have not been 'example wrappers'. They are real intended usage of the API, and in some cases the lib authors recognize that it is a problem and even provide ways to build it without the issues - either by compile time flag or always outputting two variants of the .so. The former moves the question to the distro - should they provide the version optimized for C or the one optimized for other languages? In the case of the latter it obviously creates a situation where the vast majority of both .so-s are duplicated.
As an example of the compile flag, there is libpipewire. As an example of providing two versions of the .so, there is liburing.
So no, this is clearly something the language ecosystem lacks a good answer to. There is currently no way to make everyone happy.
> such a change would be ABI-incompatible anyway
True, but the C based ones can at least simply rebuild and the problem would go away.
Example: https://github.com/axboe/liburing/blob/master/src/include/li...
Do you have a relevant example?
Thankfully that particular library do nowadays also provide a _ffi version of the .so as it is such an horror otherwise. But there are other libraries out there that don't do that, where it is almost as big of a hurdle to bind with.
It exists as "inline" and "extern inline".[1] Few people make use of them, though, partly because the semantics standardized by C99 were the complete opposite of the GCC extensions at the time, so for years people were warned to avoid them; and partly because linkage matters are a kind of black magic people avoid whenever possible--"static inline" neatly avoids needing to think about it.
[1] See C23 6.7.5 at https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3220.pdf#p...
Sure, except when you do FFI bindings. Then those functions just don't exist and you get a linking error.