Skip to content

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.

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.

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 register
add 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 4
add r5, r1, r2, r3
msub 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 unused
mult r3, r1, r2 // the assembler is rich in pseudo instructions
// `div` and `udiv` with quotient and remainder
div 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-branch
abs r1, r1
min r3, r1, r2

The logical side also gets improvements in orthogonality and expressive second operands.

// all logical operations take shifted register
xor r3, r1, r1 shl 1 // edge detection
// full 2 input bitwise operations orthogonality
bcl 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 immediate
and 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 == 0xf

Moreover, 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), checksums
clz r2, r1 // r2 = count_leading_zeros(r1), bit length
rev.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 = r1
bfext r2, r3, 5, 4 // r2 = r3[5..9], r2 = r3.c
// funnel shift the primitive of all shift
fush r4, r1, r2, r3 // left shift r1 by r3 taking carry from r2
fush 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 instructions
mov r1, 0x1234, 1 // r1 = 0x1234 shl 16
mov.keep r1, 0x4567, 2 // r1[32..48] = 0x4567
mov r1, logic_imd(32, 15, 16) // r1 = r0 | (repeat(ones(15), 64 / 32) rol 16)
// store and load has various addressing modes
ld 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)] = r1
st r1, [r2 += 0x16] // addr = r2 + 0x16; mem[addr..(addr + 8)] = r1; r2 = addr

MEP, 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 register
comp.gt c0, r1, r0 // test r1 strictly positive
// condition generation support multiple operation other than overwrite
comp.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 branches
sel 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 4

You 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.

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 symbols
symbol a, b, c;
// you can create data structures and index them
let 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 unimap
let 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 operator
let 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 composition
fn 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 universe

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.

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 keys
let 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 time
fn 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.unim

After 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) = 240
fn not_recursive (n) => n: {
[] => [],
[let v, ..let rest] => [not_dg(v), ..not_recursive(rest)],
};
// larger but more efficient
fn 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.

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.unim
fn and_dg (a, b) => a: {
0 => b: { 0 => 0, 1 => 0, /* .. */ 15 => 0 },
/* ... */
15 => b: { 0 => 0, 1 => 1, /* .. */ 15 => 15 },
};
// micro layer
fn 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 ones
fn 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 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 ios
fn 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 moves
fn 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 trick
fn 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 dg
fn 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 folding
fn 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 cycle
fn 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 readability
fn 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 division
fn udiv (a, b) => b: {
zero => [zero, a],
_ => udiv64(a, b)
};
// signed division
fn 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 equality
fn eq (a, b) => a: { b => 1, _ => 0 };
// 0 if a == b, 1 if a > b, 2 if a < b
// using a - b trick
fn 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 signs
fn 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 bit
fn 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],
};

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/filler
fn 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 nb
fn 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 shift
fn 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 bits
fn 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 levels
fn 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 shift
fn 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 - 1
fn dec_dg (dg) => add_dg(dg, 15, 0)[0];
// precomputed lookupmap of leading zero in a digit
fn 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 one
fn 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 break
fn 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 one
fn 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 bitmask
fn 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 loop
fn 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 digit
fn 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 loop
fn 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 digit
fn 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 loop
fn 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.

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 cycle
fn 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 state
fn init () => { ..init_state, mem = { /* ..... */ } };
// then it call `loop` on each iteration till an `end` symbol is returned
fn 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.

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 fun

Reading 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 bit
fn 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 section
fn 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 reg2
fn 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 regs
fn 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.

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` field
fn 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_imd
fn 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 * 16
fn 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 immediate
fn 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 level
fn 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?

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 page
fn 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 needed
fn 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 count

Unlike 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) offset
fn 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: u8
fn 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 bytes
fn 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 })),
};

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 offset
fn branch_to (state, offset) => goto(state, add(state.pc, shift_l1(offset, 0, 2)));
// set pc and signal to fetcher to not increment pc
fn goto (state, address) => [branched, { ..state, pc = address }];
// decode shift for br.table: u3_imd scaled by instruction size, cases range from 1 to 128 instructions
fn 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 },
};

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: 273

That’s all for today. Don’t hesitate to try UniMap, this math library is a free gift. See you with the next CPU.