-
Notifications
You must be signed in to change notification settings - Fork 120
Flying Roll #1: instructions.rs and the Dispatch Loop
The Flying Roll series of Wiki articles are meant to expose secret knowledge of Scryer Prolog architecture and development. They are especially intended for developers contributing work to Scryer on the Rust side of development though it is hoped they will be helpful to everyone.
The aim of this flying roll is to demystify the process by which
instructions.rs
is generated, what "variants" of each instruction are
produced, and how they modify the operation of the dispatch loop of
dispatch.rs
. It is also meant to guide Scryer Prolog developers on how
to add new instructions.
One of the biggest changes of the second iteration of the rebis
development branch (which have since been merged into the master
branch) is the flattening of the instruction dispatch
loop. Previously, dispatch flowed on the paths set by the tree
decomposition of the Line
enum, the predecessor of rebis-dev's
Instruction
enum. In many cases, the dispatcher would have to branch
its way through a lengthy path to the bottom of the tree before an
instruction could finally be executed.
To reduce runtime branching as much as possible, rebis compresses each
path to a single variant of Instruction
. The dispatch loop now
accomplishes in a single jump what once took a sequence of nested
ifs. The resulting dispatch performance is greatly improved but
maintainability is hurt as we now have to maintain several
"flavours" of each instruction.
This problem is ameliorated by generating both the Instruction
enum
and most of its helper functions programmatically, using procedural
macros from the instructions_template.rs
module. You may notice from
perusing the Scryer Prolog source tree that instructions.rs
isn't
included anywhere. This is because instructions_template.rs
programmatically generates it at build time (and in fact, isn't
linked to the Scryer Prolog executable as a runtime module!).
The unvaried parts of the WAM instruction set are stored in the base
InstructionTemplate
enum of instructions_template.rs
. The remaining
instructions, all of which call predicates implemented in Rust, are
derived fromClauseType
. ClauseType
variants determine the differences
among the instructions they generate:
ClauseType variant | Inference Counted (2) | Meta-callable (1) | Call/Execute (2) |
---|---|---|---|
BuiltInClauseType | ✓ | ✓ | ✓ |
InlinedClauseType | ✗ | ✓ | ✓ |
SystemClauseType | ✗ | ✗ | ✓ |
CallN | ✓ | ✓ | ✓ |
Named | ✓ | ✓ | ✓ |
A clause is inference counted if calling it increments the inference
count tracked in calls made from call_with_inference_limit/3
, meta-callable
if it can called by call/N
, and in execute (call) position if it
is (not) the final predicate of a clause.
The parenthesized numbers next to each column title are variant multipliers factoring into the number of instructions the ClauseType variant produces when the box is ticked in that column.
As an example, BuiltInClauseType::Sort
produces these (2*1*2 =
4) instructions:
enum Instruction {
...
CallSort(usize),
ExecuteSort(usize),
DefaultCallSort(usize),
DefaultExecuteSort(usize),
...
}
"Default" variants toggle inference counting off. They're generated in code by
wrapping the call in the '$call_with_default_policy/1
functor. Call
and Execute
decide whether the next instruction pointer value p
is set by incrementing
the current p
by 1 or by copying the continuation pointer cp
to it.
Reasons for the naming of the ClauseType
variants are hinted in the
table as well. Inlined clause types include semi-deterministic
built-ins like var
, atomic
, number
, etc. They have no need of
"Default" variants because, being inlined, they never contribute to
the inference count. Similarly, SystemClauseType
variants aren't
meta-callable or inference-counted. They follow the naming convention
'$[a-zA-Z\_]+'
to avoid probable collisions with ordinary predicate
names. New variants are usually of the type SystemClauseType
. Named
and CallN
aren't actually variants at all; they represent direct
predicate calls and call/N
appearances in compiled code respectively.
Expanding further on the sort/2
example, we look to how the four
variants are prepared for interpretation as match arms in the dispatch
loop of src/machine/dispatch.rs
.
match &self.machine_st.code[self.machine_st.p] {
&Instruction::CallSort(_) => {
try_or_throw!(self.machine_st, self.machine_st.sort()); // 1
if self.machine_st.fail {
self.machine_st.backtrack();
} else {
try_or_throw!(
self.machine_st,
(self.machine_st.increment_call_count_fn)(&mut self.machine_st) // 2
);
self.machine_st.p += 1; // 3
}
}
&Instruction::ExecuteSort(_) => {
try_or_throw!(self.machine_st, self.machine_st.sort());
if self.machine_st.fail {
self.machine_st.backtrack();
} else {
try_or_throw!(
self.machine_st,
(self.machine_st.increment_call_count_fn)(&mut self.machine_st)
);
self.machine_st.p = self.machine_st.cp; // 4
}
}
&Instruction::DefaultCallSort(_) => {
try_or_throw!(self.machine_st, self.machine_st.sort());
step_or_fail!(self, self.machine_st.p += 1); // 5
}
&Instruction::DefaultExecuteSort(_) => {
try_or_throw!(self.machine_st, self.machine_st.sort());
step_or_fail!(self, self.machine_st.p = self.machine_st.cp); // 5
}
The instruction pointer of the Scryer WAM is always stored as a usize
in
self.machine_st.p
. It indexes into the self.machine_st.code
global code vector,
fetching the next instruction for execution. As with all direct threaded interpreters,
it is the responsibility of each instruction to tell the interpreter from where to fetch
the next instruction by setting self.machine_st.p
.
We discuss the features top-down highlighted by the commented numbers in bold.
-
All four variants of
sort/2
rely on the Rust functionMachineState::sort
for their implementation.MachineState::sort
has return typeCallResult
, which is an alias of the typeResult<(), MachineStub>
.MachineStub
, theErr
variant return type, is a vector ofHeapCellValue
cells representing an error functor. TheOk
variant is the unit type,()
, signifying success and nothing else.The utility macro
try_or_throw!
inspects the return value ofMachineState::sort
. It proceeds normally, to the remaining code of the arm, upon success. Upon failure, it throws the exception inside the Scryer WAM by sending theMachineStub
payload toMachineState::throw_exception
before callingMachineState::backtrack
. -
Since
sort/2
is aBuiltInClauseType
, it is inference counted. SinceMachineState::sort
succeeded in the conventional Prolog sense (by not settingself.machine_st.fail
to true), the inference count must be incremented by calling theself.machine_st.increment_call_count_fn
function pointer. This pointer points to a no-op ifsort/2
was not ultimately called throughcall_with_inference_limit/3
. Whatever its value, this function pointer has return typeCallResult
, so it is again necessary to wrap it intry_or_throw!
so the exception is thrown without evacuating the dispatch loop. -
This variant is of
Call
rather thanExecute
type, which means it is not the final call of its host predicate. Thus,self.machine_st.p
is simply incremented by 1. -
Everything is otherwise the same, but this call to
sort/2
being in last call position means thatself.machine_st.p
must be overwritten by the WAM continuation pointer,self.machine_st.cp
. -
step_or_fail!
is a utility macro taking a single expression$expr
and expanding to this block:
if self.machine_st.fail {
self.machine_st.backtrack();
} else {
$expr
}
A similar pattern is used in cases where the inference count is incremented, of course.
By definition, Default
-type variants do not increment it.
The atom names and arities of built-in predicates are encoded in the
build/instructions_template.rs
source file by annotating the
variants of the ClauseType
subtypes with name
and arity
attributes. The attribute macros of the strum crate, like
EnumProperty
and EnumDiscriminants
, scan their host
types and generate accessor functions for these properties. An example:
#[derive(ToDeriveInput, EnumDiscriminants)]
#[strum_discriminants(derive(EnumProperty, EnumString))]
enum CompareNumber {
#[strum_discriminants(strum(props(Arity = "2", Name = ">")))]
NumberGreaterThan(ArithmeticTerm, ArithmeticTerm),
#[strum_discriminants(strum(props(Arity = "2", Name = "<")))]
NumberLessThan(ArithmeticTerm, ArithmeticTerm),
#[strum_discriminants(strum(props(Arity = "2", Name = ">=")))]
NumberGreaterThanOrEqual(ArithmeticTerm, ArithmeticTerm),
#[strum_discriminants(strum(props(Arity = "2", Name = "=<")))]
NumberLessThanOrEqual(ArithmeticTerm, ArithmeticTerm),
#[strum_discriminants(strum(props(Arity = "2", Name = "=\\=")))]
NumberNotEqual(ArithmeticTerm, ArithmeticTerm),
#[strum_discriminants(strum(props(Arity = "2", Name = "=:=")))]
NumberEqual(ArithmeticTerm, ArithmeticTerm),
}
You are encouraged to read the strum documentation
for info on how to define further discriminants and access the annotated data.
I stress that strum
is a strictly build time crate, similarly
to how instructions_template.rs
is only built and run at build time.
Beyond generating Instructions
from template types definitions,
instructions_template.rs
generates the implementation of
Instructions
(i.e., the functions of impl Instructions
) using the
proc-macro2 family
of metaprogramming crates. They provide a kind of quasiquotation-based
syntax expansion similar to that available in the Lisp family of
programming languages. The functions they're used to generate
are simply transformation and property predicate functions primarily used
by the code generator. They are:
Function | Function Description |
---|---|
fn to_name_and_arity(&self) -> (Atom, usize) |
Returns the predicate indicator of the instruction 1 |
fn to_default(&self) -> Instruction |
Returns the Default variant of the instruction if one exists |
fn to_execute(&self) -> Instruction |
Returns the Execute variant of the instruction if one exists |
fn is_execute(&self) -> bool |
Is an Execute variant |
fn perm_vars_mut(&mut self) -> Option<&mut usize> |
Returns a mutable reference to the permament variables slot of the variant if it exists 2 |
fn is_ctrl_instr(&self) -> bool |
Is a so-called "control instruction" 3 |
fn is_query_instr(&self) -> bool |
Is an instruction that may appear in the body of a compiled predicate |
2. Some instructions track the number of active permanent variables at their time of execution inside their host predicate. This is to support a WAM optimization known as environment trimming which Scryer does not yet (and may never) implement.
3. One of `allocate`, `deallocate`, `proceed`, `rev_jmp_by`.
To add a new built-in predicate to Scryer Prolog, the following steps
should be taken in instructions_template.rs
:
-
Identify the variants you want to generate for the instruction using the variant table and place your new instruction variant in the corresponding
ClauseType
subtype (this will very probably beSystemClauseType
). The variant should be named by translating the Prolog predicate name from snake case to Pascal case as Rust convention dictates. -
Add
strum_discriminants
attribute propertiesName
andArity
by mimicking surrounding attributed variants. -
Add your instruction and its generated variants as arms to the appropriate section of
Instruction::to_functor
. For now,Instruction::to_functor
is unfortunately written and maintained by hand. -
Add generated variants as arms to the dispatch loop of
dispatch.rs
.
Importantly, you MUST NOT add wildcards to any manually maintained functions or try to modify any of the auto-generated functions listed above. Match arms for new variants will be inserted into the auto-generated functions automatically.
We conclude the flying roll with a small note on the instr!
macro.
instr!
is a declarative macro generated in
instructions_template.rs
for the convenience of the code
generator. It takes as its first input the strum
-annotated Name
of
an Instruction
variant along with any arguments the named variant
takes, and expands to a declaration of that variant. An example:
instr!("jmp_by_call", vars.len(), 0, pvs)
==>
Instruction::JmpByCall(vars.len(), 0, pvs)
rustfmt
doesn't format macro_rules!
readably yet, but the
generated instr!
declaration can still be viewed, warts and all, at
instructions.rs
.