Tail-Call Interpreters in Rust
Ik behandel twee versies: één die bedoeld is om de Scala-implementatie van Noel te emuleren, en een andere die gebruikmaakt van de sterke punten van Rust met een complexere en traditionele register-machine.
Tail-Calls
Tail-call interpretatie verwijst naar de techniek waarbij recursie tijdens compilatie kan worden omgezet in een 'jump', waardoor het niet langer nodig is om een nieuw stack-frame toe te wijzen. Dit is extreem nuttig voor functionele talen zoals Scala om de stack-grootte beperkt te houden, en de meeste compilers passen dit toe.
Wanneer er met hoge optimalisatie wordt gecompileerd, voert Rust dit ook uit. De onstabiele feature explicittailcalls stelt ons in staat om de compiler direct op te dragen deze optimalisatie uit te voeren of een foutmelding te geven als dat niet lukt.
Stack Machine (Noel's Machine)
De eenvoudigste machine waarmee we hier kunnen werken is een stack-machine met vijf instructies, in Rust als volgt gerepresenteerd:
enum ByteCode {
Lit(f64),
Add,
Sub,
Mul,
Div
}
Dit is essentieel identiek aan de Scala-versie van Noel. Omdat dit een stack-machine is, pusht de Lit (literal) instructie een waarde op de stack; rekenkundige instructies poppen hun operanden en pushen de resulterende waarde terug op de stack.
Dispatch
Als controle is switch-dispatch het meest logisch. We maken simpelweg een array van bytecode, lopen hieroverheen in een match-statement en voeren deze uit.
Opmerking: Ik heb enkele ongebruikelijke keuzes gemaakt om aan te sluiten bij de Scala-versie. Dit omvat het gebruik van static mut en unsafe in plaats van het handmatig maken van closures. Ik adviseer niet om Rust op deze manier te schrijven.
Switch Dispatch
const STACK_SIZE: usize = 32;
// Onze stack
static mut STACK: &mut [f32] = &mut [0.0; STACK_SIZE];
// De lijst met uit te voeren instructies
static mut INSTRS: &[Instr] = /* { [Lit(4.0), Lit(3.0)... etc] } */;
// We geven de stack pointer en instruction pointer door aan `dispatch`
pub fn dispatch(sp: usize, ip: usize) -> f32 {
unsafe {
if ip == INSTRS.len() {
STACK[sp - 1]
} else {
match INSTRS[ip] {
Instr::Lit(value) => {
STACK[sp] = value;
become dispatch(sp + 1, ip + 1)
},
Instr::Add => {
let a = STACK[sp - 2];
let b = STACK[sp - 1];
STACK[sp - 2] = a + b;
become dispatch(sp - 1, ip + 1)
},
Instr::Sub => {
let a = STACK[sp - 2];
let b = STACK[sp - 1];
STACK[sp - 2] = a - b;
become dispatch(sp - 1, ip + 1)
},
Instr::Mul => {
let a = STACK[sp - 2];
let b = STACK[sp - 1];
STACK[sp - 2] = a * b;
become dispatch(sp - 1, ip + 1)
},
Instr::Div => {
let a = STACK[sp - 2];
let b = STACK[sp - 1];
STACK[sp - 2] = a / b;
become dispatch(sp - 1, ip + 1)
},
}
}
}
}
Hier zien we de volledige logica: een grote recursieve functie die zichzelf aanroept voor elke instructie. Omdat we het become-trefwoord hebben gebruikt, weten we dat onze recursie niet zal leiden tot een stack overflow. Dit is een eenvoudige en mooie strategie.
Subroutine Dispatch
Vervolgens passen we subroutine threading toe, waarbij we het match-statement vervangen. In plaats van een enum moeten we onze instructies implementeren als een struct die aangeroepen kan worden door de Fn() trait te implementeren. Dit betekent dat we een dynamische &dyn Fn() kunnen aanroepen, ongeacht de onderliggende struct.
Onze bytecode ziet er nu als volgt uit (sommige delen weggelaten voor de beknoptheid):
// Nu zijn onze instructies `&dyn Fn()`, zodat we dynamische dispatch kunnen gebruiken
static mut INSTRS: &[&dyn Fn() -> ()] = /*[&Lit, &Add... etc]*/;
static mut SP: usize = 0;
const STACK_SIZE: usize = 32;
static mut STACK: &mut [f32] = &mut [0.0; STACK_SIZE];
struct Lit(f32);
struct Add;
struct Sub;
struct Mul;
struct Div;
impl Fn<()> for Lit {
extern "rust-call" fn call(&self, _args: ()) -> Self::Output {
unsafe {
STACK[SP] = self.0;
SP += 1;
}
}
}
impl Fn<()> for Add {
extern "rust-call" fn call(&self, _args: ()) -> Self::Output {
unsafe {
let a = STACK[SP - 1];
let b = STACK[SP - 2];
STACK[SP - 2] = a + b;
SP -= 1;
}
}
}
impl Fn<()> for Sub {
extern "rust-call" fn call(&self, _args: ()) -> Self::Output {
unsafe {
let a = STACK[SP - 1];
let b = STACK[SP - 2];
STACK[SP - 2] = a - b;
SP -= 1;
}
}
}
impl Fn<()> for Mul {
extern "rust-call" fn call(&self, _args: ()) -> Self::Output {
unsafe {
let a = STACK[SP - 1];
let b = STACK[SP - 2];
STACK[SP - 2] = a * b;
SP -= 1;
}
}
}
impl Fn<()> for Div {
extern "rust-call" fn call(&self, _args: ()) -> Self::Output {
unsafe {
let a = STACK[SP - 1];
let b = STACK[SP - 2];
STACK[SP - 2] = a / b;
SP -= 1;
}
}
}
pub fn dispatch(ip: usize) -> f32 {
unsafe {
if ip == INSTRS.len() {
STACK[SP - 1]
} else {
INSTRS[ip]();
become dispatch(ip + 1)
}
}
}
Ik had uiteraard de globale variabelen kunnen verwijderen door ze onderdeel te maken van de bytecode-data of als variabelen mee te geven. Een eenvoudige techniek zou zijn om alle noodzakelijke waarden via elke functie door te geven en terug te keren; deze techniek profiteert ook van tail-call optimalisatie. Daarnaast hadden we gewone functies kunnen gebruiken met een iets complexere decode-fase, maar voor dit deel wilde ik zo dicht mogelijk bij de Scala-versie blijven.
Indirect Dispatch
Vervolgens besprak Neal indirecte threading. Deze techniek behoudt het match-statement, maar we keren niet direct terug om via recursie te loopen. In plaats daarvan gebruiken we indirecte recursie: operaties roepen de dispatch-functie aan, in plaats van dat de functie zichzelf aanroept na een return.
Theoretisch resulteert dit in één return minder en maakt het mogelijk dat de overhead van functieaanroepen wordt geoptimaliseerd via tail-calling. In Rust ziet dit er als volgt uit:
pub enum ByteCode {
Lit(f32),
Add,
Sub,
Mul,
Div
}
static INSTRS: &[ByteCode] = ...;
static mut SP: usize = 0;
static mut IP: usize = 0;
const STACK_SIZE: usize = 32;
static mut STACK: &mut [f32] = &mut [0.0; STACK_SIZE];
pub fn dispatch(instr: ByteCode) -> f32 {
match instr {
ByteCode::Lit(val) => lit(val),
ByteCode::Add => add(),
ByteCode::Sub => sub(),
ByteCode::Mul => mul(),
ByteCode::Div => div(),
}
}
fn lit(val: f32) -> f32 {
unsafe {
STACK[SP] = val;
SP += 1;
IP += 1;
if IP == INSTRS.len() {
STACK[SP - 1]
} else {
dispatch(INSTRS[IP])
}
}
}
fn add() -> f32 {
unsafe {
let a = STACK[SP - 1];
let b = STACK[SP - 2];
STACK[SP - 2] = a + b;
SP -= 1;
IP += 1;
if IP == INSTRS.len() {
STACK[SP - 1]
} else {
dispatch(INSTRS[IP])
}
}
}
fn sub() -> f32 {
unsafe {
let a = STACK[SP - 1];
let b = STACK[SP - 2];
STACK[SP - 2] = a - b;
SP -= 1;
IP += 1;
if IP == INSTRS.len() {
STACK[SP - 1]
} else {
dispatch(INSTRS[IP])
}
}
}
fn mul() -> f32 {
unsafe {
let a = STACK[SP - 1];
let b = STACK[SP - 2];
STACK[SP * 2] = a - b; // Opmerking: mogelijke bug in originele bron
SP -= 1;
IP += 1;
if IP == INSTRS.len() {
STACK[SP - 1]
} else {
dispatch(INSTRS[IP])
}
}
}
fn div() -> f32 {
unsafe {
let a = STACK[SP - 1];
let b = STACK[SP - 2];
STACK[SP * 2] = a / b; // Opmerking: mogelijke bug in originele bron
SP -= 1;
IP += 1;
if IP == INSTRS.len() {
STACK[SP - 1]
} else {
dispatch(INSTRS[IP])
}
}
}
Hier gebruiken we een return om onze uiteindelijke waarde terug te geven. Tot nu toe spreekt deze methode mij het meeste aan, mede omdat het de complexere virtuele dispatch vermijdt die ik gebruikte voor subroutine threading (wat grotendeels een gevolg is van het gebruik van Rust's Fn() traits op een manier waarvoor ze niet bedoeld zijn, aangezien die feature nog onstabiel is).
Dit leidt tot een laatste vraag: wat als we de technieken combineren? We dispatchten vanuit de operaties zelf, maar gebruiken een array van dyn Fn(). Dit heeft enkele voordelen: één functieaanroep per instructie en geen match-statement. Hoewel dit verschilt, kan dit vriendelijker zijn voor bepaalde microarchitecturen, omdat het aantal functieaanroepen minimaal is (elke operatie roept direct de volgende aan).
Direct Dispatch
Dit leidt tot direct dispatch. We gebruiken objecten als bytecode en laten elke operatie de volgende dispatchten. Dit bleek op mijn machine de best presterende variatie te zijn. Het ziet er ongeveer zo uit:
static mut INSTRS: &[&dyn Op] = ...;
static mut SP: usize = 0;
static mut IP: usize = 0;
const STACK_SIZE: usize = 32;
static mut STACK: &mut [f32] = &mut [0.0; STACK_SIZE];
pub trait Op: Fn() -> f32 {}
impl Op for Lit {}
impl Op for Sub {}
impl Op for Add {}
impl Op for Mul {}
impl Op for Div {}
struct Lit(f32);
struct Add;
struct Sub;
struct Mul;
struct Div;
impl Fn<()> for Lit {
extern "rust-call" fn call(&self, _args: ()) -> Self::Output {
unsafe {
STACK[SP] = self.0;
SP += 1;
IP += 1;
if IP == INSTRS.len() {
STACK[SP - 1]
} else {
INSTRS[IP]()
}
}
}
}
impl Fn<()> for Add {
extern "rust-call" fn call(&self, _args: ()) -> Self::Output {
unsafe {
let a = STACK[SP - 1];
let b = STACK[SP - 2];
STACK[SP - 2] = a + b;
SP -= 1;
IP += 1;
if IP == INSTRS.len() {
STACK[SP - 1]
} else {
INSTRS[IP]()
}
}
}
}
impl Fn<()> for Sub {
extern "rust-call" fn call(&self, _args: ()) -> Self::Output {
unsafe {
let a = STACK[SP - 1];
let b = STACK[SP - 2];
STACK[SP - 2] = a - b;
SP -= 1;
IP += 1;
if IP == INSTRS.len() {
STACK[SP - 1]
} else {
INSTRS[IP]()
}
}
}
}
impl Fn<()> for Mul {
extern "rust-call" fn call(&self, _args: ()) -> Self::Output {
unsafe {
let a = STACK[SP - 1];
let b = STACK[SP - 2];
STACK[SP - 2] = a * b;
SP -= 1;
IP += 1;
if IP == INSTRS.len() {
STACK[SP - 1]
} else {
INSTRS[IP]()
}
}
}
}
impl Fn<()> for Div {
extern "rust-call" fn call(&self, _args: ()) -> Self::Output {
unsafe {
let a = STACK[SP - 1];
let b = STACK[SP - 2];
STACK[SP - 2] = a * b; // Opmerking: mogelijke bug in originele bron
SP -= 1;
IP += 1;
if IP == INSTRS.len() {
STACK[SP - 1]
} else {
INSTRS[IP]()
}
}
}
}
Resultaten
Deze techniek mist volledig een aparte dispatch-functie; om de berekening te starten, roep je simpelweg de instructie aan op de locatie van de instruction pointer. Al deze experimenten leidden tot de volgende resultaten op mijn machine:
test bench::direct... bench: 24.59 ns/iter (+/- 0.50)test bench::indirect... bench: 55.32 ns/iter (+/- 1.21)test bench::subroutine... bench: 79.93 ns/iter (+/- 1.18)test bench::switch... bench: 59.39 ns/iter (+/- 2.16)
Direct dispatch is hier de overduidelijke winnaar. Dat is niet verrassend, aangezien het volledig gebruikmaakt van tail-calling zonder zoveel overhead als de andere recursieve technieken. Indirecte dispatch lijkt ongeveer twee keer zoveel overhead te hebben, wat betekent dat de extra functieaanroep niet noodzakelijkerwijs minimaal is. We moeten hier wel voorzichtig mee zijn; dit zijn zeer kleine getallen en we moeten waken voor optimalisaties die de compiler maakt, zeker omdat deze in dit geval volledige informatie heeft over de instructies. Desondanks lijken de cijfers overeen te komen met wat we zouden verwachten op moderne hardware.
Verdere Experimenten
Hoewel deze technieken (direct vertaald van Scala naar onorthodox Rust) interessant zijn, passen ze niet echt bij de stijl van mijn ternary VM. Mijn VM's gedragen zich meer als werkelijke hardware, omdat het doel is om theoretische ternary-hardware te simuleren. Dit betekent een complexere decoding-stap en registers. Daarom heb ik een zeer beperkte 16-bit register-machine gemaakt om dit in condities te testen die dichter bij mijn uiteindelijke use case liggen.
De machine is simpel:
pub struct Machine {
regs: [u16; 16],
instrs: [Instr; 256],
ip: usize,
}
pub enum Op {
Halt = 0b0000,
Add = 0b0001,
Sub = 0b0010,
Mul = 0b0011,
Div = 0b0100,
Bgt = 0b0101,
Bleq = 0b0110,
}
Instruction Encoding:
12 8 4 0
┌─────┬────┬────┬────┐
│ IMM │ R1 │ RD │ OP │
└─────┴────┴────┴────┘
Switch Dispatch
Switch dispatch is wat je zou verwachten, maar dan inclusief een grotere decode-fase. Daarnaast hebben we de instruction pointer geïntegreerd in de machine, waardoor de onfortuinlijke static mut uit de vorige fase wordt vermeden.
pub fn run(machine: &mut Machine) {
loop {
let instr = machine.instrs[machine.ip];
let rd = instr.rd() as usize;
let r1 = instr.r1() as usize;
let rdv = machine.regs[rd];
let r1v = machine.regs[r1];
let imm = instr.imm();
machine.ip = match instr.op() {
Op::Halt => return,
Op::Add => {
machine.regs[rd] = rdv + (r1v + imm);
machine.ip + 1
},
Op::Sub => {
machine.regs[rd] = rdv - (r1v + imm);
machine.ip + 1
},
Op::Mul => {
machine.regs[rd] = rdv * (r1v + imm);
machine.ip + 1
},
Op::Div => {
machine.regs[rd] = rdv / (r1v + imm);
machine.ip + 1
},
Op::Bgt => {
if rdv > r1v {
imm as usize
} else {
machine.ip + 1
}
},
Op::Bleq => {
if rdv <= r1v {
imm as usize
} else {
machine.ip + 1
}
},
};
}
}
Dit houdt het simpel en is redelijk voor een klein prototype, maar qua prestaties scoort het minder.
Subroutine Dispatch
We kunnen nu handmatige closures vermijden omdat we de decoding-stap direct omarmen. We gebruiken nu een statische array van handlers:
type Handler = fn(&mut Machine, instr: Instr) -> usize;
static HANDLERS: [Handler; 7] = [
halt, // Halt = 0b0000,
add, // Add = 0b0001,
sub, // Sub = 0b0010,
mul, // Mul = 0b0011,
div, // Div = 0b0100,
bgt, // Bgt = 0b0101,
blq, // Bleq = 0b0110,
];
#[inline(always)]
fn decode(instr: Instr) -> (usize, u16, u16, u16) {
let rd = instr.rd() as usize;
let r1 = instr.r1() as usize;
let imm = instr.imm();
let r1v = machine.regs[r1];
let rdv = machine.regs[rd];
(rd, imm, r1v, rdv)
}
fn add(machine: &mut Machine, instr: Instr) -> usize {
let (rd, imm, r1v, rdv) = decode(instr);
machine.regs[rd] = rdv.wrapping_add(r1v.wrapping_add(imm));
machine.ip + 1
}
fn sub(machine: &mut Machine, instr: Instr) -> usize {
let (rd, imm, r1v, rdv) = decode(instr);
machine.regs[rd] = rdv.wrapping_sub(r1v.wrapping_add(imm));
machine.ip + 1
}
fn mul(machine: &mut Machine, instr: Instr) -> usize {
let (rd, imm, r1v, rdv) = decode(instr);
machine.regs[rd] = rdv.wrapping_mul(r1v.wrapping_add(imm));
machine.ip + 1
}
fn div(machine: &mut Machine, instr: Instr) -> usize {
let (rd, imm, r1v, rdv) = decode(instr);
machine.regs[rd] = rdv.wrapping_div(r1v.wrapping_add(imm));
machine.ip + 1
}
fn bgt(machine: &mut Machine, instr: Instr) -> usize {
let (rd, imm, r1v, rdv) = decode(instr);
if rdv > r1v {
imm as usize
} else {
machine.ip + 1
}
}
fn blq(machine: &mut Machine, instr: Instr) -> usize {
let (rd, imm, r1v, rdv) = decode(instr);
if rdv <= r1v {
imm as usize
} else {
machine.ip + 1
}
}
fn halt(_machine: &mut Machine, _instr: Instr) -> usize {
return 0;
}
pub fn dispatch(machine: &mut Machine) {
let instr = machine.instrs[machine.ip];
if let Op::Halt = instr.op() {
return;
}
machine.ip = HANDLERS[instr.op() as usize](machine, instr);
become dispatch(machine);
}
Indirect Dispatch
fn add(machine: &mut Machine, instr: Instr) {
let (rd, imm, r1v, rdv) = decode(instr);
machine.regs[rd] = rdv.wrapping_add(r1v.wrapping_add(imm));
machine.ip += 1;
let instr = machine.instrs[machine.ip];
become dispatch(machine, instr);
}
fn sub(machine: &mut Machine, instr: Instr) {
let (rd, imm, r1v, rdv) = decode(instr);
machine.regs[rd] = rdv.wrapping_sub(r1v.wrapping_add(imm));
machine.ip += 1;
let instr = machine.instrs[machine.ip];
become dispatch(machine, instr);
}
fn mul(machine: &mut Machine, instr: Instr) {
let (rd, imm, r1v, rdv) = decode(instr);
machine.regs[rd] = rdv.wrapping_mul(r1v.wrapping_add(imm));
machine.ip += 1;
let instr = machine.instrs[machine.ip];
become dispatch(machine, instr);
}
fn div(machine: &mut Machine, instr: Instr) {
let (rd, imm, r1v, rdv) = decode(instr);
machine.regs[rd] = rdv.wrapping_div(r1v.wrapping_add(imm));
machine.ip += 1;
let instr = machine.instrs[machine.ip];
become dispatch(machine, instr);
}
fn bgt(machine: &mut Machine, instr: Instr) {
let (rd, imm, r1v, rdv) = decode(instr);
if rdv > r1v { machine.ip = imm as usize } else { machine.ip += 1 }
let instr = machine.instrs[machine.ip];
become dispatch(machine, instr);
}
fn blq(machine: &mut Machine, instr: Instr) {
let (rd, imm, r1v, rdv) = decode(instr);
if rdv <= r1v { machine.ip = imm as usize } else { machine.ip += 1 }
let instr = machine.instrs[machine.ip];
become dispatch(machine, instr);
}
fn hlt(_machine: &mut Machine, _instr: Instr) {
return;
}
fn dispatch(machine: &mut Machine, instr: Instr) {
match instr.op() {
Op::Halt => become hlt(machine, instr),
Op::Add => become add(machine, instr),
Op::Sub => become sub(machine, instr),
Op::Mul => become mul(machine, instr),
Op::Div => become div(machine, instr),
Op::Bgt => become bgt(machine, instr),
Op::Bleq => become blq(machine, instr),
}
}
Hier verwijderen we de HANDLERS static en gebruiken we in plaats daarvan een dispatch-functie om onze instructies via tail-calls aan te roepen. De instructies roepen op hun beurt dispatch weer aan, wat ons event-loop vormt. Wanneer we halten, kunnen we simpelweg returnen.
Direct Dispatch
Ten slotte combineren we de twee strategieën voor direct dispatch:
type Handler = fn(&mut Machine, instr: Instr);
static HANDLERS: [Handler; 8] = [
halt, // Halt = 0b0000,
add, // Add = 0b0001,
sub, // Sub = 0b0010,
mul, // Mul = 0b0011,
div, // Div = 0b0100,
bgt, // Bgt = 0b0101,
blq, // Bleq = 0b0110,
];
fn add(machine: &mut Machine, instr: Instr) {
let (rd, imm, r1v, rdv) = decode(instr);
machine.regs[rd] = rdv.wrapping_add(r1v.wrapping_add(imm));
machine.ip += 1;
let instr = machine.instrs[machine.ip];
become HANDLERS[instr.op() as usize](machine, instr)
}
fn sub(machine: &mut Machine, instr: Instr) {
let (rd, imm, r1v, rdv) = decode(instr);
machine.regs[rd] = rdv.wrapping_sub(r1v.wrapping_add(imm));
machine.ip += 1;
let instr = machine.instrs[machine.ip];
become HANDLERS[instr.op() as usize](machine, instr)
}
fn mul(machine: &mut Machine, instr: Instr) {
let (rd, imm, r1v, rdv) = decode(instr);
machine.regs[rd] = rdv.wrapping_mul(r1v.wrapping_add(imm));
machine.ip += 1;
let instr = machine.instrs[machine.ip];
become HANDLERS[instr.op() as usize](machine, instr)
}
fn div(machine: &mut Machine, instr: Instr) {
let (rd, imm, r1v, rdv) = decode(instr);
machine.regs[rd] = rdv.div_euclid(r1v.wrapping_add(imm));
machine.ip += 1;
let instr = machine.instrs[machine.ip];
become HANDLERS[instr.op() as usize](machine, instr)
}
fn bgt(machine: &mut Machine, instr: Instr) {
let (rd, imm, r1v, rdv) = decode(instr);
if rdv > r1v {
machine.ip = imm as usize
} else {
machine.ip += 1
}
let instr = machine.instrs[machine.ip];
become HANDLERS[instr.op() as usize](machine, instr)
}
fn blq(machine: &mut Machine, instr: Instr) {
let (rd, imm, r1v, rdv) = decode(instr);
if rdv <= r1v {
machine.ip = imm as usize
} else {
machine.ip += 1
}
let instr = machine.instrs[machine.ip];
become HANDLERS[instr.op() as usize](machine, instr)
}
fn halt(_machine: &mut Machine, _instr: Instr) {
return;
}
Benchmarks (Register Machine)
De benchmarks bevestigen dit resultaat:
test bench::direct_machine... bench: 62.87 ns/iter (+/- 1.32)test bench::indirect_machine... bench: 115.00 ns/iter (+/- 0.96)test bench::subroutine_machine... bench: 183.29 ns/iter (+/- 12.05)test bench::switch_machine... bench: 96.35 ns/iter (+/- 2.20)
Direct dispatch wint overduidelijk. Interessant is dat switch nu beter scoort dan indirect. Hoewel ik niet precies weet waarom, vermoed ik dat dit komt door de nieuwe branch-instructies of het gebruikte programma.
Geavanceerde Control Flow
Tijdens mijn zoektocht stuitte ik op de architectuur die wordt gebruikt voor de wasm3 virtual machine. Hierin werd besproken dat elk codeblok zijn eigen dispatch-functie kan aanroepen. Bij een stack-machine betekent dit dat de lexicale scope exact overeenkomt met de werkelijke scope. Dit is zeer handig en stelt ons in staat om bepaalde soorten control flow, waaronder exceptions, triviaal te implementeren.
Er is hier ook een mooie synergie met Rust; de ? operator en drop maken het mogelijk om zeer interessante control flow-structuren te vormen.
Conclusie
Zodra je aan deze methoden gewend bent, voelen ze heel natuurlijk aan. Met expliciete tail-calling en de voordelen voor control flow is er eigenlijk geen reden meer om switch dispatch te gebruiken. Voor mijn specifieke use case maakt het feit dat instructies de dispatch-functie recursief kunnen aanroepen (zonder tail-call recursie, waarbij de status op de stack wordt bewaard) het zeer eenvoudig om trap handling en geprivilegieerde uitvoering te implementeren, met vrijwel geen risico op een stack overflow.
Groetjes,