beginning of haku2: a reimplementation of haku in Zig
the goal is to rewrite haku completely, starting with the VM---because it was the most obvious point of improvement the reason is because Rust is kinda too verbose for low level stuff like this. compare the line numbers between haku1 and haku2's VM and how verbose those lines are, and it's kind of an insane difference it also feels like Zig's compilation model can work better for small wasm binary sizes and of course, I also just wanted an excuse to try out Zig :3
This commit is contained in:
parent
598c0348f6
commit
01d4514a65
19 changed files with 1946 additions and 11 deletions
458
crates/haku2/src/vm.zig
Normal file
458
crates/haku2/src/vm.zig
Normal file
|
@ -0,0 +1,458 @@
|
|||
const std = @import("std");
|
||||
const mem = std.mem;
|
||||
const testAllocator = std.testing.allocator;
|
||||
|
||||
const bytecode = @import("bytecode.zig");
|
||||
const system = @import("system.zig");
|
||||
const value = @import("value.zig");
|
||||
const Value = value.Value;
|
||||
|
||||
const Vm = @This();
|
||||
|
||||
stack: []Value,
|
||||
stack_top: u32 = 0,
|
||||
call_stack: []CallFrame,
|
||||
call_stack_top: u32 = 0,
|
||||
defs: []Value,
|
||||
fuel: u32,
|
||||
exception_buffer: [1024]u8 = [_]u8{0} ** 1024, // buffer for exception message
|
||||
exception: ?Exception = null,
|
||||
|
||||
pub const Limits = struct {
|
||||
stack_capacity: usize = 256,
|
||||
call_stack_capacity: usize = 256,
|
||||
fuel: u32 = 63336,
|
||||
};
|
||||
|
||||
pub const CallFrame = struct {
|
||||
closure: *const value.Closure,
|
||||
ip: [*]const u8,
|
||||
bottom: u32,
|
||||
};
|
||||
|
||||
pub const Exception = struct {
|
||||
message: []const u8,
|
||||
};
|
||||
|
||||
/// All errors coming from inside the VM get turned into a single Exception type, which signals
|
||||
/// any kind of error that prevented the machine from running any further.
|
||||
/// Details about the exception can be found in the VM's `exception` field.
|
||||
pub const Error = error{Exception};
|
||||
|
||||
// NOTE: A VM is only ever initialized. There is no function for deallocating its resources.
|
||||
// The intent is to use it with a freshly-reset Scratch, and deallocate its resources through that.
|
||||
pub fn init(a: mem.Allocator, defs: *const bytecode.Defs, limits: *const Limits) !Vm {
|
||||
return .{
|
||||
.stack = try a.alloc(Value, limits.stack_capacity),
|
||||
.call_stack = try a.alloc(CallFrame, limits.call_stack_capacity),
|
||||
.defs = try a.alloc(Value, defs.num_defs),
|
||||
.fuel = limits.fuel,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn throw(vm: *Vm, comptime fmt: []const u8, args: anytype) Error {
|
||||
const message = std.fmt.bufPrint(vm.exception_buffer[0..], fmt, args) catch {
|
||||
vm.exception = .{ .message = "[exception message is too long; format string: " ++ fmt ++ "]" };
|
||||
return error.Exception;
|
||||
};
|
||||
vm.exception = .{ .message = message };
|
||||
return error.Exception;
|
||||
}
|
||||
|
||||
pub fn outOfMemory(vm: *Vm) Error {
|
||||
return vm.throw("out of memory", .{});
|
||||
}
|
||||
|
||||
/// Debug assertion for bytecode validity.
|
||||
/// In future versions, this may become disabled in release builds.
|
||||
fn validateBytecode(vm: *Vm, ok: bool, comptime fmt: []const u8, args: anytype) Error!void {
|
||||
if (!ok) {
|
||||
return vm.throw("corrupted bytecode: " ++ fmt, args);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn consumeFuel(vm: *Vm, fuel: *u32, amount: u32) Error!void {
|
||||
const new_fuel, const overflow = @subWithOverflow(fuel.*, amount);
|
||||
if (overflow > 0) {
|
||||
return vm.throw("code ran for too long (out of fuel!)", .{});
|
||||
}
|
||||
fuel.* = new_fuel;
|
||||
}
|
||||
|
||||
pub fn push(vm: *Vm, val: Value) Error!void {
|
||||
if (vm.stack_top >= vm.stack.len) {
|
||||
return vm.throw("too many live temporary values (local variables and expression operands)", .{});
|
||||
}
|
||||
vm.stack[vm.stack_top] = val;
|
||||
vm.stack_top += 1;
|
||||
}
|
||||
|
||||
pub fn pop(vm: *Vm) Error!Value {
|
||||
try vm.validateBytecode(vm.stack_top > 0, "value stack underflow", .{});
|
||||
vm.stack_top -= 1;
|
||||
return vm.stack[vm.stack_top];
|
||||
}
|
||||
|
||||
pub fn pushCall(vm: *Vm, frame: CallFrame) Error!void {
|
||||
if (vm.call_stack_top >= vm.call_stack.len) {
|
||||
return vm.throw("too much recursion", .{});
|
||||
}
|
||||
vm.call_stack[vm.call_stack_top] = frame;
|
||||
vm.call_stack_top += 1;
|
||||
}
|
||||
|
||||
pub fn popCall(vm: *Vm) Error!CallFrame {
|
||||
try vm.validateBytecode(vm.call_stack_top > 0, "call stack underflow", .{});
|
||||
vm.call_stack_top -= 1;
|
||||
return vm.call_stack[vm.call_stack_top];
|
||||
}
|
||||
|
||||
pub fn local(vm: *Vm, bottom: u32, offset: u8) Error!*Value {
|
||||
const index = bottom + offset;
|
||||
try vm.validateBytecode(index < vm.stack_top, "stack index out of bounds", .{});
|
||||
return &vm.stack[bottom + offset];
|
||||
}
|
||||
|
||||
pub fn def(vm: *Vm, index: u16) Error!*Value {
|
||||
try vm.validateBytecode(index < vm.defs.len, "def index out of bounds", .{});
|
||||
return &vm.defs[index];
|
||||
}
|
||||
|
||||
// Utility struct for storing and restoring the VM's state variables across FFI boundaries.
|
||||
const Context = struct {
|
||||
fuel: u32,
|
||||
};
|
||||
|
||||
fn restoreContext(vm: *Vm) Context {
|
||||
return .{
|
||||
.fuel = vm.fuel,
|
||||
};
|
||||
}
|
||||
|
||||
fn storeContext(vm: *Vm, context: Context) void {
|
||||
vm.fuel = context.fuel;
|
||||
}
|
||||
|
||||
inline fn read(comptime T: type, ip: *[*]const u8) T {
|
||||
const result = mem.readInt(T, ip.*[0..@sizeOf(T)], std.builtin.Endian.little);
|
||||
ip.* = ip.*[@sizeOf(T)..];
|
||||
return result;
|
||||
}
|
||||
|
||||
inline fn readOpcode(ip: *[*]const u8) bytecode.Opcode {
|
||||
return @enumFromInt(read(u8, ip));
|
||||
}
|
||||
|
||||
/// Before calling this, vm.stack_top should be saved and the appropriate amount of parameters must
|
||||
/// be pushed onto the stack to execute the provided closure.
|
||||
/// The saved vm.stack_top must be passed to init_bottom.
|
||||
///
|
||||
/// allocator should be a scratch buffer; there is no way to free the memory allocated by a VM.
|
||||
pub fn run(
|
||||
vm: *Vm,
|
||||
allocator: mem.Allocator,
|
||||
init_closure: *const value.Closure,
|
||||
init_bottom: u32,
|
||||
) Error!void {
|
||||
var closure = init_closure;
|
||||
var ip: [*]const u8 = closure.chunk.bytecode[closure.start..].ptr;
|
||||
var bottom = init_bottom;
|
||||
var fuel = vm.fuel;
|
||||
|
||||
for (0..closure.local_count) |_| {
|
||||
try vm.push(.nil);
|
||||
}
|
||||
|
||||
const call_bottom = vm.call_stack_top;
|
||||
try vm.pushCall(.{
|
||||
.closure = closure,
|
||||
.ip = ip,
|
||||
.bottom = bottom,
|
||||
});
|
||||
|
||||
next: switch (readOpcode(&ip)) {
|
||||
.nil => {
|
||||
try vm.consumeFuel(&fuel, 1);
|
||||
try vm.push(.nil);
|
||||
continue :next readOpcode(&ip);
|
||||
},
|
||||
|
||||
.false => {
|
||||
try vm.consumeFuel(&fuel, 1);
|
||||
try vm.push(.false);
|
||||
continue :next readOpcode(&ip);
|
||||
},
|
||||
|
||||
.true => {
|
||||
try vm.consumeFuel(&fuel, 1);
|
||||
try vm.push(.true);
|
||||
continue :next readOpcode(&ip);
|
||||
},
|
||||
|
||||
.tag => {
|
||||
try vm.consumeFuel(&fuel, 1);
|
||||
const tag_id: value.TagId = @enumFromInt(read(u16, &ip));
|
||||
try vm.push(.{ .tag = tag_id });
|
||||
continue :next readOpcode(&ip);
|
||||
},
|
||||
|
||||
.number => {
|
||||
try vm.consumeFuel(&fuel, 1);
|
||||
const number: f32 = @bitCast(read(u32, &ip));
|
||||
try vm.push(.{ .number = number });
|
||||
continue :next readOpcode(&ip);
|
||||
},
|
||||
|
||||
.rgba => {
|
||||
try vm.consumeFuel(&fuel, 1);
|
||||
const r = read(u8, &ip);
|
||||
const g = read(u8, &ip);
|
||||
const b = read(u8, &ip);
|
||||
const a = read(u8, &ip);
|
||||
try vm.push(.{ .rgba = value.rgbaFrom8(value.Rgba8{ r, g, b, a }) });
|
||||
continue :next readOpcode(&ip);
|
||||
},
|
||||
|
||||
.local => {
|
||||
try vm.consumeFuel(&fuel, 1);
|
||||
const index = read(u8, &ip);
|
||||
const l = try vm.local(bottom, index);
|
||||
try vm.push(l.*);
|
||||
continue :next readOpcode(&ip);
|
||||
},
|
||||
|
||||
.set_local => {
|
||||
try vm.consumeFuel(&fuel, 1);
|
||||
const index = read(u8, &ip);
|
||||
const new = try vm.pop();
|
||||
const l = try vm.local(bottom, index);
|
||||
l.* = new;
|
||||
continue :next readOpcode(&ip);
|
||||
},
|
||||
|
||||
.capture => {
|
||||
try vm.consumeFuel(&fuel, 1);
|
||||
const index = read(u8, &ip);
|
||||
try vm.validateBytecode(index < closure.captures.len, "capture index out of bounds", .{});
|
||||
const capture = closure.captures[index];
|
||||
try vm.push(capture);
|
||||
continue :next readOpcode(&ip);
|
||||
},
|
||||
|
||||
.def => {
|
||||
try vm.consumeFuel(&fuel, 1);
|
||||
const index = read(u16, &ip);
|
||||
const d = try vm.def(index);
|
||||
try vm.push(d.*);
|
||||
continue :next readOpcode(&ip);
|
||||
},
|
||||
|
||||
.set_def => {
|
||||
try vm.consumeFuel(&fuel, 1);
|
||||
const index = read(u16, &ip);
|
||||
const new = try vm.pop();
|
||||
const d = try vm.def(index);
|
||||
d.* = new;
|
||||
continue :next readOpcode(&ip);
|
||||
},
|
||||
|
||||
.list => {
|
||||
const len = read(u16, &ip);
|
||||
try vm.consumeFuel(&fuel, len);
|
||||
const list_end = vm.stack_top;
|
||||
vm.stack_top -= len;
|
||||
const elements = vm.stack[vm.stack_top..list_end];
|
||||
const list = allocator.dupe(Value, elements) catch return vm.outOfMemory();
|
||||
const ref = allocator.create(value.Ref) catch return vm.outOfMemory();
|
||||
ref.* = .{ .list = list };
|
||||
try vm.push(.{ .ref = ref });
|
||||
continue :next readOpcode(&ip);
|
||||
},
|
||||
|
||||
.function => {
|
||||
try vm.consumeFuel(&fuel, 1);
|
||||
const param_count = read(u8, &ip);
|
||||
const then = read(u16, &ip);
|
||||
const body = ip;
|
||||
ip = closure.chunk.bytecode[then..].ptr;
|
||||
|
||||
const local_count = read(u8, &ip);
|
||||
const capture_count = read(u8, &ip);
|
||||
|
||||
const captures = allocator.alloc(Value, capture_count) catch return vm.outOfMemory();
|
||||
for (captures) |*capture| {
|
||||
const capture_kind = read(u8, &ip);
|
||||
const index = read(u8, &ip);
|
||||
capture.* = switch (capture_kind) {
|
||||
bytecode.capture_local => (try vm.local(bottom, index)).*,
|
||||
bytecode.capture_capture => blk: {
|
||||
try vm.validateBytecode(index < closure.captures.len, "capture index out of bounds", .{});
|
||||
break :blk closure.captures[index];
|
||||
},
|
||||
else => .nil,
|
||||
};
|
||||
}
|
||||
|
||||
const new_closure = value.Closure{
|
||||
.chunk = closure.chunk,
|
||||
.start = @truncate(body - closure.chunk.bytecode.ptr),
|
||||
.param_count = param_count,
|
||||
.local_count = local_count,
|
||||
.captures = captures,
|
||||
};
|
||||
const ref = allocator.create(value.Ref) catch return vm.outOfMemory();
|
||||
ref.* = .{ .closure = new_closure };
|
||||
try vm.push(.{ .ref = ref });
|
||||
|
||||
continue :next readOpcode(&ip);
|
||||
},
|
||||
|
||||
.jump => {
|
||||
try vm.consumeFuel(&fuel, 1);
|
||||
const offset = read(u16, &ip);
|
||||
ip = closure.chunk.bytecode[offset..].ptr;
|
||||
continue :next readOpcode(&ip);
|
||||
},
|
||||
|
||||
.jump_if_not => {
|
||||
try vm.consumeFuel(&fuel, 1);
|
||||
const offset = read(u16, &ip);
|
||||
const condition = try vm.pop();
|
||||
if (!condition.isTruthy()) {
|
||||
ip = closure.chunk.bytecode[offset..].ptr;
|
||||
}
|
||||
continue :next readOpcode(&ip);
|
||||
},
|
||||
|
||||
.field => {
|
||||
try vm.consumeFuel(&fuel, 1);
|
||||
|
||||
const tag_count = read(u8, &ip);
|
||||
const field_tag = try vm.pop();
|
||||
if (field_tag != .tag) {
|
||||
return vm.throw("name of data field to look up must be a tag (starting with an uppercase letter)", .{});
|
||||
}
|
||||
const field_tag_id = field_tag.tag;
|
||||
|
||||
var found_index: ?usize = null;
|
||||
for (0..tag_count) |i| {
|
||||
const tag_id: value.TagId = @enumFromInt(read(u16, &ip));
|
||||
if (tag_id == field_tag_id) {
|
||||
found_index = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (found_index) |index| {
|
||||
try vm.validateBytecode(index < closure.captures.len, "field index out of bounds", .{});
|
||||
const field = closure.captures[index];
|
||||
try vm.push(field);
|
||||
} else {
|
||||
return vm.throw("field with this name does not exist", .{});
|
||||
}
|
||||
|
||||
continue :next readOpcode(&ip);
|
||||
},
|
||||
|
||||
.call => {
|
||||
try vm.consumeFuel(&fuel, 1);
|
||||
|
||||
const arg_count = read(u8, &ip);
|
||||
|
||||
const function_value = try vm.pop();
|
||||
if (function_value != .ref or function_value.ref.* != .closure) {
|
||||
return vm.throw("attempt to call a value that is not a function", .{});
|
||||
}
|
||||
const called_closure = &function_value.ref.closure;
|
||||
|
||||
if (arg_count != called_closure.param_count) {
|
||||
return vm.throw(
|
||||
"function expects {} arguments, but it received {}",
|
||||
.{ called_closure.param_count, arg_count },
|
||||
);
|
||||
}
|
||||
|
||||
const call_frame = CallFrame{
|
||||
.closure = closure,
|
||||
.ip = ip,
|
||||
.bottom = bottom,
|
||||
};
|
||||
|
||||
const new_bottom, const overflow = @subWithOverflow(vm.stack_top, arg_count);
|
||||
try vm.validateBytecode(overflow == 0, "not enough values on the stack for arguments", .{});
|
||||
|
||||
closure = called_closure;
|
||||
ip = closure.chunk.bytecode[closure.start..].ptr;
|
||||
bottom = new_bottom;
|
||||
|
||||
for (0..closure.local_count) |_| {
|
||||
try vm.push(.nil);
|
||||
}
|
||||
|
||||
try vm.pushCall(call_frame);
|
||||
|
||||
continue :next readOpcode(&ip);
|
||||
},
|
||||
|
||||
.system => {
|
||||
try vm.consumeFuel(&fuel, 1);
|
||||
|
||||
const index = read(u8, &ip);
|
||||
const arg_count = read(u8, &ip);
|
||||
const system_fn = system.fns[index];
|
||||
|
||||
vm.storeContext(.{ .fuel = fuel });
|
||||
const result = try system_fn(.{
|
||||
.vm = vm,
|
||||
.allocator = allocator,
|
||||
.args = vm.stack[vm.stack_top - arg_count ..],
|
||||
});
|
||||
const context = vm.restoreContext();
|
||||
fuel = context.fuel;
|
||||
|
||||
vm.stack_top -= arg_count;
|
||||
try vm.push(result);
|
||||
|
||||
continue :next readOpcode(&ip);
|
||||
},
|
||||
|
||||
.ret => {
|
||||
try vm.consumeFuel(&fuel, 1);
|
||||
|
||||
const result = try vm.pop();
|
||||
const call_frame = try vm.popCall();
|
||||
|
||||
try vm.validateBytecode(
|
||||
bottom <= vm.stack_top,
|
||||
"called function popped too many values. bottom={} stack_top={}",
|
||||
.{ bottom, vm.stack_top },
|
||||
);
|
||||
try vm.validateBytecode(
|
||||
call_bottom <= vm.call_stack_top,
|
||||
"called function popped too many call frames. call_bottom={} call_stack_top={}",
|
||||
.{ call_bottom, vm.call_stack_top },
|
||||
);
|
||||
|
||||
vm.stack_top = bottom;
|
||||
try vm.push(result);
|
||||
|
||||
if (vm.call_stack_top == call_bottom) {
|
||||
// If this is the last call frame from this `run` instance, return from the function.
|
||||
vm.storeContext(Context{ .fuel = fuel });
|
||||
break :next;
|
||||
}
|
||||
|
||||
closure = call_frame.closure;
|
||||
ip = call_frame.ip;
|
||||
bottom = call_frame.bottom;
|
||||
|
||||
continue :next readOpcode(&ip);
|
||||
},
|
||||
|
||||
_ => |invalid_opcode| {
|
||||
try vm.consumeFuel(&fuel, 1);
|
||||
// NOTE: Not a validateBytecode call because this is zero-cost on the happy path,
|
||||
// so we don't need to disable it on release builds.
|
||||
return vm.throw("corrupted bytecode (invalid opcode {})", .{invalid_opcode});
|
||||
},
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue