diff options
Diffstat (limited to 'docs/history/07-zygote/code/README.md')
| -rw-r--r-- | docs/history/07-zygote/code/README.md | 168 |
1 files changed, 0 insertions, 168 deletions
diff --git a/docs/history/07-zygote/code/README.md b/docs/history/07-zygote/code/README.md deleted file mode 100644 index 924c48b..0000000 --- a/docs/history/07-zygote/code/README.md +++ /dev/null @@ -1,168 +0,0 @@ -# tinyvm zygote: notes and transcript - -a tiny stack cpu that started as a c89 code golf entry and ended up as a self-hosting runtime. a small native core jit-compiles vm code to arm64, and the assembler lives in vm memory next to the program, where the program can modify it. - -this folder only has the latest code. earlier stages are described in the transcript below, but their code isn't included. - -## files - -`core.c` is the native core. it holds the arm64 jit, the stubs and the invalidation logic, and is the only native code in the system. `gen.s` is the genome header and boot loader, in vm assembly. `aslib.s` is the assembler, written in vm assembly. `mkgenome.c` is a build tool that assembles `gen.s` + `aslib.s` at address 48000 into `genome.h`, which is the generated result and is checked in. `repl.s` is a demo that mutates the resident assembler and repeatedly assembles and runs input chunks. `io_linux.c` is the `Q`/`G`/`J` test harness. `test.sh` builds for arm64 linux and runs everything under qemu. `examples/` has hello, a recursive factorial (prints in binary), echo, a self-patching test, and `asmain.s`, the standalone wrapper that turns `aslib.s` into a stdin-to-stdout assembler. - -## build - -``` -cc -o mkgenome mkgenome.c && cat gen.s aslib.s | ./mkgenome >genome.h # only after changing gen.s or aslib.s -cc -O2 core.c io.c -o zy # io.c provides char Q(void), void G(char), and J -./zy <examples/hello.s -``` - -`J` is a `char` by default. compile with `-DJTYPE=int` if yours is an int. `ZYTRACE=1` logs every translation and invalidation on stderr. the program source ends at eof or at a backtick. anything after the backtick stays unread, so the program can read it as its own input. - -## the instruction set - -every word is 32 bits and memory is 65536 words. an odd word is a literal that pushes `w>>1` (31 bits, sign extended). an even word is an opcode, `w/2`. there's a data stack and a return stack, both stored inside memory. the data stack grows down from 65000 and the return stack grows up from 60000. - -``` -0 q halt 5 ~ swap 10 , in (Q) 15 * mul 20 < less -1 @ load 6 c call 11 ? empty (J) 16 & and -2 ! store 7 j jump 12 . out (G) 17 | or -3 $ pick 8 z jz 13 + add 18 ^ xor -4 _ drop 9 r ret 14 - sub 19 > shr -``` - -stack effects: `!` is `(v addr --)`, `$` is `(n -- copy of the nth item)` so `0 $` is dup, `z` is `(cond addr --)` and jumps when cond is 0, and `c` and `j` take their target from the stack. there's no div or mod, because a divider can't finish in one cycle in hardware. division is done in software. - -assembler syntax uses whitespace-separated tokens. `:name` defines a label, a single op character is that opcode, `"text` emits raw characters, `#x` emits a raw number or label address, and anything else becomes a literal push of a number or label. when a label is defined twice, the last definition wins. a label nobody references emits nothing, so `:;like_this` works as a comment. - -## memory map and abi - -``` -0.. the program (assembled at origin 0 by the boot loader) -20000..44999 boot source buffer, one char per word (free after boot) -45000..47999 boot label table (free after boot) -48000 genome: 48000 boot, 48002 a.run (call with 48002 c), - 48004 &a.src 48005 &a.dst 48006 &a.org 48007 &a.lab 48008 &a.n 48009 &a.ops -60000 up return stack -65000 down data stack -``` - -to use the resident assembler, store a source address into `48004 @`, the destination into `48005 @`, the run-at origin into `48006 @` and a label scratch area into `48007 @`, then do `48002 c`. the word count ends up in `48008 @`. `48009 @` points to the mnemonic table `q@!$_~cjzr,?.+-*&|^><`, which you can rewrite. boot limits: the program has to fit in 20000 words and the source in 25000 chars. - -## how the core works - -registers: x19 holds the memory base, x20 the data stack pointer, x21 the return stack pointer, x22 the translation table and x23 the state block (memory, s, r, table, coverage map, Q, G, &J, store hook). - -compilation is lazy. the first time control reaches an address, that block is translated from live memory. a block ends at call, jump, ret or halt. a `z` doesn't end it: the not-taken side keeps compiling, which makes superblocks. a literal followed by `c`, `j` or `z` is fused into a constant target. every exit goes through a 5-instruction dispatch stub that bounds-checks the target, loads `tab[t]` and branches to it directly when it's already compiled. otherwise it returns to c, which compiles the block and re-enters. - -self-modification is handled with a byte map of which words some live translation covers. a store checks that byte inline: an uncovered target, the usual case, gets a plain store. a covered target calls the hook, which drops every translation containing that word and makes the jit code return to c at the next instruction, so even patches to the very next word take effect. writing back the same value is a no-op. if the 16 mb code buffer fills, everything is flushed. - -platform: arm64 only. the darwin path uses `MAP_JIT`, `pthread_jit_write_protect_np` and `sys_icache_invalidate`. linux uses rwx mmap and `__builtin___clear_cache`. only the linux arm64 path has actually been run, under qemu. the darwin path follows apple's documented recipe but hasn't been tested. under the hardened runtime it also needs the allow-jit entitlement. - -differences from the golfed reference vm, which leaves all of these undefined: shift counts wrap at 32, stores outside memory are ignored, and jumping outside memory halts. - -## test results (linux arm64 via qemu) - -everything in `test.sh` passes: hello, factorial, echo after the backtick, self-patching that matches the golfed vm byte for byte, the self-hosted assembler running on the zygote and producing the same bytes as the c assembler, and the repl that prints `hi` and then `yo` from two chunks assembled into the same address. the trace shows the first chunk's translation being dropped. earlier in the session, `asm.s` running on the zygote reproduced the c assembler's output for its own source too. a 20 million iteration loop took 0.54 s on the zygote against 2.18 s for the golfed vm interpreter under the same qemu, and that 0.54 includes assembling the source at startup. - -## open ends - -- the llvm ir port was asked for and then dropped in favor of the zygote before any of it was written. -- the assembler library uses globals, so it isn't re-entrant. code that it assembles at runtime shouldn't call it while an outer `a.run` is still running. -- translations are only reclaimed by a full flush, and the hook rescans the block list on every store to a covered word. that's fine unless code rewrites itself in a hot loop. -- none of the later stages are golfed. the golf-sized code is the reference vm and assembler from stage 2. - ---- - -## transcript - -each of my messages is quoted exactly, followed by a summary of the reply. - -### 1 - -> codegolf a tiny cpu vm in C89. the goal is to achieve the lowest character count possible. include an assembler as well (scored separately) and a hello world program (not scored, but elegance and simplicity is a bonus) -> it must be capable of re-entrant code and a realistically workable instruction set. - -**summary:** a two-stack forth-style machine. the vm was 384 bytes: one ternary chain inside `while(o=m[p++])`, the top two stack items cached in `a` and `b`, the program loaded with `read(open(...))` so it needs no headers, and both stacks inside the 65536-word memory. it had 22 opcodes including div and mod, a two-word literal, and jump/call targets popped from the stack. the assembler was 348 bytes and two-pass, using `strcspn` instead of `strchr` so the implicit int return stays safe on 64-bit. hello world was a pointer loop over a string with a zero terminator. re-entrancy was shown with a recursive factorial plus a recursive decimal printer. - -### 2 - -> your cpu should also be realistically close to hardware (translating it to logical circuitry hardware should be fairly easy) and sport an acceptably fast execution (if hypothetically ported to hardware, not in software) -> -> assume i/o is handled by two external functions, `char Q()` (getchar) and `void G(char)` (putchar) and a state variable J (boolean, shows if input is empty). -> -> don't be afraid to use macros. - -**summary:** no reply was sent to this message at the time. it was answered together with message 3. - -### 3 - -> pro tip: if you define a macro as `!o--`, you can save 2 characters per if condition -> oh and btw, newlines are free - -**summary:** used the tip as `#define X !--o?`, with pre-decrement because opcode 0 (halt) is already handled by the loop, and with the `?` folded into the macro and free newlines used as separators. the hardware-oriented redesign: bit 0 of each word is a literal flag, so literals take one cycle like the j1 cpu. div and mod were dropped because they can't finish in one cycle and were replaced by an arithmetic shift right, giving 8 alu ops that decode from 3 bits. every instruction does a fixed amount of work with at most one data memory access, and call and jump share a datapath (`r-=o`). i/o now goes through `Q`, `G` and a `?` op that reads `J`. the result was the vm at 339 bytes and the assembler at 346. the final golfed code: - -```c -#define X !--o? -int m[1<<16],p,s=65e3,r=6e4,o,a,b;main(c,v)char**v;{read(open(v[1],0),m,s);while(o=m[p++])a=m[s],b=m[s+1],o&1?m[--s]=o>>1:(o/=2,X -m[s]=m[a]:X -m[a]=b,s+=2:X -m[s]=m[s-~a]:X -s++:X -m[s]=b,m[s+1]=a:(o-=2)<1?m[r]=p,r-=o,p=a,s++:X -p=b?p:a,s+=2:X -p=m[--r]:X -m[--s]=Q():X -m[--s]=J:X -G(a),s++:(m[++s]=X -b+a:X -b-a:X -b*a:X -b&a:X -b|a:X -b^a:X -b>>a:b<a));} -``` - -```c -char t[9999][99],*o="q@!$_~cjzr,?.+-*&|^><",*q;A[9999],k,i,j,n,P;E(y){n++;write(P,&y,4);}V(char*s){for(j=k;j--&&(*t[j]-58||strcmp(t[j]+1,s)););return~j?A[j]:atoi(s);}main(){while(~scanf("%s",t[k]))k++;for(;P<2;P++)for(n=i=0;i<k;i++)if(*(q=t[i])==34)while(*++q)E(*q);else*q==58?A[i]=n:!q[1]&&o[j=strcspn(o,q)]?E(j*2):*q==35?E(V(q+1)):E(V(q)*2+1);} -``` - -### 4 - -> now let's move in stages. write a compiler that takes this assembler's code and converts it into darwin arm - -**summary:** an ungolfed c89 compiler from vm assembly to darwin arm64 assembly. every word got its own label, the image was emitted as an addressable data array, x19/x20/x21 held the memory base and the two stack pointers, and computed jumps went through a table of offsets. a literal followed by a jump became a direct branch. `Q` and `G` went through `sxtb`, since char is signed on apple arm64, and `J` was read with `ldrb`. it was verified by assembling with llvm-mc for darwin, then rewriting to linux with sed and running under qemu, where it matched the vm byte for byte. this version couldn't handle self-modifying code. - -### 5 - -> can you write the assembler in assembly and pair the code with the assembly and the assembler so that it can re-wire itself at runtime? or maybe include an interp for self-modification, otherwise fns are exec'd native - -**summary:** both. `aslib.s` is the assembler written in vm assembly as a library (`a.src`, `a.dst`, `a.org`, `a.lab`, then `a.run c`), and with `asmain.s` it's a 568-word standalone assembler. it's byte-identical to the c assembler and reaches a fixpoint when assembling itself. this is also where the `:;comment` trick came from. the compiler became a hybrid. the image is split into basic blocks, each block counts how many of its words differ from the original image, and stores into code go through a slow path that updates the counters. clean blocks run native, while dirty blocks and anything past the image run in an interpreter compiled into the same binary, which hands control back to native code whenever it lands in a clean block. `cc -t` printed `[` and `]` at each switch. the demo rewired a function at runtime to point at freshly assembled code and then restored it. - -### 6 - -> which one do you like more? and which is more performant? -> keep that one, then port to using llvm ir so that it can be platform independent - -**summary:** no reply was sent at the time. the llvm port was dropped when message 7 changed direction. the question was answered at the start of the reply to message 7: the hybrid was the favorite because it kept the vm's semantics exact. the plain compiler was slightly faster on code that never rewrites itself, but it silently ran stale code once anything did. the zygote beats both. - -### 7 - -> can you make it a zygote approach? -> a minimal core loads first, reads and compiles the program (either wholly or partially, for speed) and replaces it with itself. so it's like a jvm but self-contained and on the same level as itself, where the assembler can itself be modified in-memory to assemble different code. for this we need to ditch llvm - -**summary:** the current design, described above. the native core only does jit and glue. it loads the genome (boot loader + assembler, both vm code) at 48000. the boot loader reads source until eof or a backtick, assembles it at 0 and jumps there. compilation is lazy per block, straight from live memory, with superblocks through `z`, fused constant targets, and dispatch chained through a table. invalidation uses the coverage byte map plus a store hook, so self-modified code gets retranslated instead of interpreted. the resident assembler sits at fixed addresses, and `repl.s` rewrites its mnemonic table so `p` means out, then assembles and runs input chunks in a loop, overwriting the same address each time. verified under qemu on linux arm64, including the self-hosted assembler running on the zygote. about 4x faster than the golfed interpreter. - -### 8 - -> damn dawg you made an entire baremetal os? -> -> let's move to cowork. pack everything up alongside a transcript of every message i've sent and your own notes on what you've implemented in an md file, with the code, so i can move it. - -**summary:** not quite an os. it's a userland runtime. it still runs on darwin or linux, uses their mmap for executable memory, and gets i/o from the host through `Q`, `G` and `J`. there's no scheduler, no drivers and no hardware access, so a closer description is a tiny self-hosting jit, in the spirit of a jvm. the first packing attempt bundled every stage's code with a full test script. - -### 9 - -> nono we don't need all the code, just the recent ones. my msgs and your summaries are more important and all should be there - -**summary:** this package. only the zygote stack plus its examples and tests are included, and earlier stages appear only as summaries. the golfed reference vm and assembler are inlined under message 3, because they define the instruction set. |
