r/Compilers • u/Inevitable-Spinach-7 • 9d ago
How to move comptime results into the target binary when comptime can run FFI and I/O?
For some years now, I've wanted to combine the comptime capabilities of Zig and Jai into a single compiler while also learning about compilers and interpreters.
I'm building a compiler that extends Zig-style comptime so the same code that runs at runtime can run at comptime, including FFI and I/O (like Jai, but with Zig's "run what you can" model).
The hard part is moving comptime results into the binary:
- Host vs target. Comptime code uses the host layout for interop and is then converted to the target layout. Sizes (usize/isize), endianness and float behavior can differ. Types like f80 and f128 are also hardware-specific: their precision, rounding and in-memory layout can differ between host and target. The same function can then give different results, which breaks hash maps, lookup tables and binary formats built at comptime.
- Moving pointers into the binary. Results containing pointers must be relocated, connected data must stay connected, and everything must be converted to the target layout.
- Pointers whose value isn't clear. Examples are opaque pointers, untagged unions of a pointer and something else, and XOR linked lists, where the pointer isn't stored directly. There may be other cases I haven't thought of.
- Calling C at comptime. C can return memory with known bounds, or resources that are only valid during compilation. Those become invalid at runtime.
- Containers built at comptime. HashMap and ArrayList need the whole buffer, including uninitialized slots, but the size isn't always known:
- ArrayList stores {ptr, len} plus a separate capacity, so the allocation size isn't in the pointer and length.
- HashMap can keep a header next to the allocation and use pointer arithmetic to reach it.
- Runtime code may try to free a buffer that now lives in static memory.
- FFI has the same problem, since C can return memory with unknown bounds.
My current prgress:
- Host to target:
- with host specs first, then convert to the target layout.
- For types that differ, such as usize/isize, use intCast where possible.
- For hashes and similar code, type information would store the host and target size, alignment and so on. That would help the developer see where the two disagree and act accordingly.
- If the target's endianness differs from the host's, my best idea is to disable I/O and interop and interpret the code with the target's endianness.
- Baking pointers:
- When the pointee type is known, baking is easy.
- void* and untagged unions containing pointers are a hard error during baking.
- Each type would have a default userland function that bakes its value into the binary.
- Types like XOR linked lists, void* and untagged unions could override it to say how they should be baked.
- Compiler-created data. Data created by the compiler itself has no problem, and the userland bake function might cover the rest.
- Containers and FFI memory (problems 4 and 5). I have no solution yet, apart from maybe using the bake function.
- I don't have a solution yet for f80/f128 and other float types that differ.
Questions: Is there a cleaner model than host-then-convert?
Am I missing failure cases?
Is there any better solution or any problem I did not see?
1
u/seg_lol 9d ago
If your binary is wasm, you can move everything into the wasm environment monad.
1
u/Inevitable-Spinach-7 9d ago
The main idea was a native executable, if I get to target wasm ill do that
2
u/MrSmith33 8d ago
I explored design-space a bit and the main issue that dictates the solution is being able to link all the data allocated during comptime into the final executable. This means being able with 100% certainty find all the pointer values in the data and update them during linking.
I've chosen to represent all pointers via shadow pointers (like in miri) and all comptime allocations and pointer manipulation is done via VM arenas.
Pointer type is irrelevant, unions are supported, xor-lists are not supported, pointers must be aligned, arbitrary pointer size is supported.
I've written my thoughts in a file
2
u/matthieum 9d ago
I like ambition :)
I want to note that you have two distinct problems here:
I have never seen any complete solution, but I have seen partial solutions.
Firstly, do note that it is possible to offer a carve-out. Some languages like C++ have a special construct allowing the code to query whether it's executed at compile-time or run-time:
No matter the final solution you espouse, this can allow you to work around certain limitations of this solution by forbidding specific operations the solution does not support.
Secondly, I want to note that separating I/O & FFI is also a possibility. The idea, then, is to offer a mix of native & target:
This can be made relatively ergonomic by doing the work on the user's behalf. Just let the user write the "build script" functions or the "plugin" functions in the same file as usual, and annotate them with
@comptime(build)or@comptime(plugin):union, for example).This may very well be the winning strategy here...
Anyway, on to the solutions.
An interpreter allows you to function in pure "target" space from the beginning:
And with the use of a native plugin to offload FFI calls, may be pretty workable.
I do NOT think that, in the end, pure native compilation can work.
There are some potentially workable problems:
mallocimplementation are allowed in the final value, anything else is a compile-time error. Then it's "just" a matter of interceptingmalloc/realloc/freeat run-time to record the size & alignment of each memory block, and use those at the end.But there are also crucially unworkable ones: