MEP: A Mapped CPU
Every bold thesis needs a serious contraption to back it up, and I am a logic designer.
Mapping, the act of data transformation through pattern matching, is a powerful and versatile pattern that every developer adores. It isn’t just a local clean-code nicety, it empowers every serious compiler and transformation engine. One of my biggest thesis was: how far can it scale? Can it express an entire universe?
To prove that claim, I created UniMap, a practical esolang where mapping and matching are its very identity. UniMap doesn’t feature any numerical processor fluff. It has no construct for numbers, math, or typical control flow. It only features data structure mapping, equipped with pipes and function composition.
UniMap was just the medium. It needed a serious creation to prove its practicality, and a CPU was the natural choice, since a CPU is vast, complex, and built on data transformation at its core. It also happens to be my expertise as a self-taught logic designer.
That CPU is the CPU of today, also known as MEP (Mapped Expressions Processor), an advanced RISC 64-bit CPU with surprising elegance in its internals, relative to no math environment. Before diving into the CPU, let’s talk about its precursor.
Typed Encoded Precursor
Section titled “Typed Encoded Precursor”It all started when I was tinkering with TypeScript’s type system, driven by curiosity and boredom. What began as simple HTML and JSON parsers quickly escalated into a full math library with advanced bit manipulation operations. The ergonomics of TypeScript’s type system turned an esoteric experiment into a great puzzle, and with a working math library already lying around, building a CPU on top of it was the obvious next step.
TEEP, the Type Encoded Expressions Processor, is an advanced 64-bit RISC CPU with a feature-rich instruction set implemented entirely as a TypeScript type. If you read the reference, you’ll notice its blocky instruction format despite its advanced operations. This comes from it being a math function dispatcher built on top of digit-oriented jump tables.
After building UniMap, I decided to stress test it with a sophisticated contraption, so MEP was created as a successor to TEEP, owing to the similarity of their host languages. If you look inside, you’ll find MEP nearly identical to TEEP in features and internals, with MEP being more refined and feeling like a real ISA, built with less chaotic syntax.
To reach the depths, you must begin in the shallows, and these shallows are just as interesting as the depths.
ISA Overview
Section titled “ISA Overview”MEP is RISC in spirit, not in the tradition of minimalism. It features the usual RISC conventions: 32 general purpose registers with a zero register, fixed 32-bit instructions, and three-operand instructions. Heavily inspired by ARM64, it provides the modern, powerful general instructions loved by compilers, with aggressive instruction fusion.
Starting with the arithmetic side, MEP features the common math operations and enriches them with compounds and variants.
// `add` and `sub` takes a shifted registeradd r3, r1, r2 shl 3 // r3 = &(r1:arr<u64>)[r2]
// triple `add`, fused `madd` and `msub` for math equations acceleration// r5 = r1 + r2 + r3 - r4 * r1 in 2 instructions instead of 4add r5, r1, r2, r3msub r5, r4, r1, r5
// full 128 bit product `mult`mult.full r3, r0, r1, r2 // (low: r3, high: r0) = r1 * r2, optimizable to `multl` by decoder if high is unusedmult r3, r1, r2 // the assembler is rich in pseudo instructions
// `div` and `udiv` with quotient and remainderdiv r3, r4, r1, r2 // (quo: r3, rem: r4) = r1 / r2, no double divide required
// `abs` and `min`/`max` gets an integer variant that most isa forget// r3 = min(abs(r1), r2), forget compare-and-branchabs r1, r1min r3, r1, r2The logical side also gets improvements in orthogonality and expressive second operands.
// all logical operations take shifted registerxor r3, r1, r1 shl 1 // edge detection
// full 2 input bitwise operations orthogonalitybcl r3, r1, r2 // r3 = r1 & !r2, r3[b] = if r2[b] then 0 else r1[b]imply r3, r1, r2 // r3 = !r1 | r2, r3[b] = if r1[b] then r2[b] else 1
// arm64 inspired logical immediateand r2, r1, logic_imd(16, 0) // r2 = r1 & (ones_from_low(16) rol 0), isolate low 16 bits
// test if `op` of the bits masked is `1`test.any r2, r1, logic_imd(32, 32) // r2 = r1[32..64] != 0, check u32 overflow// bitfield { a: b3, b: b2, c: b4, /* .. */ }test.all r3, r2, logic_imd(4, 5) // r3 = r2[5..9] == 0xf, r3 = r2.c == 0xfMoreover, MEP provides versatile bit manipulation operations that are present in every modern ISA.
// acceleration for bit counting and reversioning in all their variants.cnt r2, r1 // r2 = count_ones(r1), checksumsclz r2, r1 // r2 = count_leading_zeros(r1), bit lengthrev.8 r2, r1 // r2 = reverse_bytes(r1), endian conversion
// bitfields operations for optimized packing// bitfield { a: b3, b: b2, c: b4, /* .. */ }bfins r3, r1, 3, 2 // r3[3..5] = r1, r3.b = r1bfext r2, r3, 5, 4 // r2 = r3[5..9], r2 = r3.c
// funnel shift the primitive of all shiftfush r4, r1, r2, r3 // left shift r1 by r3 taking carry from r2fush r3, r1, r1, r2 // r3 = rol(r1, r2)A modern ISA needs powerful data movement instructions, and MEP has them.
// move has many variants, most are pseudo instructionsmov r1, 0x1234, 1 // r1 = 0x1234 shl 16mov.keep r1, 0x4567, 2 // r1[32..48] = 0x4567mov r1, logic_imd(32, 15, 16) // r1 = r0 | (repeat(ones(15), 64 / 32) rol 16)
// store and load has various addressing modesld r1, [-0x124] // r1 = (mem:arr<u8>)[(pc - 0x124)..(pc - 0x11c)]ld.s16 r1, [r2] // r1 = signext16(mem[r2..(r2 + 2)])st.32 r1, [r2 + r3 shl 4] // addr = r2 + r3 shl 4; mem[addr..(addr + 4)] = r1st r1, [r2 += 0x16] // addr = r2 + 0x16; mem[addr..(addr + 8)] = r1; r2 = addrMEP, like many RISC ISAs, stores conditions inside general purpose registers, and it enhances conditional execution with helpful compounds.
// `c0` replace `r0` and is used for holding conditions without polluting a registercomp.gt c0, r1, r0 // test r1 strictly positive
// condition generation support multiple operation other than overwritecomp.eq.or c0, r1, r0 // c0 = c0 | (r1 == 0)test.none.and c0, r1, logic_imd(1, 63) // c0 = c0 & (r1[63] == 0)
// conditional select to kill all short branchessel r2, c0, r1, 123 // r2 = if c0 { r1 } else { 123 }cinc r2, r1, r0 // r2 = if r1 { 1 } else { 0 }cnot r2, r1, r0 // r2 = if r1 { 0xffff_ffff_fff_fffff } else { 0 }
// table branch for optimized jump tables// match r1 { 0 => { /* 4 instructions */ }, ... }br.table r2, r1 shl 4 // r2 = pc; pc = pc + r1 shl 4You might expect this instruction set’s complexity to demand sophisticated internals, especially with pure pattern matching and no math constructs. Surprisingly, its internals are relatively elegant and direct, just a field-based dispatcher with textbook math bootstrapping.
Core Primitives
Section titled “Core Primitives”UniMap QuickView
Section titled “UniMap QuickView”Here is a quick overview of UniMap. A bigger tour can be found here, and the language reference lives here.
// everything is a symbol, a globally unique primitive, numbers are predefined symbolssymbol a, b, c;
// you can create data structures and index themlet arr = [1, 2, c];let obj = { a = 1, 3 = b, [arr[2]] = { b = [..arr, b] } }; // => { a = 1, 3 = b, c = { b = [1, 2, c, b] } }
// map expression, the heart of unimaplet mapped = obj.a: { // if a pattern is matched, the arm expression is evaluated a => 1, 1 => a, // match anything _ => 2, // structural matching with destructuring support { a: [1, 2, ..let arr], let b } => arr[b]}; // => a
// you also have pipe operatorlet piped = 1 |> [_, 2, 3] |> { [_[1]] = _ } |> dbg(_) |> _: { { 2: [1, ..let arr] } => arr[1], { 2: [2, ..let arr] } => arr[2],}; //> print { 2: [1, 2, 3] } then evaluated to 2
// you also have function, with recursion and compositionfn filter(arr, filtered) => arr: { [] => [], [filtered, ..let rest] => filter(rest, filtered), [let x, ..let rest] => [x, ..filter(rest, filtered)],};let filtered = filter([1, 2, 3], 2); // => [1, 3]
// and that is what you need to build your universeNumber Representation
Section titled “Number Representation”Reading through the source, you’ll notice some premature optimization, especially loop unrolling and bulk maps. This is because I’m an engineer with a natural instinct for optimization. Full purity is elegant and concise but very inefficient, and a slight bit of verbosity drastically improves performance. Even though this is an esoteric experiment, being efficient enough is good.
This mentality greatly affected the number representation, since this representation dictates the balance between ergonomics and performance in this project. The obvious choice would be a digit array in binary base, since that’s the universal choice in computers and binary is the easiest base for implementing math.
Though binary is amazingly inefficient in this situation. Taking the pure approach, a primitive operation would take 64 iterations of a list-flattening recursive loop, requiring 4032 item moves just from recursive intermediaries wasted, a downside that engineering discipline doesn’t accept.
The solution was widening the base using a 2 ^ 2 ^ n approach. This preserves the logarithmic property and uniform divisions while balancing word width against map size. At n = 1 (base quaternary), the map size is a small 4 but the word width is a large 32. At n = 3 (base 256), the word width is a small 8 but the map size is a huge 256. The best option is n = 2 (base hexadecimal), where map size and word width are both small and equal, at 16.
Efficient Primitives
Section titled “Efficient Primitives”After the quickview, you already know how everything is done: composed functions of piped maps, and a constant stream of data transformation. Still, the optimization instinct greatly shaped the building blocks of MEP’s implementation, and the first sign of this is the jump-map optimization.
When the UniMap runtime encounters a map composed only of symbol patterns, it transforms it from iterative checking into a jump table powered by a hashmap, establishing O(1) match time and incredible performance. This is why the majority of maps here are digit-oriented.
let optimized = 1: { 1 => 2, 3 => 3, 3 | 5 | 6 => 2, _ => obj.a,};
// these patterns are difficult to reduce to simple keyslet unoptimized = 1: { [1, 2, 3] => 3, 1 | 2 | [] => obj, let a => a,};
// the best optimization is precomputing// mostly one level and computes 4 bits at a timefn not_dg (a) => a: { 0 => 15, 1 => 14, 2 => 13, 3 => 12, 4 => 11, 5 => 10, 6 => 9, 7 => 8, 8 => 7, 9 => 6, 10 => 5, 11 => 4, 12 => 3, 13 => 2, 14 => 1, 15 => 0,};
// the few big ones (2 level, +256 case) are dumped into ./math/tables.unimAfter Having maps optimized to constant time, loops deserve the same treatment. Recursion, while essential for reduction loops (where I still use it), is wasteful for mapping ones. It requires 240 intermediary moves for 16 iterations, giving O(n^2) complexity. Since there’s no native construct for loops, the next optimization level is loop unrolling.
// 2 rest, each moving n - 1 items recursively, waste = 2 * (15 + 14 ... 1) = 240fn not_recursive (n) => n: { [] => [], [let v, ..let rest] => [not_dg(v), ..not_recursive(rest)],};
// larger but more efficientfn not_unrolled(n) => [ not_dg(n[0 ]), not_dg(n[1 ]), not_dg(n[2 ]), not_dg(n[3 ]), not_dg(n[4 ]), not_dg(n[5 ]), not_dg(n[6 ]), not_dg(n[7 ]), not_dg(n[8 ]), not_dg(n[9 ]), not_dg(n[10]), not_dg(n[11]), not_dg(n[12]), not_dg(n[13]), not_dg(n[14]), not_dg(n[15]),];With the foundation layer in place, the next step is building arithmetic from nothing but unrolled piped maps.
Mathing From Principles
Section titled “Mathing From Principles”Math-Inspired Elegance
Section titled “Math-Inspired Elegance”Throughout this article I’ve mentioned elegance a lot. The elegance I mean isn’t the slimness and cleanliness of one-liners, it’s the elegance of math operations in their composability and simplicity, implemented in a direct way. Math operations aren’t elegant because their symbols are beautiful and brief, they’re elegant because they’re universal and easy to express, even if that requires a ton of boilerplate.
Yes, the math implementation in UniMap is verbose and repetitive at the nano level, but it looks, feels, and works like C and assembly: verbose but not complex, and composable like regular functions, with no esoteric madness or forbidden hacks. Also, what lies beneath the ergonomics of math on your computer is a universe of repetitive, hardcore wiring arranged in modular blocks.
If you trace MEP’s math implementation from primitives to advanced operations, you’ll notice it consists of three levels: nano, grounded in raw precomputed lookup maps, micro, built on loop unrolling and handwired data movement, and macro, where operations are synthesized through function composition, with maps and pipes tying them together and brevity increasing at each layer.
That’s how you build universes, in UniMap and in general: sheer engineering, compounding its work through composition. The best example of this is the logical operations, which span all three layers.
// nano layer, dumped inside ./math/tables.unimfn and_dg (a, b) => a: { 0 => b: { 0 => 0, 1 => 0, /* .. */ 15 => 0 }, /* ... */ 15 => b: { 0 => 0, 1 => 1, /* .. */ 15 => 15 },};
// micro layerfn and (a, b) => [ and_dg(a[0 ], b[0 ]), and_dg(a[1 ], b[1 ]), and_dg(a[2 ], b[2 ]), and_dg(a[3 ], b[3 ]), and_dg(a[4 ], b[4 ]), and_dg(a[5 ], b[5 ]), and_dg(a[6 ], b[6 ]), and_dg(a[7 ], b[7 ]), and_dg(a[8 ], b[8 ]), and_dg(a[9 ], b[9 ]), and_dg(a[10], b[10]), and_dg(a[11], b[11]), and_dg(a[12], b[12]), and_dg(a[13], b[13]), and_dg(a[14], b[14]), and_dg(a[15], b[15]),];
// the other primary operations (not, or and xor) are the same.// the macro layer: the secondary operation derived from the primary onesfn nand (a, b) => not(and(a, b));fn nor (a, b) => not(or (a, b));fn xnor (a, b) => not(xor(a, b));fn bcr (a, b) => and(a, not(b));fn imply (a, b) => or (not(a), b);Arithmetic’s Efficient Explosion
Section titled “Arithmetic’s Efficient Explosion”Arithmetic is simple in principle, just accumulation and serial emission, where each operator calculates digit by digit and carries the overflow to the next digit. That accumulation isn’t difficult in recursive loops, it’s just another parameter, but in unrolled loops with an optimization instinct, it becomes a different story.
Carry propagation complicates the macro layer by enlarging it syntactically. There are no statement variables, and pipe intermediaries vanish after one chain part, which requires using map towers for accumulation folding.
// no change in nano size, just more iosfn add_dg (a, b, c) => a: { 0 => b: { 0 => c: { 0 => [0, 0], 1 => [1, 0], }, // ... 15 => c: { 0 => [15, 0], 1 => [0, 1], }, }, // ....};
// larger syntactic footprint at micro, still repeated units and efficient with no wasteful movesfn add_op (a, b, c) => add_dg(a[0], b[0], c): { // use maps as pipes with longer scope intermediaries [let s0, let c] => add_dg(a[ 1], b[ 1], c): { [let s1, let c] => add_dg(a[ 2], b[ 2], c): { // ... [let s14, let c] => add_dg(a[15], b[15], c): { [let s15, let c] => [[s0, s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12, s13, s14, s15], c]}}}}}}}}}}}}}}}};
// same macro conciseness// classic twos complement trickfn sub (a, b) => add_op(a, not(b), 1)[0];Multiplication is a series of additions, which sounds harmless until you remember that addition has a micro layer, and multiplication needs one too. Fortunately, multiplication doesn’t need a nano layer, since binary multiplication is just a series of test-add-shift steps. Even in a hexadecimal digit representation, the underlying operation is still binary.
// test, add then shift for 4 bits in the dgfn mult_dg (a, b, acc) => [..acc, 0] // u68 pad as acc is u64 |> bit_dg(b, 0): { 0 => _, 1 => add68(_, a) } |> bit_dg(b, 1): { 0 => _, 1 => add68(_, shift68_l1(a, 1)) } |> bit_dg(b, 2): { 0 => _, 1 => add68(_, shift68_l1(a, 2)) } |> bit_dg(b, 3): { 0 => _, 1 => add68(_, shift68_l1(a, 3)) };
// like addition, map tower for accumulation foldingfn mult (a, b) => [..a, 0] |> mult_dg(_, b[0], zero): { // the rest pattern implicitly shift the product and a by a dg // a 16 item copy is better than 128 bit addition [let p0, ..let p_rest] => mult_dg(_, b[1], p_rest): { [let p1, ..let p_rest] => mult_dg(_, b[2], p_rest): { // ... [let p14, ..let p_rest] => mult_dg(_, b[15], p_rest): { [let p15, ..let p_high] => [ [p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, p15], p_high ],}}}}}}}}}}}}}}}};Division has similar architectural and time complexity to multiplication. Binary division is also a shift-sub-test cycle over 64 iterations, though it doesn’t require map towers, since the carry and result are fused into the same accumulator by the algorithm. That makes it appear simpler.
// 1 bit restoring divide cyclefn udiv1 (div, acc) => shift_left128_by1(acc): { [let quo, let acc] => sub(acc, div) |> sign(_): { pos => [or(quo, one), _], neg => [quo, acc] }};
// 64 cycles split over 3 level for better readabilityfn udiv4 (div, acc) => udiv1(div, acc) |> udiv1(div, _) |> udiv1(div, _) |> udiv1(div, _);fn udiv16 (div, acc) => udiv4(div, acc) |> udiv4(div, _) |> udiv4(div, _) |> udiv4(div, _);fn udiv64 (a, b) => udiv16(b, [a, zero]) |> udiv16(b, _) |> udiv16(b, _) |> udiv16(b, _);
// unsigned divisionfn udiv (a, b) => b: { zero => [zero, a], _ => udiv64(a, b)};
// signed divisionfn div (a, b) => b: { zero => [zero, a], // strip sign then divide, then sign accordingly _ => udiv64(abs(a), abs(b)): { [let quo, let rem] => [ eq(sign(a), sign(b)): { 1 => quo, 0 => negate(quo) }, sign(a): { pos => rem, neg => negate(rem) } ] }};The arithmetic explosion has officially ended. No more complex operations require map towers or a second micro layer. What remains is logic operations at nano-layer conciseness and simple compounds at the macro layer, just like how sign processing is implemented.
symbol pos, neg;fn sign (nb) => b3(nb[15]): { 0 => pos, 1 => neg };// map can match by a local equalityfn eq (a, b) => a: { b => 1, _ => 0 };
// 0 if a == b, 1 if a > b, 2 if a < b// using a - b trickfn comp (a, b) => sub(a, b): { zero => 0, let diff => eq(sign(a), sign(b)): { 1 => sign(diff): { pos => 1, neg => 2 }, // different sign overflows difference, compare though sign 0 => sign(a): { pos => 1, neg => 2 } }};/// unsigned compare use the same trick, just different mechanism since they are no signsfn ucomp (a, b) => sub_borrow(a, b, 0): { [zero, _] => 0, [_, 0] => 1, // no borrow, a - b > 0 _ => 2,};
fn min (a, b) => comp(a, b): { 1 => b, _ => a };fn max (a, b) => comp(a, b): { 1 => a, _ => b };
// sign extend, replicate sign bitfn sign_ext8 (nb) => b3(nb[1]): { 0 => nb, _ => [nb[0], nb[1], 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15],};Streamlined Bit Manipulation
Section titled “Streamlined Bit Manipulation”Bit manipulation is more syntactically streamlined than arithmetic, since it’s mostly handwired mapping and reduction without per-digit accumulation. That doesn’t make it any less algorithmically diverse or interesting though, and the best example is the shift operations.
The shift operation is the last huge lookup-table primitive and my favorite one. It resembles real silicon more than any other operation, in its vastness, design, and variant derivation. A shift is the simplest bit-movement operation, a uniform, linear movement of the whole value, but it requires huge circuitry for 64 distinct wirings. That complexity can be simplified through a funnel shifter, shifting in multiple levels.
// huge but streamlined and efficient// left shift a digit by 0..3 bits with custom carry/fillerfn shift_dg (am, n, f) => n: { 0 => f: { 0 => am: { 0 => 0, 1 => 0, 2 => 0, 3 => 0 }, // .. 15 => am: { 0 => 0, 1 => 1, 2 => 3, 3 => 7 }, }, // ...}
// 4 bit funnel shift of whole nbfn shift_l1 (nb, fl, am) => [ // each digit has the prev as its filler shift_dg(am, nb[0 ], fl ), shift_dg(am, nb[1 ], nb[0 ]), shift_dg(am, nb[2 ], nb[1 ]), shift_dg(am, nb[3 ], nb[2 ]), // ... shift_dg(am, nb[14], nb[13]), shift_dg(am, nb[15], nb[14])];
// whole digit funnel shiftfn shift_l2 (nb, fl, am) => [nb, fl]: { // extract individual digits [[ let n0, let n1, let n2, let n3, let n4, let n5, let n6, let n7, let n8, let n9, let n10, let n11, let n12, let n13, let n14, let n15, ], [ let f0, let f1, let f2, let f3, let f4, let f5, let f6, let f7, let f8, let f9, let f10, let f11, let f12, let f13, let f14, let f15, ]] => am: { // then reassemble through a handwired jumpmap, with top digit of filler 0 => [nb, f15], 1 => [[f15, n0, n1, n2, n3, n4, n5, n6, n7, n8, n9, n10, n11, n12, n13, n14], f14], 2 => [[f14, f15, n0, n1, n2, n3, n4, n5, n6, n7, n8, n9, n10, n11, n12, n13], f13], // ... 15 => [[f1, f2, f3, f4, f5, f6, f7, f8, f9, f10, f11, f12, f13, f14, f15, n0], f0 ], }};
// split a u6 number into low 2 bits and high 4 bitsfn u6_u4u2 (nb) => nb[0]: { 0 => nb[1]: { 0 => [0, 0], 1 => [0, 4], 2 => [0, 8], 3 => [0, 12] }, // ... 14 => nb[1]: { 0 => [2, 3], 1 => [2, 7], 2 => [2, 11], 3 => [2, 15] }, 15 => nb[1]: { 0 => [3, 3], 1 => [3, 7], 2 => [3, 11], 3 => [3, 15] },};
// funnel shift left shift a number with custom filler on 2 levelsfn funnel_shift (nb, fl, amount) => u6_u4u2(amount): { [let l1, let l2] => shift_l2(nb, fl, l2): { [let nb_l2, let fl_l1] => shift_l1(nb_l2, fl_l1, l1),}};
// all shift variants are just simple variations of funnel shiftfn shl (nb, amount) => funnel_shift(nb, zero, amount);// shr = fush(0, nb, 64 - am)fn shr (nb, amount) => amount: { [0, 0] => nb, // fush can not do shift by 64 _ => funnel_shift(zero, nb, sub8([0, 4], amount)),};fn rol (nb, amount) => funnel_shift(nb, nb, amount);// sar = fush(nb, sign(nb): { neg => all_ones, pos => 0 }, 64 - am)fn sar (nb, amount) => amount: { [0, 0] => nb, _ => funnel_shift(sign(nb): { neg => ffff, pos => zero }, nb, sub8([0, 4], amount)),};Recursion isn’t inherently bad, it’s just O(n^2) inefficient for mapping purposes. In other operations that don’t use an array as state, though, it’s very efficient and convenient to use, which is why I use it in bit scanning, where the ability to break out early is essential.
// (n + 15) % 16 = n - 1fn dec_dg (dg) => add_dg(dg, 15, 0)[0];
// precomputed lookupmap of leading zero in a digitfn clz_dg (dg) => dg: { 1 => 3, 2 => 2, 3 => 2, 4 => 1, 5 => 1, 6 => 1, 7 => 1, 8 => 0, 9 => 0, 10 => 0, 11 => 0, 12 => 0, 13 => 0, 14 => 0, 15 => 0};// scan digits from high till first non zero onefn clz_loop (nb, dg_pos, bit_pos) => nb[dg_pos]: { 0 => clz_loop(nb, dec_dg(dg_pos), add8(bit_pos, [4, 0])), _ => add8(bit_pos, [clz_dg(nb[dg_pos]), 0])};// if all zero, loop would breakfn clz (nb) => nb: { zero => [0, 4], _ => clz_loop(nb, 15, [0, 0]) };
fn clo (nb) => clz(not(nb));// cls count leading sign bits other than the first onefn cls (nb) => sub8(sign(nb): { pos => clz(nb), neg => clo(nb) }, [1, 0]);
// count trailing is mirror to this but reversed direction.Bitfield operations are just applied logical operations and shifts living in the macro layer.
fn bitfield_extract (nb, offset, width) => and(shr(nb, offset), n_ones(width));
fn bitfield_insert (nb, base, offset, width) => n_ones(width): { // cleared_base | shifted_insert let mask => or(bcr(base, shl(mask, offset)), shl(and(nb, mask), offset)),};
// precomputed lookupmap of 64 bitmaskfn n_ones (n) => n[1]: { 0 => n[0]: { 0 => [ 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 1 => [ 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // ... }, 4 => n[0]: { // ... 14 => [15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 7], 15 => [15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15], },};The remaining operations in bit manipulation, and in this math implementation generally, are just different flavors of loop unrolling, ranging from simple hardwired selection to unique reduction trees.
// reverse bytes though unrolled selection loopfn rev8 (nb) => [ nb[14], nb[15], nb[12], nb[13], nb[10], nb[11], nb[8], nb[9], nb[6 ], nb[7 ], nb[4 ], nb[5 ], nb[2 ], nb[3 ], nb[0], nb[1]];
// reverse a digitfn rev_dg (dg) => dg: { 0 => 0, 1 => 8, 2 => 4, 3 => 12, 4 => 2, 5 => 10, 6 => 6, 7 => 14, 8 => 1, 9 => 9, 10 => 5, 11 => 13, 12 => 3, 13 => 11, 14 => 7, 15 => 15};// reverse though unrolled mapping loopfn rev (nb) => [ rev_dg(nb[15]), rev_dg(nb[14]), rev_dg(nb[13]), rev_dg(nb[12]), // ... rev_dg(nb[ 3]), rev_dg(nb[ 2]), rev_dg(nb[ 1]), rev_dg(nb[ 0])];
// count bits in a digitfn cnt_bit_dg (dg) => [dg: { 0 => 0, 1 => 1, 2 => 1, 3 => 2, 4 => 1, 5 => 2, 6 => 2, 7 => 3, 8 => 1, 9 => 2, 10 => 2, 11 => 3, 12 => 2, 13 => 3, 14 => 3, 15 => 4}, 0];// unrolled reduction loopfn count_bits (nb) => add8( add8( add8(add8(cnt_bit_dg(nb[0]), cnt_bit_dg(nb[1])), add8(cnt_bit_dg(nb[2]), cnt_bit_dg(nb[3]))), add8(add8(cnt_bit_dg(nb[4]), cnt_bit_dg(nb[5])), add8(cnt_bit_dg(nb[6]), cnt_bit_dg(nb[7]))), ), add8( add8(add8(cnt_bit_dg(nb[8]), cnt_bit_dg(nb[9])), add8(cnt_bit_dg(nb[10]), cnt_bit_dg(nb[11]))), add8(add8(cnt_bit_dg(nb[12]), cnt_bit_dg(nb[13])), add8(cnt_bit_dg(nb[14]), cnt_bit_dg(nb[15]))), ));fn count_zeros (nb) => count_bits(not(nb));After building the operations from first principles in a direct way, we can now dive into the CPU itself, which turns out to be simpler than you might expect.
MEP Deep Dive
Section titled “MEP Deep Dive”Reading through MEP, you’ll find it much like any other virtual machine internally, and that’s exactly what it is. MEP stores all its state, from registers to memory, in a record called state. This record is passed to every CPU logic location, and unlike imperative virtual machines, the state is updated by recreation through resting and computed fields.
fn read_reg (state, reg) => state.regs[reg];fn write_reg (state, reg, value) => reg: { 0 => state, // discard write _ => { ..state, regs = { ..state.regs, [reg] = value } }};
fn read_cond(state, reg) => reg: { 0 => state.c0, _ => state.regs[reg]: { zero => 0, _ => 1 }, // all non zero values are true};fn write_cond (state, reg, value) => reg: { 0 => { ..state, c0 = value }, _ => write_reg(state, reg, value: { 0 => zero, 1 => one })};MEP employs a fetch-decode-execute loop just like any other CPU, and to support enormous iteration counts without stack overflow, MEP uses continuation mode. Continuation mode is an execution mode and optimization provided by UniMap for loop-oriented programs, where the loop lives in the runtime instead of deep recursion.
let init_state = { regs = { 0 = zero, 1 = zero, /* .. */ 31 = zero }, pc = zero, c0 = 0, mem = {},};
// run a cyclefn run_iter (state) => read_mem_32(state, state.pc) // fetch |> exec(_, state): { // execute [branched, let state] => [continue, state], [halted, let state] => [end, state], // else increment pc let state => [continue, { ..state, pc = add(state.pc, four) }], };
// the assembler link the loop through// called by runtime to produce the init statefn init () => { ..init_state, mem = { /* ..... */ } };// then it call `loop` on each iteration till an `end` symbol is returnedfn loop (state) => run_iter(state);From the main loop, we dive deeper into the decode and execution path, which, like everything else in UniMap, is powered by maps and pipes.
Mapped Fields Decoder
Section titled “Mapped Fields Decoder”Our journey starts at the top level router, the exec function, which routes instructions into major execution paths through a regular map. UniMap doesn’t have native errors, but the stack isn’t unlimited.
fn exec (inst, state) => inst[0]: { 1 => exec_dpr(inst, state), 2 => exec_dpi(inst, state), 3 => exec_mem(inst, state), 4 => exec_branch(inst, state), _ => invalid(state, inst),};
symbol invalid_instruction;fn invalid (state, inst) => dbg([invalid_instruction, state.pc, inst, state]) |> panic();fn panic() => panic(); // we can decent the loop normally, but panicking is more funReading the architectural reference, you’ll notice the encoding is entirely normal and bit-oriented, unlike TEEP’s blocky encoding. This comes down to a collection of helper functions that extract bits from digits through the typical lookup maps.
// extract specific bitfn b0 (dg) => dg: { 0 => 0, 1 => 1, 2 => 0, 3 => 1, 4 => 0, 5 => 1, 6 => 0, 7 => 1, 8 => 0, 9 => 1, 10 => 0, 11 => 1, 12 => 0, 13 => 1, 14 => 0, 15 => 1};fn b1 (dg) => dg: { 0 => 0, 1 => 0, /* .. */ 15 => 1 };fn b2 (dg) => dg: { 0 => 0, 1 => 0, /* .. */ 15 => 1 };fn b3 (dg) => dg: { 0 => 0, 1 => 0, /* .. */ 15 => 1 };
// extract specific bit sectionfn low2 (dg) => dg: { 0 => 0, 1 => 1, 2 => 2, 3 => 3, 4 => 0, 5 => 1, 6 => 2, 7 => 3, 8 => 0, 9 => 1, 10 => 2, 11 => 3, 12 => 0, 13 => 1, 14 => 2, 15 => 3};fn high2 (dg) => dg: { 0 => 0, 1 => 0, /* .. */ 15 => 3 };fn low3 (dg) => dg: { 0 => 0, 1 => 1, /* .. */ 15 => 7 };fn high3 (dg) => dg: { 0 => 0, 1 => 0, /* .. */ 15 => 7 };fn mid2 (dg) => dg: { 0 => 0, 1 => 0, /* .. */ 15 => 3 };These small functions enable the extraction and mapping of inner digit fields. For multi-digit fields, you just increase the map’s dimensions, just like how registers are decoded.
// -4321 0123 4567 8901 2345// reg-1 reg0 reg1 reg2fn dec_reg0 (field0, field1) => high3(field0): { 0 => low2(field1): { 0 => 0, 1 => 8, 2 => 16, 3 => 24 }, // ... 7 => low2(field1): { 0 => 7, 1 => 15, 2 => 23, 3 => 31 }};fn dec_reg1 (field1, field2) => high2(field1): { 0 => low3(field2): { 0 => 0, 1 => 4, /* .. */ 7 => 28 }, // ... 3 => low3(field2): { 0 => 3, 1 => 7, /* .. */ 7 => 31 }};fn dec_reg2 (field2, field3) => b3(field2): { 0 => field3: { 0 => 0, 1 => 2, /* .. */ 15 => 30 }, 1 => field3: { 0 => 1, 1 => 3, /* .. */ 15 => 31 }};// named -1 since most instructions are 3 regsfn dec_reg-1 (field-1, field0) => b0(field0): { 0 => field-1, 1 => field-1: { 0 => 16, 1 => 17, /* .. */ 15 => 31 }};
fn dec_3reg (field0, field1, field2, field3) => [dec_reg0(field0, field1), dec_reg1(field1, field2), dec_reg2(field2, field3)];fn dec_2reg (field0, field1, field2) => [dec_reg1(field0, field1), dec_reg2(field1, field2)];fn dec_1reg (field0, field1) => dec_reg2(field0, field1);fn dec_4reg (field-1, field0, field1, field2, field3) => [ dec_reg-1(field-1, field0), dec_reg0(field0, field1), dec_reg1(field1, field2), dec_reg2(field2, field3)];From registers, we move on to their category, the data processing register major group, which is the largest group by operation count and the most diverse.
Straightforward Data Processing
Section titled “Straightforward Data Processing”The DPR group starts usual with a minor opcode router, which routes into specific executors, each handling decode, read, execute, and writeback in a pipe chain, with maps handling the dispatch flow.
// if you show someone this code, would they believe this is an esolang?fn exec_dpr (inst, state) => inst[1]: { 0 => exec_3regs(inst, state), 1 => exec_2regs(inst, state), 2 => exec_4regs(inst, state), 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 => exec_shift(inst, state), _ => invalid(state, inst)};
fn exec_shift (inst, state) => [ // decode dec_3reg(inst[4], inst[5], inst[6], inst[7]), u2u4_u6(high2(inst[2]), inst[3])]: { // shift [[let dst, let src1, let src2], let shift_amount] => low2(inst[2]): { 0 => shift_amount: { [0, 0] => state.regs[src2], // no shift fast path _ => shl(state.regs[src2], shift_amount) }, 1 => shr(state.regs[src2], shift_amount), 2 => sar(state.regs[src2], shift_amount), 3 => rol(state.regs[src2], shift_amount) } // compute |> b0(inst[4]): { 0 => inst[1]: { // add dst:reg, src1:reg, src2:sh_reg 8 => add(state.regs[src1], _), // sub dst:reg, src1:reg, src2:sh_reg 9 => sub(state.regs[src1], _), // sub dst:reg, src1:sh_reg, src2:reg 10 => sub(_, state.regs[src1]), _ => invalid(state, inst) }, 1 => inst[1]: { // and dst:reg, src1:reg, src2:sh_reg 8 => and(state.regs[src1], _), // or dst:reg, src1:reg, src2:sh_reg 9 => or(state.regs[src1], _), // ... // bcr dst:reg, src1:reg, src2:sh_reg 15 => bcr(state.regs[src1], _), } // write } |> write_reg(state, dst, _)};Yes, it really is this straightforward. I wasn’t joking when I said MEP is essentially an advanced math function dispatcher powered by maps, since thanks to function composition, all the horror we went through implementing math from nothing but symbols is hidden under functions with small names.
This counts among the more complex operations in DPR. DPR is the largest and most diverse group, but it’s also the most straightforward, since it’s just general operations acting on registers, though that doesn’t mean it lacks instructions worth a closer look.
fn exec_3regs (inst, state) => dec_3reg(inst[4], inst[5], inst[6], inst[7]): { [let dst, let src1, let src2] => inst[2]: { 0 => inst[3]: { // ... // cnot dst:reg, cond:reg, src:reg 6 => state.regs[src2] |> read_cond(state, src1): { 1 => not(_), 0 => _ }, // cinc dst:reg, cond:reg, src:reg 7 => state.regs[src2] |> read_cond(state, src1): { 1 => add(_, one), 0 => _ }, // cneg dst:reg, cond:reg, src:reg 8 => state.regs[src2] |> read_cond(state, src1): { 1 => negate(_), 0 => _ }, _ => invalid(state, inst) } |> write_reg(state, dst, _), // ... 2 => low3(inst[3]): { // test.none cond:reg, src:reg, mask:reg, src1 & src2 == 0 0 => eq(and(state.regs[src1], state.regs[src2]), zero), // test.any cond:reg, src:reg, mask:reg, src1 & src2 != 0 1 => ne(and(state.regs[src1], state.regs[src2]), zero), // test.all cond:reg, src:reg, mask:reg, src1 & src2 == src2 2 => eq(and(state.regs[src1], state.regs[src2]), state.regs[src2]), } |> map_cond(state, inst, dst, _), _ => invalid(state, inst) }};
// write cond, and / or with dst based on `cw` fieldfn map_cond (state, inst, dst, cond) => b3(inst[3]): { 0 => write_cond(state, dst, cond), 1 => write_cond(state, dst, b0(inst[4]): { 0 => and_dg(read_cond(state, dst), cond), 1 => or_dg(read_cond(state, dst), cond), })};The other category of data processing is DPI, data processing immediate. DPI is similar to DPR in its operation logic and general architecture, though not every DPR instruction has a DPI counterpart. DPI has more encoding diversity thanks to its variety and size of immediates, though all of these immediates are implemented using the same hardwired approach.
// sel dst:reg, cond:reg, src1:reg, src2:s9_imdfn exec_select(inst, state) => dec_3reg(inst[2], inst[3], inst[4], inst[5]): { // decode s9 immediate with separate sign bit [let dst, let cond, let src1] => b0(inst[2]): { 0 => [inst[6], inst[7], 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 1 => [inst[6], inst[7], 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15], } // compute then write |> read_cond(state, cond): { 1 => state.regs[src1], 0 => _ } |> write_reg(state, dst, _)};
// u16 immediate shifted by sh * 16fn sh_u16_imd(nb0, nb1, nb2, nb3, sh) => sh: { 0 => [nb0, nb1, nb2, nb3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 1 => [0, 0, 0, 0, nb0, nb1, nb2, nb3, 0, 0, 0, 0, 0, 0, 0, 0], 2 => [0, 0, 0, 0, 0, 0, 0, 0, nb0, nb1, nb2, nb3, 0, 0, 0, 0], 3 => [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, nb0, nb1, nb2, nb3],};
fn exec_mov (inst, state) => [dec_1reg(inst[2], inst[3]), mid2(inst[2])]: { [let dst, let sh] => sh_u16_imd(inst[4], inst[5], inst[6], inst[7], sh) |> b0(inst[2]): { 0 => _, // mov dst:reg, src:u16_imd, sh:u2_imd // mov.keep dst:reg, src:u16_imd, sh:u2_imd 1 => or(bcr(state.regs[dst], sh_u16_imd(15, 15, 15, 15, sh)), _), } |> write_reg(state, dst, _)};The logical immediate used inside logical instructions is special, both on paper and in its implementation, though it’s still just the lookup table approach you’ve already seen, piped into the rot function.
// split a u12 field into u6 then u6 immediatefn imd_6_6 (inst) => [[inst[5], low2(inst[6])], u2u4_u6(high2(inst[6]), inst[7])];
// logic immediate: l0:u1, ones:u6, rot:u6// l0 and ones concatenation is a prefix code selecting level, with remainder encoding one_len// logic_imd = rotate(repeat(ones(one_len), level), rot)fn logic_imd (inst) => imd_6_6(inst): { [let ones, let rot] => n_ones_leveled(ones, b1(inst[2])) |> rol(_, rot)};
// `n_ones`, just repeated based on levelfn n_ones_leveled (n, l0) => l0: { 0 => n_ones(n), 1 => n[1]: { 0 => n[0]: { 0 => [1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0], /* .. */ }, // ... 3 => n[0]: { 0 => [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0], /* .. */ }, }};With data processing powered by maps and pipes, what about data movement?
Memory Architecture
Section titled “Memory Architecture”UniMap uses a Von Neumann architecture with little-endian, byte-addressable memory and a full 32-bit address space. Memory is organized into 7 levels of pages with 16-byte lines. This uniformly splits the address into eight 4-bit sections (a digit each), 7 pages plus a line offset, which removes any translation requirement and reduces page-update overhead. Memory is sparse to reduce size and is accessed through index patterns and the spread operator.
let zero128 = [..zero, ..zero];// same map tower in arithmetic section, just using index pattern on each pagefn read_mem_line (state, address) => state.mem: { // index pattern: match a computed field against a pattern { [address[7]]: let page0 } => page0: { { [address[6]]: let page1 } => page1: { // ... { [address[1]]: let line } => line, _ => zero128 // ... }, _ => zero128 }, // else return empty line _ => zero128};
fn safe_read (obj, field) => obj: { { [field]: let val } => val, _ => {}};// update each page, and create it if neededfn write_mem_line (state, address, line) => { ..state, mem = { ..state.mem, [address[7]] = safe_read(state.mem, address[7]) |> { .._, [address[6]] = safe_read(_, address[6]) |> { // ... .._, [address[1]] = line // ... }} };Accessing memory lines is trivial, and accessing data types stored in memory is also trivial, just slicing and overwriting memory lines through hardwiring and jump maps. Though, like many operations in UniMap, it’s syntactically massive.
symbol unaligned_address;fn read_mem_32 (state, address) => read_mem_line(state, address) |> address[0]: { // hardwired slicing based on byte pos in line 0 => [_[ 0], _[ 1], _[ 2], _[ 3], _[ 4], _[ 5], _[ 6], _[ 7]], 4 => [_[ 8], _[ 9], _[10], _[11], _[12], _[13], _[14], _[15]], 8 => [_[16], _[17], _[18], _[19], _[20], _[21], _[22], _[23]], 12 => [_[24], _[25], _[26], _[27], _[28], _[29], _[30], _[31]], _ => dbg([unaligned_address, state.pc, address]) |> panic(),};
fn write_mem_64 (state, address, value) => read_mem_line(state, address) |> address[0]: { // hardwired merging based on byte pos in line 0 => [ value[0], value[1], value[2], value[3], value[4], value[5], value[6], value[7], value[8], value[9], value[10], value[11], value[12], value[13], value[14], value[15], _[16], _[17], _[18], _[19], _[20], _[21], _[22], _[23], _[24], _[25], _[26], _[27], _[28], _[29], _[30], _[31], ], 8 => [ _[0], _[1], _[2], _[3], _[4], _[5], _[6], _[7], _[8], _[9], _[10], _[11], _[12], _[13], _[14], _[15], value[0], value[1], value[2], value[3], value[4], value[5], value[6], value[7], value[8], value[9], value[10], value[11], value[12], value[13], value[14], value[15], ], _ => dbg([unaligned_address, state.pc, address]) |> panic(),} |> write_mem_line(state, address, _);// and write_mem_8 is the largest non lookupmap function in term of expression countUnlike memory access, memory instructions are actually syntactically tiny despite their richness, just map-oriented decode and execute like the previous DPR and DPI instructions.
// ld/st [base + offset], +/- 2Kb (u8) / 8Kb (u64) offsetfn exec_imd (inst, state, writeback) => [dec_2reg(inst[2], inst[3], inst[4]), s12_imd(inst)]: { [[let reg, let base], let imd] => cal_address(state.regs[base], imd, low2(inst[2])): { let address => access_mem(state, inst, reg, address, high2(inst[1]), low2(inst[2])) |> writeback: { 0 => _, 1 => write_reg(_, base, address) } }};
// size field: 0: u64, 1: u32, 2: u16, 3: u8fn access_mem (state, inst, reg, address, op, size) => op: { 0 => size: { // ld 0 => read_mem_64(state, address), // ld.32 1 => read_mem_32(state, address) |> n32_nb(_), // .. } |> write_reg(state, reg, _), 1 => state.regs[reg] |> size: { // st 0 => write_mem_64(state, address, _), // st.32 1 => write_mem_32(state, address, _), // .. }, // ...};
// add base and scaled offset by data width// u64 is scaled by 2 since instructions are 4 bytesfn cal_address (base, offset, size) => size: { 3 => add(base, offset), // quick path for u8 _ => add(base, shift_l1(offset, 0, size: { 0 => 2, 1 => 2, 2 => 1 })),};The Last Branches
Section titled “The Last Branches”Now we arrive at the final destination inside MEP, the branch instructions. Despite belonging to a different category, they share the same mechanism found in data processing and memory.
fn exec_branch (inst, state) => inst[1]: { // br.true cond:reg, offset:s19_imd 3 => read_cond(state, dec_1reg(inst[6], inst[7])): { 0 => state, 1 => branch_to(state, s19_imd(inst)), }, 4 => [inst[2], inst[3]]: { [0, 0] => dec_2reg(inst[5], inst[6], inst[7]): { [let link, let index] => low3(inst[4]): { // jmp.table link:reg, index:reg shl amount:u3_imd 1 => write_reg(state, link, next_address(state)) // save the return address // calculate address then jump |> goto(_, add(next_address(state), dec_shift(b3(inst[4]), low2(inst[5])) |> shl(state.regs[index], [_, 0]) )), // ... } }, _ => invalid(state, inst), }, // ...};
fn next_address (state) => add(state.pc, four); // address of next instruction// jump to pc + scaled offsetfn branch_to (state, offset) => goto(state, add(state.pc, shift_l1(offset, 0, 2)));// set pc and signal to fetcher to not increment pcfn goto (state, address) => [branched, { ..state, pc = address }];
// decode shift for br.table: u3_imd scaled by instruction size, cases range from 1 to 128 instructionsfn dec_shift (low, high) => low: { 0 => high: { 0 => 2, 1 => 4, 2 => 6, 3 => 8 }, 1 => high: { 0 => 3, 1 => 5, 2 => 7, 3 => 9 },};The End
Section titled “The End”We’ve reached the end of our dive, and my thesis has been proven. It’s no deep insight that most things are Turing complete, the difference lies in efficiency. My actual thesis was about how practical and elegant it is to express a computational universe from nothing but symbolic pattern matching, and MEP proves that, arguably more than enough.
Not only was a full math suite implemented from primitive symbols, it was built in a readable, unhacky form. Beyond that, a full 64-bit RISC CPU with rich operations was created on top of it, in a straightforward and clean architecture.
The beauty of systems isn’t in their outer form, in vibrant visuals or impressive slimness. It’s in their simplicity, effectiveness, modularity, and everything else that makes them worth studying. That’s what UniMap was created, in convenient minimalism, to present, and what MEP, in all its complexity, was created to demonstrate.
MEP is my second CPU and ISA. Despite all its richness, it’s still incomplete. Privileged execution, floating point, and predicated vector processing are still missing. Though the current feature set is already enough for MEP’s existential purpose, and those concepts are simply saved for future work.
As a final touch, here are some insights extracted from the runtime with a print! placed inside its core. The .unim file used is the assembly result of a single halt instruction.
item count: - expressions: 16727 - patterns: 671
- jump tables: 789 - regular maps: 245 - pipes: 78
- functions: 163 - symbols: 22 - constants: 26
- numbers: 11980 - arrays: 1096 - records: 35
- function call: 733 - let bindings: 343 - pipe intermediaries: 1096
top functions in expression count: - lookupmaps: - add_dg: 2082 - shift_dg: 1570 - n_ones_leveled: 1112 - n_ones: 1092 - and_dg / or_dg / xor_dg: 290 - non lookupmaps: - write_mem_8: 1564 - write_mem_16: 797 - write_mem_32: 409 - shift_l2: 294 - exec_4regs: 273That’s all for today. Don’t hesitate to try UniMap, this math library is a free gift. See you with the next CPU.