SectorC: A C Compiler in 512 bytes
xorvoid.com
xorvoid.com
That said, the title is just a little clickbaity --- it's a C-subset compiler, and more accurately a JIT interpreter. There also appears to be no attempt at operator precedence. Nonetheless, it's still an impressive technical achievement and shows the value of questioning common assumptions.
Finally, I feel tempted to offer a small size optimisation:
sub ax,2
is 3 bytes whereas dec ax
dec ax
is 2 bytes.You may be able to use single-byte xchg's with ax instead of movs, and the other thing which helps code density a lot in 16-bit code is to take advantage of the addressing modes and LEA to do 3-operand add immediates where possible.
This is why some JIT'd languages (like Javascript) can be sometimes faster than C.
If the JIT has instrumentation that analyzes execution traces, then it can notice that a call through a function pointer always goes to the same function. It can then recompile that code to use a static function call instead, which is considerably faster.
Basically, it can perform a similar set of optimizations to a static compiler + profiling information in basic cases. In more advanced scenarios, it specializes the program based on the runtime input, which profiling can't do for all possible inputs, eg. say the above function pointer call only happens for input B but not for input C.
For another crowd "fast" means that the code they haphazardly thrown together in an interpreted language runs fast enough that nobody is negatively affected (which is a completely valid usecase, not judging here).
And to answer your question for examples:
An interpreter with JIT compiler might for example notice that you have a for loop that always gets run with the same number of arguments, unroll the loop and at the same time vectorize the instructions for an immediate 4x gain in execution speed.
Otoh Javas Hotspot JIT compiler tracked how often code was called and once a "hotspot" was identified compiled that part of the program.
Last example: if you are using an interpreted language (say Python) every roundtrip through "python-land" costs you ... A simple for loop that just runs a simple instruction (say: acc = 0; for x in xs: acc += x) will be orders of magnitudes slower that calling a dedicated function (numly.sum(xs)), JITing that code (e.g. with numba) will remove the roundtrip through python and achieve similar speeds.
But what it is is mostly choosing optimizations based on runtime characteristics. It's a dynamic analogue to profile-guided optimization. Like you might have an optimization that trades code size for CPU cycles, which you could choose not to do at runtime if you're low on memory bandwidth instead of CPU time. Stuff like that.
In practice, static AOT compilation is essentially always faster for a couple reasons. The various types of overhead associated with supporting dynamic re-compilation usually aren't offset by the gains. Re-compiling code at runtime is expensive, so it is virtually always done at a lower optimization level than AOT compilation to minimize side-effects. CPU silicon is also quite good at efficiently detecting and optimizing execution of many of these cases in static AOT code. You can also do static optimization based on profiling runtime execution, which is almost (but not quite) the same thing with more steps.
(JIT interpreter is not a phrase I'm familiar with.)
You can also have an optimizing JIT compiler. Or further, a profiling JIT compiler.
[0]: <https://softwareengineering.stackexchange.com/questions/2460...>
In general, reading (lots of) source code is a good way to learn how to do things in a new language, i.e. to move from the lexical and syntactical level to the level of idioms for problem-solving. On reflection, I find it strange that in programming teaching, larger pieces of existing well-written source code are never discussed/explained/critiqued.
http://t3x.org/subc/index.html (source code)
https://guix.gnu.org/blog/2023/the-full-source-bootstrap-bui...
All of this is incredible work, but a minimal C-subset compiler in under 512 bytes of x86 assembly seems like a unique achievement as it includes non-trivial parsing and linking phases not required for that hex0 interpreter.
> Even more recently (2018), the GNU C Library glibc-2.28 adds Python as a build requirement
I’m surprised they went with Python and not GNU software (e.g. Guile).
Edit: clicking through the link it sounds like this might be intended to replace other accumulated dependencies (Perl?) and stop supporting old versions of Python.
[0] https://bootstrappable.org/ [1] https://bootstrapping.miraheze.org/wiki/Live-bootstrap
I once read a lisp paper that kept defining lisp primitives in terms of simpler lisp primitives until they had only 4-10 primitives. Then the paper started defining those in terms of assembly language (but with parens). In the final step, they just removed the parens. It was so elegant. I can't find the paper though :( Any ideas?
I considered writing a self-hosting compiler. It should be doable without too much work. I was going to do one in BarelyC originally.
SectorC -> TCC -> GCC sounds fun. C all the way down, haha
Such anarchy has obvious security implications but has worked remarkably well in practice. There's a mailing list too.
Some of the compressors in that forum thread since they are 32 bytes and such, might find it easier to get net gains.
[0] https://github.com/temisu/oneKpaq
[1] https://encode.su/threads/3387-(Extremely)-tiny-decompressor...
Hashes are perhaps the holy-grail of computer-science. With a good hash, we can just side-step all the hard problems by trading them for an even harder problem (hash collisions), and then we just ignore that harder problem. Brilliant. (sticks fingers in ears)
I wrote a similar x86-16 assembler in < 512 B of x86-16 assembly, and this seems much more difficult <https://github.com/kvakil/0asm/>. I did find a lot of similar tricks were helpful: using gadgets and hashes. Once trick I don't see in sectorc which shaved quite a bit off of 0asm was self-modifying code, which 0asm uses to "change" to the second-pass of the assembler. (I wrote some other techniques here: <https://kvakil.me/posts/asmkoan.html>.)
bootOS (<https://github.com/nanochess/bootOS>) and other tools by the author are also amazing works of assembly golf.
I'll point out to any passerby that this C doesn't support structs, so it's unlikely you'd actually want to build anything in it.
I think this, from the conclusion, is the real takeaway:
> Things that seem impossible often aren’t and we should Just Do It anyway
I certainly would never have tried to get a C compiler (even a subset) so small since it my instinct would have been that it was not possible.
Perhaps with a few more "layers of compilers" on top you can get a very early GCC going.
Applause. Best comment ;)
The problem is not just declaring structs but accessing them, referencing them, accessing/referencing fields and subfields, etc. That has to all be in the syntax. For such a minimal project you can ignore initialization.
I did all this while adding maybe 500 lines of C to GitHub.com/vitiral/fngi. Never tried for this level of minimalism though.
[1]: This is why all of the names of struct members in unix are "needlessly" prefixed; i.e. struct stat.st_mode is not struct stat.mode because that could conflict with struct foo.mode until the early 1980s.
There's something to be said for taking advantage of that existing code; but if that's a dependency it should be part of the environment manifest. Everything has (most things have) a context where it might be useful and that should be included in the explanation so a tool isn't misused and so incorrect expectations aren't set.
I would have preferred 32-bit mode because real mode is awkward with its seg:off addressing scheme.
Using the bios functions just allows it to have “not-boring” examples easily.
It's like using an M18A1 Claymore mine and hoping it actually is aimed (and stays aimed) in the right direction.
Or BCPL, for short.
> The C programming language was devised in the early 1970s as a system implementation language for the nascent Unix operating system. Derived from the typeless language BCPL, it evolved a type structure; created on a tiny machine as a tool to improve a meager programming environment, it has become one of the dominant languages of today. This paper studies its evolution. [1]
Especially liked this nugget:
> (NOTE: This grammar is 704 bytes in ascii, 38% larger than it's implementation!)
I would have wished some explanation on where the function calls like vga_init and vga_set_pixel come from, I'm not a graybeard yet.
or alternatively for embedding a C compiler inside a LLM to use the LLM as a form of virtual machine.
What would a C interpreter do that wouldn't be done better by simply uploading a new compiled binary, though? The source code is most likely less space efficient than machine code.
do you think there are any lessons that can be applied to a "normal" interpreter/compiler written in standard C? i'm always interested in learning how to reduce the size of my interpreter binaries
niklaus wirth thought similarly... in 1995![0]
i enjoy implementing array langs (k primarily) so small binaries come with the territory. really appreciated your write-up. i may try something similar for an array lang.
you might also appreciate (or detest) 'b'[1][2] - a small implementation of a "fast c compiler (called b: isomorphic to c)". it has some similarities to SectorC.
[0] https://www.computer.org/csdl/magazine/co/1995/02/r2064/13rR... [1] https://web.archive.org/web/20230117082148/https://kparc.com... [2] https://github.com/kparc/bcc (more legible)
You can't have the goal for the code to generate revenue, at equal priority for it to be easy for lots of inexperienced programmers to work on (i.e. maximise costs), because the only time you're going to choose "readability" and "unit tests" (and other things with direct costs) is because of doubts you have in your ability to do the former without this hedging of your bets a little. If you disagree, you do not know what I said.
And so they ask: What is the next great software unicorn? Who knows, but being able to rapidly put stuff in front of consumers and ask is this it? has proven to be an effective way at finding out. And if you go into a product not knowing how (exactly) to generate revenue, you can't very well design a system to do it.
Do you see that? Software is small when it does what it is supposed to by design; Software is big when it does so through emergence. Programmers can design software; non-programmers cannot. And there are a lot more non-programmers. And in aggregate, they can outcompete programmers "simply" by hiring. This is why they "win" in your mind.
But this is also true: programmers who know what exactly makes money can make as much money as they want. And so, if someone has a piece of tiny software that makes them a ton of money, they're not going to publish it; nobody would ever know. 2-3 orders of magnitude could be taking a software product built by 100 people and doing it with 1-2. The software must be smaller, and that software I think "wins" because that's two people sharing that revenue instead of 100+management.
To that end, I think learning how to make tiny C compilers is good practice for seeing 2-3 orders of magnitude improvement in software design elsewhere, and you've got to get good at that if you want the kind of "wins" I am talking about.