tvl-depot/tvix/eval/src/observer.rs

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

273 lines
8.8 KiB
Rust
Raw Normal View History

//! Implements traits for things that wish to observe internal state
//! changes of tvix-eval.
//!
//! This can be used to gain insights from compilation, to trace the
//! runtime, and so on.
//!
//! All methods are optional, that is, observers can implement only
/// what they are interested in observing.
use std::io::Write;
use std::rc::Rc;
use tabwriter::TabWriter;
use crate::chunk::Chunk;
refactor(tvix/eval): flatten call stack of VM using generators Warning: This is probably the biggest refactor in tvix-eval history, so far. This replaces all instances of trampolines and recursion during evaluation of the VM loop with generators. A generator is an asynchronous function that can be suspended to yield a message (in our case, vm::generators::GeneratorRequest) and receive a response (vm::generators::GeneratorResponsee). The `genawaiter` crate provides an interpreter for generators that can drive their execution and lets us move control flow between the VM and suspended generators. To do this, massive changes have occured basically everywhere in the code. On a high-level: 1. The VM is now organised around a frame stack. A frame is either a call frame (execution of Tvix bytecode) or a generator frame (a running or suspended generator). The VM has an outer loop that pops a frame off the frame stack, and then enters an inner loop either driving the execution of the bytecode or the execution of a generator. Both types of frames have several branches that can result in the frame re-enqueuing itself, and enqueuing some other work (in the form of a different frame) on top of itself. The VM will eventually resume the frame when everything "above" it has been suspended. In this way, the VM's new frame stack takes over much of the work that was previously achieved by recursion. 2. All methods previously taking a VM have been refactored into async functions that instead emit/receive generator messages for communication with the VM. Notably, this includes *all* builtins. This has had some other effects: - Some test have been removed or commented out, either because they tested code that was mostly already dead (nix_eq) or because they now require generator scaffolding which we do not have in place for tests (yet). - Because generator functions are technically async (though no async IO is involved), we lose the ability to use much of the Rust standard library e.g. in builtins. This has led to many algorithms being unrolled into iterative versions instead of iterator combinations, and things like sorting had to be implemented from scratch. - Many call sites that previously saw a `Result<..., ErrorKind>` bubble up now only see the result value, as the error handling is encapsulated within the generator loop. This reduces number of places inside of builtin implementations where error context can be attached to calls that can fail. Currently what we gain in this tradeoff is significantly more detailed span information (which we still need to bubble up, this commit does not change the error display). We'll need to do some analysis later of how useful the errors turn out to be and potentially introduce some methods for attaching context to a generator frame again. This change is very difficult to do in stages, as it is very much an "all or nothing" change that affects huge parts of the codebase. I've tried to isolate changes that can be isolated into the parent CLs of this one, but this change is still quite difficult to wrap one's mind and I'm available to discuss it and explain things to any reviewer. Fixes: b/238, b/237, b/251 and potentially others. Change-Id: I39244163ff5bbecd169fe7b274df19262b515699 Reviewed-on: https://cl.tvl.fyi/c/depot/+/8104 Reviewed-by: raitobezarius <tvl@lahfa.xyz> Reviewed-by: Adam Joseph <adam@westernsemico.com> Tested-by: BuildkiteCI
2023-02-14 13:02:39 +01:00
use crate::generators::GeneratorRequest;
use crate::opcode::{CodeIdx, OpCode};
use crate::value::Lambda;
use crate::SourceCode;
use crate::Value;
/// Implemented by types that wish to observe internal happenings of
/// the Tvix compiler.
pub trait CompilerObserver {
/// Called when the compiler finishes compilation of the top-level
/// of an expression (usually the root Nix expression of a file).
fn observe_compiled_toplevel(&mut self, _: &Rc<Lambda>) {}
/// Called when the compiler finishes compilation of a
/// user-defined function.
///
/// Note that in Nix there are only single argument functions, so
/// in an expression like `a: b: c: ...` this method will be
/// called three times.
fn observe_compiled_lambda(&mut self, _: &Rc<Lambda>) {}
/// Called when the compiler finishes compilation of a thunk.
fn observe_compiled_thunk(&mut self, _: &Rc<Lambda>) {}
}
/// Implemented by types that wish to observe internal happenings of
/// the Tvix virtual machine at runtime.
pub trait RuntimeObserver {
/// Called when the runtime enters a new call frame.
fn observe_enter_call_frame(&mut self, _arg_count: usize, _: &Rc<Lambda>, _call_depth: usize) {}
/// Called when the runtime exits a call frame.
fn observe_exit_call_frame(&mut self, _frame_at: usize, _stack: &[Value]) {}
/// Called when the runtime suspends a call frame.
fn observe_suspend_call_frame(&mut self, _frame_at: usize, _stack: &[Value]) {}
/// Called when the runtime enters a generator frame.
fn observe_enter_generator(&mut self, _frame_at: usize, _stack: &[Value]) {}
/// Called when the runtime exits a generator frame.
fn observe_exit_generator(&mut self, _frame_at: usize, _stack: &[Value]) {}
/// Called when the runtime suspends a generator frame.
fn observe_suspend_generator(&mut self, _frame_at: usize, _stack: &[Value]) {}
/// Called when a generator requests an action from the VM.
fn observe_generator_request(&mut self, _msg: &GeneratorRequest) {}
/// Called when the runtime replaces the current call frame for a
/// tail call.
fn observe_tail_call(&mut self, _frame_at: usize, _: &Rc<Lambda>) {}
/// Called when the runtime enters a builtin.
fn observe_enter_builtin(&mut self, _name: &'static str) {}
/// Called when the runtime exits a builtin.
fn observe_exit_builtin(&mut self, _name: &'static str, _stack: &[Value]) {}
/// Called when the runtime *begins* executing an instruction. The
/// provided stack is the state at the beginning of the operation.
fn observe_execute_op(&mut self, _ip: CodeIdx, _: &OpCode, _: &[Value]) {}
}
#[derive(Default)]
pub struct NoOpObserver {}
impl CompilerObserver for NoOpObserver {}
impl RuntimeObserver for NoOpObserver {}
/// An observer that prints disassembled chunk information to its
/// internal writer whenwever the compiler emits a toplevel function,
/// closure or thunk.
pub struct DisassemblingObserver<W: Write> {
source: SourceCode,
writer: TabWriter<W>,
}
impl<W: Write> DisassemblingObserver<W> {
pub fn new(source: SourceCode, writer: W) -> Self {
Self {
source,
writer: TabWriter::new(writer),
}
}
fn lambda_header(&mut self, kind: &str, lambda: &Rc<Lambda>) {
let _ = writeln!(
&mut self.writer,
"=== compiled {} @ {:p} ({} ops) ===",
kind,
*lambda,
lambda.chunk.code.len()
);
}
fn disassemble_chunk(&mut self, chunk: &Chunk) {
// calculate width of the widest address in the chunk
let width = format!("{:#x}", chunk.code.len() - 1).len();
for (idx, _) in chunk.code.iter().enumerate() {
let _ = chunk.disassemble_op(&mut self.writer, &self.source, width, CodeIdx(idx));
}
}
}
impl<W: Write> CompilerObserver for DisassemblingObserver<W> {
fn observe_compiled_toplevel(&mut self, lambda: &Rc<Lambda>) {
self.lambda_header("toplevel", lambda);
self.disassemble_chunk(&lambda.chunk);
let _ = self.writer.flush();
}
fn observe_compiled_lambda(&mut self, lambda: &Rc<Lambda>) {
self.lambda_header("lambda", lambda);
self.disassemble_chunk(&lambda.chunk);
let _ = self.writer.flush();
}
fn observe_compiled_thunk(&mut self, lambda: &Rc<Lambda>) {
self.lambda_header("thunk", lambda);
self.disassemble_chunk(&lambda.chunk);
let _ = self.writer.flush();
}
}
/// An observer that collects a textual representation of an entire
/// runtime execution.
pub struct TracingObserver<W: Write> {
writer: TabWriter<W>,
}
impl<W: Write> TracingObserver<W> {
pub fn new(writer: W) -> Self {
Self {
writer: TabWriter::new(writer),
}
}
fn write_value(&mut self, val: &Value) {
let _ = match val {
// Potentially large types which we only want to print
// the type of (and avoid recursing).
Value::List(l) => write!(&mut self.writer, "list[{}] ", l.len()),
Value::Attrs(a) => write!(&mut self.writer, "attrs[{}] ", a.len()),
Value::Thunk(t) if t.is_evaluated() => Ok(self.write_value(&t.value())),
// For other value types, defer to the standard value printer.
_ => write!(&mut self.writer, "{} ", val),
};
}
fn write_stack(&mut self, stack: &[Value]) {
let _ = write!(&mut self.writer, "[ ");
for val in stack {
self.write_value(&val);
}
let _ = writeln!(&mut self.writer, "]");
}
}
impl<W: Write> RuntimeObserver for TracingObserver<W> {
fn observe_enter_call_frame(
&mut self,
arg_count: usize,
lambda: &Rc<Lambda>,
call_depth: usize,
) {
let _ = write!(&mut self.writer, "=== entering ");
let _ = if arg_count == 0 {
write!(&mut self.writer, "thunk ")
} else {
write!(&mut self.writer, "closure ")
};
if let Some(name) = &lambda.name {
let _ = write!(&mut self.writer, "'{}' ", name);
}
let _ = writeln!(
&mut self.writer,
"in frame[{}] @ {:p} ===",
call_depth, *lambda
);
}
/// Called when the runtime exits a call frame.
fn observe_exit_call_frame(&mut self, frame_at: usize, stack: &[Value]) {
let _ = write!(&mut self.writer, "=== exiting frame {} ===\t ", frame_at);
self.write_stack(stack);
}
fn observe_suspend_call_frame(&mut self, frame_at: usize, stack: &[Value]) {
let _ = write!(&mut self.writer, "=== suspending frame {} ===\t", frame_at);
self.write_stack(stack);
}
fn observe_enter_generator(&mut self, frame_at: usize, stack: &[Value]) {
let _ = write!(
&mut self.writer,
"=== entering generator frame {} ===\t",
frame_at
);
self.write_stack(stack);
}
fn observe_exit_generator(&mut self, frame_at: usize, stack: &[Value]) {
let _ = write!(&mut self.writer, "=== exiting generator {} ===\t", frame_at);
self.write_stack(stack);
}
fn observe_suspend_generator(&mut self, frame_at: usize, stack: &[Value]) {
let _ = write!(
&mut self.writer,
"=== suspending generator {} ===\t",
frame_at
);
self.write_stack(stack);
}
fn observe_generator_request(&mut self, msg: &GeneratorRequest) {
let _ = writeln!(&mut self.writer, "=== generator requested {} ===", msg);
}
fn observe_enter_builtin(&mut self, name: &'static str) {
let _ = writeln!(&mut self.writer, "=== entering builtin {} ===", name);
}
fn observe_exit_builtin(&mut self, name: &'static str, stack: &[Value]) {
let _ = write!(&mut self.writer, "=== exiting builtin {} ===\t", name);
self.write_stack(stack);
}
fn observe_tail_call(&mut self, frame_at: usize, lambda: &Rc<Lambda>) {
let _ = writeln!(
&mut self.writer,
"=== tail-calling {:p} in frame[{}] ===",
*lambda, frame_at
);
}
fn observe_execute_op(&mut self, ip: CodeIdx, op: &OpCode, stack: &[Value]) {
let _ = write!(&mut self.writer, "{:04} {:?}\t", ip.0, op);
self.write_stack(stack);
}
}
impl<W: Write> Drop for TracingObserver<W> {
fn drop(&mut self) {
let _ = self.writer.flush();
}
}