A small IR for building programming languages.
- Simple DSL over LLVM/QBE.
- Your language AST -> mycIR -> [LLVM / QBE / C] -> binary.
- ~29 stack-based opcodes.
- Whole IR spec fits in 30 minutes of reading.
- Compiles to native code via LLVM, QBE, or C.
- Fast compilation, "zero" overhead (I hope).
- ~7000 lines in Crystal.
- Includes mycc as POC: a C subset compiler using myc as backend and libclang for parsing.
- I was writing my own language and got tired of fighting with LLVM IR. SSA, phi nodes, basic blocks — X(.
- Usually when you write your own language, you first build a parser and generate an AST. Then comes the hell stage — translating your AST into LLVM or another backend. Myc takes on all that complexity.
- LLVM is complex.
- Myc is simple and fun.
- Stack-based opcodes are easy to emit from AST with a simple one-pass tree walk.
- You're not locked into one backend. LLVM for speed, QBE for fast compiles, C for anywhere.
Alpha. But already powerful. All 3 backends work smoothly. 4100 tests pass. mycc can compile LangArena benchmark (230kb non-trivial C code).
Beat LLVM (joke). Real goal: beat gcc :).
Mandelbrot renderer from mandel.bf (by Erik Bosman). All IRs represent the same program. This shows whether Myc adds overhead over direct backend usage. Running on Ryzen3800+Linux in benchmark/brainfuck-compiler.
| IR | Compiler | IR size, Kb | Compile time | Run time |
|---|---|---|---|---|
| llvm-ll | clang(-O3) | 1529 | 1528ms | 621ms |
| myc | myc-llvm(--release) | 486 | 1528ms | 631ms |
| qbe-ssa | qbe + clang(as+linker) | 345 | 199ms + 63ms | 807ms |
| myc | myc-qbe(--release) | 486 | 1047ms | 833ms |
| c | clang(-O3) | 128 | 1576ms | 635ms |
| myc | myc-c(--release) | 486 | 1822ms | 638ms |
Myc adds "zero" overhead over the LLVM and C backends. The myc-qbe backend adds overhead due to suboptimal code generation, which will be addressed by future peephole optimization passes.
Requires Crystal to compile the myc compiler.
echo 'FUNC main BODY PUSH "Hello myc\n" PRINTF 0 ENDFUNC' | crystal src/cli/llvm.cr rgit clone https://github.com/kostya/myc
cd myc
# compile Myc IR C backend
crystal build src/cli/c.cr --release -o myc-c
# compile Myc IR LLVM backend
# requires LLVM >= 15.0, install it system wide or provide LLVM_CONFIG env variable
crystal build src/cli/llvm.cr --release -o myc-llvm
# compile Myc IR Qbe backend
git clone https://github.com/kostya/qbe.git plugins/qbe
cd plugins/qbe; make; cd -
crystal build src/cli/qbe.cr --release -o myc-qbeAll opcodes self documented. Also see examples.
- 23 main opcodes: PUSH, LOCAL, STORE, CALL, PARAM, BINARY, UNARY, FIELD, DEREF, ADDR, AS, SELECT, MALLOC, CREATE, INSPECT, PRINTF, STACK, SIZEOF, TO, INVOKE, LABEL, GOTO, ALLOCA.
- 6 Control flow: IF/THEN/ELSE, LOOP/INIT/COND/BODY/STEP, SWITCH/CASE, BREAK, NEXT, RET.
- Types: STRUCT, ENUM/VARIANT, FLAT + void, bool, i8..i64, u8..u64, f32, f64, ptr.
cd benchmark/brainfuck-compiler
python3 bf2myc.py mandel.bf | ../../myc-llvm run --release./myc-llvm run --release examples/ir/mandel.myc
./myc-llvm run --release examples/ir/bf.myc
./myc-llvm run --release examples/ir/loop.mycexamples/ir/fact.myc
FUNC fact
ARGS
TYPE i32
RETURN
TYPE i32
BODY
PUSH 1
PARAM 0
BINARY less_eq
IF
THEN
PUSH 1
RET
ENDIF
PUSH 1
PARAM 0
BINARY sub
CALL fact
PARAM 0
BINARY mul
RET
ENDFUNC
FUNC main
BODY
PUSH 5
CALL fact
INSPECT
ENDFUNC
LLVM Backend `./myc-llvm dump examples/ir/fact.myc`
; ModuleID = 'fact'
source_filename = "fact"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-darwin23.3.0"
@str = private constant [15 x i8] c"fact(%d) = %d\0A\00"
define i32 @fact(i32 %0) {
alloca:
%__myc_result = alloca i32, align 4
br label %body
body: ; preds = %alloca
%1 = icmp sle i32 %0, 1
br i1 %1, label %then, label %endif
ret: ; preds = %endif, %then
%2 = load i32, ptr %__myc_result, align 4
ret i32 %2
then: ; preds = %body
store i32 1, ptr %__myc_result, align 4
br label %ret
endif: ; preds = %body
%3 = sub i32 %0, 1
%4 = call i32 @fact(i32 %3)
%5 = mul i32 %0, %4
store i32 %5, ptr %__myc_result, align 4
br label %ret
}
define void @main() {
alloca:
br label %body
body: ; preds = %alloca
%0 = call i32 @fact(i32 5)
%1 = call i32 (ptr, ...) @printf(ptr @str, i32 5, i32 %0)
br label %ret
ret: ; preds = %body
ret void
}
declare i32 @printf(ptr, ...)
QBE Backend `./myc-qbe dump examples/ir/fact.myc`
data $str_0 = { b "fact(%d) = %d\n", b 0 }
export function w $fact(w %arg0) {
@start
%__myc_result =l alloc8 4
jmp @body
@body
%t1 =w cslew %arg0, 1
jnz %t1, @then_1, @endif_2
@then_1
storew 1, %__myc_result
jmp @ret
@endif_2
%t2 =w sub %arg0, 1
%t3 =w call $fact(w %t2)
%t4 =w mul %arg0, %t3
storew %t4, %__myc_result
jmp @ret
@ret
%ret_val =w loadw %__myc_result
ret %ret_val
}
export function $main() {
@start
jmp @body
@body
%t1 =w call $fact(w 5)
%t2 =w call $printf(l $str_0, ..., w 5, w %t1)
jmp @ret
@ret
ret
}
C Backend `./myc-c dump examples/ir/fact.myc`
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <inttypes.h>
int32_t fact(int32_t arg0);
void main();
int32_t fact(int32_t arg0) {
int32_t __myc_result;
int t1 = arg0 <= 1;
if (t1) goto then_1; else goto endif_2;
then_1:;
__myc_result = 1;
goto ret;
endif_2:;
int32_t t2 = arg0 - 1;
int32_t t3 = fact(t2);
int32_t t4 = arg0 * t3;
__myc_result = t4;
goto ret;
ret:;
return __myc_result;
}
void main() {
int32_t t5 = fact(5);
int32_t t6 = printf("fact(%d) = %d\n", 5, t5);
goto ret;
ret:;
return;
}
crystal spec
Usage
Usage: ./myc-llvm COMMAND [OPTIONS] INPUT [INPUT]* [OUTPUT]
Commands:
compile|c ; compile multiple .myc files into executable binary
; ./myc-llvm c file.myc out
; ./myc-llvm c --release *.myc out
; cat file.myc | ./myc-llvm c --release out
run|r ; compile multiple .myc files and run the program
; ./myc-llvm r file.myc
; ./myc-llvm r --release file.myc
; cat file.myc | ./myc-llvm r --release
obj|o ; compile one .myc file into object file (.o) for linking
; ./myc-llvm o file.myc file.o
; ./myc-llvm o --release file.myc file.o
; cat file.myc | ./myc-llvm o --release file.o
dump|d ; output backend IR to console (for debugging and optimization analysis)
; ./myc-llvm d file.myc
; ./myc-llvm d --release file.myc
; cat file.myc | ./myc-llvm d --release
beautify|b ; format, validate, and add auto-comments(--annotate) to .myc files
; ./#{cli_name} b .
; ./#{cli_name} b --annotate src/
; ./#{cli_name} b file1.myc file2.myc
version|v ; display version information
; ./myc-llvm version
OPTIONS:
--release ; compile in performance mode (optimizations enabled)
--target=TARGET (TARGET: arm64, x86_64, x86, wasm32, ...; default: native)
As a proof of concept, over the course of 3 weeks and 2800 lines of code, I implemented mycc: a compiler for a subset of the C language (roughly close to the C99 standard) built on top of myc. Mycc already successfully compiles and runs LangArena — a benchmark suite consisting of 50 tests and 9,000 lines of non-trivial C code (json, base64, multithreaded matmul, neural net, compression, maze A*, bf interpreter, and others) with heavy macros like uthash. For the parser, I used libclang — it's an overhead, but it's the simplest solution for a POC.
C source -> SyntaxTree(libclang/clang.cr) -> TypedAST(mycc) -> IR(myc) -> [LLVM/QBE/C] -> binary
The most challenging part was SyntaxTree -> TypedAST. The file ast_builder.cr contains 1700 lines of code. libclang returns a non-normalized AST with many edge cases that need to be transformed into a consistent form. Additionally, C has a ton of implicit behavior: implicit type conversions, array decay, and others. Only the edge cases necessary for compiling LangArena are implemented here; there's likely still a lot uncovered.
The TypedAST(mycc) -> IR(myc) stage — as expected, is one of the simplest (codegen.cr, 700 lines) — a single-pass generation of stack-based IR directly from the AST.
clang: C -> Parse(libclang) -> Clang CodeGen -> LLVM -> binary
mycc: C -> Parse(libclang) -> mycc CodeGen -> IR(myc) -> [LLVM/QBE/C] -> binary
Rare features are not implemented: 2D VLA, complex numbers, longjmp, bitfields, asm, volatile, and anonymous nested structs. I wouldn't try building Linux or sqlite with it. It has only been tested on arm64 and linux64.
Compares Clang, Gcc, Cproc(QBE), and Mycc on LangArena benchmark.
The ./c directory of LangArena contains 29 .c files (230KB total). All compilers build these files sequentially without using cache or threads. Link time is not counted — it's roughly 50ms for all, and precompiled dependencies were also used. The results show:
Build time— total build time for all 29 filesBuild rss— average RSS during compilation per fileBench Runtime— benchmark execution time
This is not just a random benchmark - each of the 50 LangArena tests validates its output using checksums. A compiler cannot "cheat" by deleting or skip work. This benchmark is also a part of CI.
To run it: cd benchmark; ruby run_lang_arena.rb, needs to add cproc,qbe,clang,gcc to PATH, compile mycc and install uthash and pcre2 (sudo apt install uthash-dev libpcre2-dev).
Results for linux64, gcc 13.3, clang 20.1.
| Compiler | Build time | Build rss | Bench Runtime |
|---|---|---|---|
| clang(-O3) | 3079ms | 105Mb | 52.1s |
| gcc(-O3) | 3495ms | 34Mb | 52.3s |
| cproc | 932ms | 12Mb | 72.7s |
| mycc(llvm, --release) | 4269ms | 101Mb | 53.2s |
| mycc(qbe, --release) | 2939ms | 86Mb | 72.8s |
| mycc(c, --release, clang) | 5091ms | 102Mb | 52.1s |
| mycc(c, --release, gcc) | 5128ms | 86Mb | 53.7s |
Currently, mycc is slower at compile time in the benchmarks. Run time is close. This is due to several suboptimal stages:
- libclang adds parsing overhead of about 10ms per file.
- IR is generated as text and then parsed again — this adds another ~10ms of overhead per file (also, because of this, error locations are not yet available).
- There are no self optimization passes yet — and probably never will be :)
- The code generation is primitive, with a lot of redundant load/store operations.
- Haven't managed to beat clang or gcc yet — but coming for you.
Requires LLVM/libclang >= 20.
# sudo apt install llvm-20 libclang-20-dev
shards install
crystal build src/cli/mycc.cr --release -o ./mycc# compile file
./mycc c --release examples/mycc/sieve.c
# compile file with --backend option
./mycc c --release --backend llvm examples/mycc/sieve.c
./mycc c --release --backend qbe examples/mycc/sieve.c
./mycc c --release --backend c examples/mycc/sieve.c
# show optimized llvm ir dump
./mycc examples/mycc/sieve.c d | ./myc-llvm d --release
# show qbe dump
./mycc examples/mycc/sieve.c d | ./myc-qbe d
# include paths example
MYCC_INCLUDE='/opt/homebrew/include,/usr/local/include' ./mycc examples/mycc/sieve.c c
# Build object file for custom linking
./mycc o --release examples/mycc/sieve.c sieve.o
clang sieve.o -lm -o ./sieve
Licensed under the Apache License, Version 2.0.