-
Notifications
You must be signed in to change notification settings - Fork 2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[RFC] Expression compiler #7
Comments
Our task is a bit simpler than other languages since we don't have "new". |
Right, all heap allocation happens inside libraries. This is the same in Rust. Not sure what the implications are.
This is a bit tricky. We are single-threaded in the sense that all "our" code runs in the context of a worker thread and doesn't share state with other workers. But if we use a functional map that internally implements lazy copying, it must be implemented using |
But what if we give up altogether using multiple workers (when not targeting DD)? Assuming all work in DDlog is done on the calling thread. |
Yeah, I'm not sure. Theoretically, |
It might be worth looking at something like symmetric interaction calculus, or some of the later variants the author has developed, as a potential model. It has the advantage of not needing GC, has efficient parallel reduction semantics, etc., but there are some interesting design constraints. See the points mentioned in the README. There's also several blog posts from the author on developing that further (later variants of that are used in a fintech computation engine of some sort, but I don't remember the details). |
Intro
This RFC focuses on efficient compilation of the DDlog-2 expression language
(i.e., the non-relational subset of the language). Our language design choices,
as well as the choice of the backends we want to support, affect the complexity
(and feasibility) of building an efficient compiler.
Here is the expression language design we've discussed so far:
Syntactically, our expression language is a subset of Rust.
Semantically, it is a pure functional language with pass-by-value semantics
(unless we allow
mut
variables), i.e., copying a variable to another variableor field, or passing it as an argument to a function is semantically equivalent
to creating a copy of its value.
Our primary target is Rust, hence we cannot rely on a garbage collector for
automatic memory management (all we have is (1) Rust's ownership model and (2)
limited forms of GC that can be implemented as libraries, e.g., reference
counters).
To the best of my knowledge, the combination of 2 and 3 is unique: functional
languages typically use full-fledged mark-and-sweep GC. The only language I
know that relies on reference counting for GC is Swift, and Swift is
pass-by-reference, not pass-by-value (to be more precise, Swift has
struct
'sthat are not reference counted and are pass-by-value, and classes, which are
reference counted and are pass-by-reference). "Unique" is not a good thing in
this case, I'd rather rely on a well-understood technology, but our requirements
seem to dictate these choices.
So we need compiler + runtime design that implements pass-by-value semantics,
supports automatic memory management, and avoids actual cloning as much as
possible. We discuss a few strawman designs below. A practical solution may
combine more than one of these designs.
Strawman designs
Design 1: wrap all objects in a refcount
The compiler wraps all data types, except primitive types, in a reference
counted pointer. If we want to support
mut
variables, we probably want to usea reference counter with copy-on-write support, so that copying is
O(1)
evenif the value may get mutated later. In addition, the compiler can perform data
flow analysis to avoid incrementing/decrementing the counter when possible.
Pros:
Cons:
Wastes memory and CPU to store and update a reference counter with each
object.
Even small objects must be heap-allocated. This alone can kill the
performance.
Design 2: Let the user decide
With this approach the user annotates each type to tell the compiler whether
they want it wrapped in a reference counter or not. In Swift, for example, the
class
keyword generates a reference-counted type, while structs and tuples arenot reference counted.
Pros:
is not available to the compiler.
Cons:
Design 3: Let the compiler decide
The compiler can decide which types to wrap in a reference counter, e.g., based
on their size.
Pros:
Cons:
can arbitrarily wrap types in an
Arc
.Design 4: Keep reference counters inside libraries
A copy-on-write reference counter is really just one example of a functional
data structure. Functional languages typically come with libraries that
implement many others, e.g., functional maps, sets, heaps, lists.
In this design the compiler does not inject (or even aware of) reference
counters. User-defined types translate directly into equivalent Rust structs,
enums, or tuples. The compiler uses a combination of pass-by-reference
(borrowing), ownership transfer, and cloning to maintain the pass-by-value
semantics. Library data types, especially container types, internally use
functional data structure to reduce the amortized cost of cloning and
modification operations.
Pros:
for people implementing extern functions.
Cons:
getting cloned when the compiler needs to copy the value (i.e., it cannot
simulate pass-by-value by borrowing or ownership transfer). This can be
expensive, especially for structs with many fields.
Compiler design
All alternatives outlined above require an expression compiler that can maintain
pass-by-value semantics while minimizing actual cloning. In a nutshell,
an expression of the form
let x = y
, can be compiled to Rust in several ways:Option 1 is always valid, but can be expensive, whereas options 2 and 3 require
dataflow analysis to establish the lifetimes and access patterns of
x
andy
.In general, more complex program rewriting may be required to generate optimal
code, e.g., depending on the context, the following code
may get compiled to:
In general, even ignoring the algorithmic complexity of optimization, it may
be impossible to statically determine the best implementation.
Optimizations cannot always be applied across function boundaries. For example,
passing input arguments by reference and returning results by value is a
reasonable default calling convention. However, there are cases when
passing by value or returning by reference makes more sense:
In this example, the compiler has sufficient information to derive these
optimizations, but if functions
f1
andf2
areextern
or are declaredwithout an implementation as part of a trait, then the compiler must use
default calling conventions or rely on user annotations.
The text was updated successfully, but these errors were encountered: