feat(tvix/eval): deduplicate overlap between Closure and Thunk

This commit deduplicates the Thunk-like functionality from Closure
and unifies it with Thunk.

Specifically, we now have one and only one way of breaking reference
cycles in the Value-graph: Thunk.  No other variant contains a
RefCell.  This should make it easier to reason about the behavior of
the VM.  InnerClosure and UpvaluesCarrier are no longer necessary.

This refactoring allowed an improvement in code generation:
`Rc<RefCell<>>`s are now created only for closures which do not have
self-references or deferred upvalues, instead of for all closures.
OpClosure has been split into two separate opcodes:

- OpClosure creates non-recursive closures with no deferred
  upvalues.  The VM will not create an `Rc<RefCell<>>` when executing
  this instruction.

- OpThunkClosure is used for closures with self-references or
  deferred upvalues.  The VM will create a Thunk when executing this
  opcode, but the Thunk will start out already in the
  `ThunkRepr::Evaluated` state, rather than in the
  `ThunkRepr::Suspeneded` state.

To avoid confusion, OpThunk has been renamed OpThunkSuspended.

Thanks to @sterni for suggesting that all this could be done without
adding an additional variant to ThunkRepr.  This does however mean
that there will be mutating accesses to `ThunkRepr::Evaluated`,
which was not previously the case.  The field `is_finalised:bool`
has been added to `Closure` to ensure that these mutating accesses
are performed only on finalised Closures.  Both the check and the
field are present only if `#[cfg(debug_assertions)]`.

Change-Id: I04131501029772f30e28da8281d864427685097f
Signed-off-by: Adam Joseph <adam@westernsemico.com>
Reviewed-on: https://cl.tvl.fyi/c/depot/+/7019
Tested-by: BuildkiteCI
Reviewed-by: tazjin <tazjin@tvl.su>
This commit is contained in:
Adam Joseph 2022-10-15 16:10:10 -07:00 committed by tazjin
parent c91d86ee5c
commit d978b556e6
9 changed files with 206 additions and 152 deletions

View file

@ -1,7 +1,7 @@
//! This module implements the virtual (or abstract) machine that runs
//! Tvix bytecode.
use std::{cell::RefMut, path::PathBuf, rc::Rc};
use std::{ops::DerefMut, path::PathBuf, rc::Rc};
use crate::{
chunk::Chunk,
@ -9,7 +9,7 @@ use crate::{
nix_search_path::NixSearchPath,
observer::RuntimeObserver,
opcode::{CodeIdx, Count, JumpOffset, OpCode, StackIdx, UpvalueIdx},
upvalues::{UpvalueCarrier, Upvalues},
upvalues::Upvalues,
value::{Builtin, Closure, CoercionKind, Lambda, NixAttrs, NixList, Thunk, Value},
warnings::{EvalWarning, WarningKind},
};
@ -675,30 +675,37 @@ impl<'o> VM<'o> {
upvalue_count > 0,
"OpClosure should not be called for plain lambdas"
);
let closure = Closure::new(blueprint);
let upvalues = closure.upvalues_mut();
self.push(Value::Closure(closure.clone()));
// From this point on we internally mutate the
// closure object's upvalues. The closure is
// already in its stack slot, which means that it
// can capture itself as an upvalue for
// self-recursion.
self.populate_upvalues(upvalue_count, upvalues)?;
let mut upvalues = Upvalues::with_capacity(blueprint.upvalue_count);
self.populate_upvalues(upvalue_count, &mut upvalues)?;
self.push(Value::Closure(Closure::new_with_upvalues(
upvalues, blueprint,
)));
}
OpCode::OpThunk(idx) => {
OpCode::OpThunkSuspended(idx) | OpCode::OpThunkClosure(idx) => {
let blueprint = match &self.chunk()[idx] {
Value::Blueprint(lambda) => lambda.clone(),
_ => panic!("compiler bug: non-blueprint in blueprint slot"),
};
let upvalue_count = blueprint.upvalue_count;
let thunk = Thunk::new(blueprint, self.current_span());
let thunk = if matches!(op, OpCode::OpThunkClosure(_)) {
debug_assert!(
upvalue_count > 0,
"OpThunkClosure should not be called for plain lambdas"
);
Thunk::new_closure(blueprint)
} else {
Thunk::new_suspended(blueprint, self.current_span())
};
let upvalues = thunk.upvalues_mut();
self.push(Value::Thunk(thunk.clone()));
// From this point on we internally mutate the
// upvalues. The closure (if `is_closure`) is
// already in its stack slot, which means that it
// can capture itself as an upvalue for
// self-recursion.
self.populate_upvalues(upvalue_count, upvalues)?;
}
@ -715,13 +722,9 @@ impl<'o> VM<'o> {
OpCode::OpFinalise(StackIdx(idx)) => {
match &self.stack[self.frame().stack_offset + idx] {
Value::Closure(closure) => {
closure.resolve_deferred_upvalues(&self.stack[self.frame().stack_offset..])
}
Value::Closure(_) => panic!("attempted to finalise a closure"),
Value::Thunk(thunk) => {
thunk.resolve_deferred_upvalues(&self.stack[self.frame().stack_offset..])
}
Value::Thunk(thunk) => thunk.finalise(&self.stack[self.frame().stack_offset..]),
// In functions with "formals" attributes, it is
// possible for `OpFinalise` to be called on a
@ -809,21 +812,23 @@ impl<'o> VM<'o> {
fn populate_upvalues(
&mut self,
count: usize,
mut upvalues: RefMut<'_, Upvalues>,
mut upvalues: impl DerefMut<Target = Upvalues>,
) -> EvalResult<()> {
for _ in 0..count {
match self.inc_ip() {
OpCode::DataLocalIdx(StackIdx(local_idx)) => {
let idx = self.frame().stack_offset + local_idx;
upvalues.push(self.stack[idx].clone());
upvalues.deref_mut().push(self.stack[idx].clone());
}
OpCode::DataUpvalueIdx(upv_idx) => {
upvalues.push(self.frame().upvalue(upv_idx).clone());
upvalues
.deref_mut()
.push(self.frame().upvalue(upv_idx).clone());
}
OpCode::DataDeferredLocal(idx) => {
upvalues.push(Value::DeferredUpvalue(idx));
upvalues.deref_mut().push(Value::DeferredUpvalue(idx));
}
OpCode::DataCaptureWith => {
@ -841,7 +846,7 @@ impl<'o> VM<'o> {
captured_with_stack.push(self.stack[*idx].clone());
}
upvalues.set_with_stack(captured_with_stack);
upvalues.deref_mut().set_with_stack(captured_with_stack);
}
_ => panic!("compiler error: missing closure operand"),