Universal high-level intermediate representation

Just an idea I have, comments welcome. This post assumes that reader has some knowledge of Lisp and Haskell, otherwise it's probably incomprehensible.

Goal

Make a universal high-level intermediate representation which is suitable for many kinds of execution models such as:

  • imperative mutable / pure functional
  • low-level / manual memory management / automatic memory management

Expression tree

I assume that expression tree is perhaps the simplest way to build it, in form of a LISP or AST of a minimal language.

But a downside of that it might be too opinionated, particularly, regarding value bindings. How can we do it differently?

DAG

Another way to do it is to represent an expression DAG as a list. Each element of a list consists of an operator and a list of references to DAG nodes from the list before this nodes or surrounding lists.

For this example, I will use s-expression notation. Comments are after ;. The example just shows an idea and isn't meant to be complete, particularly, it says nothing about types, etc.

  (link ; link directive binds global entities to local module environment
        plus) ; reference to function plus, gets index 0 in local module environment

  (define-function add5 ( x ) ; names do not affect semantics
        (int-constant 5) ; load 5 into function environment slot 0
        ; 2D notation is used to reference DAG nodes, where first index is 
        ; index of environment (this list, arg list, module list) and second is index 
        ; of a slot in that environment
        (call (2 . 0) ; module-local environment has index 2, slot 0 is function plus
               (0 . 0) ; reference slot 0 of this environment, which is integer 5
               (1 . 0))) ; reference slot 0 of function arg list, which is x

An expression tree (i.e. Lisp) analog of this is (defun add5 (x) (plus 5 x)) which is kind of shorter, but...

What are possible advantages of this approach over expression trees?

  • name bindings are unrolled into a DAG, thus languages compiling to this IR can implement their own binding semantics
  • lexical environment with its complexities is just not a thing in this model
  • it strongly discourages special operators which make use of "lexical environments", since they are not a thing

What operators (types of nodes) do we need? I believe for pure functional IR we fundamentally only need call and lambda, but if and int-constant can be handy for efficiency reasons too.

Here's example of a function which uses lambda to run a loop. I will first show the function implemented Common Lisp:

  (defun product-via-summation (x y)
      (let ((inner (lambda (left sum) 
                          (if (zerop left)
                              sum
                              (funcall inner (- left 1) (+ sum y))))))
         (funcall inner x 0))

(It won't actually work in CL because of lexical visibility rules. We need to use LABELS to be able to call local function recursively.) Now same using IR, for simplicity I refer to globals by their name but in reality it might be compiled down to index:

  (define-function product-via-summation (x y) ; silly function which implements * using +
        (lambda (left sum)
              (call zerop (1 . 0)) ; check if `left` is zero
              (call plus (1 . 1) (3 . 1)) ;   sum + y
              (call decrement (1 . 0)) ; left - 1
              (call (2 . 0) (0 . 2) (0 . 1)) ; recursive call to itself
              (if (0 . 0) (1 . 1) (0 . 3))) ; if zero return sum, otherwise return result of recursive call
        (call (0 . 0)  (1 . 0) zero))

Imperative programming / IO

Lazy pure functional programming, is, of course, easy mode. The real question is how imperative programming fits into this model. To handle imperative programming we add following operators:

  • seq: make sure that all referenced nodes are evaluated in order, return the last one.
  • par: like seq but without ordering
  • call/seq: sugar for a call followed by seq.

Let's try doing the function above while also printing intermediate results:

   (define-io-function product-via-summation (x y) 
        (lambda (left sum)
              (call zerop (1 . 0)) ; 0: check if `left` is zero
              (call plus (1 . 1) (3 . 1)) ; 1:  sum + y
              (call decrement (1 . 0)) ; 2: left - 1
              (call (2 . 0) (0 . 2) (0 . 1)) ; 3: recursive call to itself
              (call print (1 . 1)) ; 4: print sum so we start with printing 0
              (if (0 . 0) (1 . 1) (0 . 3)) ; 5: if zero return sum, otherwise return result of recursive call
              (seq (0 . 4) (0 . 5))) ; make sure we always print before returning
        (call (0 . 0)  (1 . 0) zero))

How do we implement mutable variables and explicit memory management?

Just consider them as IO. The function above can be written as:

   (define-io-function product-via-summation (x y) 
        (mkvar (1 . 0)) ; 0: left
        (mkvar zero) ; 1 : sum
        (lambda ()
              (call readvar (2 . 0)) ; 0: read left
              (call readvar (2 . 1)) ; 1: read sum
              (call zerop (0 . 0)) ; 2: check if `left` is zero
              (call plus (0 . 1) (3 . 1)) ; 3:  sum + y
              (call decrement (0 . 0)) ; 4: left - 1
              (call print (0 . 1)) ; 5: print sum
              (call writevar (2 . 0) (0 . 4)) ; 6: write left
              (call writevar (2 . 1) (0 . 3)) ; 7: write sum
              (call (2 . 2)) ; 8: recursive call to itself
              (seq (0 . 6) (0 . 7) (0 . 8)) ; 9: imperative sequence for going to next iteration: write variables then call itself
              (if (0 . 2) (0 . 1) (0 . 9)) ; 10: if zero return sum, otherwise return result of recursive call
              (seq (0 . 5) (0 . 10))) ; make sure we always print before returning
        (call (0 . 2)))

This looks quite a bit like assembly (so I'm fairly certain it can represent low-level operation like assembly), but call sequence is determined by how dependencies and calls to seq and call are ordered, not by order of operations. So e.g. " (call writevar (2 . 0) (0 . 4))" does not happen unless it is required by the returned value.

Did you just reinvent Haskell?..

This is, obviously, influenced by how pure functional languages approach IO, but:

  • evidently, it is not driven by types, although types can certainly prevent non-sensical programs to be compiled
  • IR can in principle let programmer to write imperative using a language similar to Python or C which then gets compiled to this IR

25 comments · 88% upvoted · other discussions

comments

cxzuk

So, on a practical level, there are many IRs. Programming in general, the structure of your input data affects your algorithms that process them.

The algorithms you want to apply will vary on what your language offers. So it's not really possible to have a universal IR here.

What I think you're describing is p-code or bytecode. Which is technically IR but is focused on the "portable" side than the processing side.

Kind regards, M ✌️

killerstorm [OP]

My goal is to create an IR which can

  • represent languages as diverse as C and Haskell
  • preserves high-level structure of program, so, potentially, Haskell can be recovered from IR

This is different from low-level IRs, e.g. if you compile Haskell to LLVM IR all its haskellness is gone at that point.

Universality is not about replacing all other kinds of IRs, obviously, but about supporting different models of execution.

[deleted]

From an IR I'd expect that (after optimisations) it'd compile the same regardless of which source language I am using. But I don't understand how this works with your IR.

C and Haskell have very different execution models. In particular how do you handle laziness?

killerstorm [OP]

That's where it gets complex. :)

What I want to do is something which enables any-to-any conversion, i.e. allows to compile different languages to different compilation targets. Sort of like pandoc but for software.

Compilation targets might range from boring binaries for a particular OS to some locked down VM. Obviously, some of (source, target) combination simply make no sense. Other might require emulation.

In the post I described only the execution model of IR, but it should have other components as well, particularly, it should define what source requires from the target. So a compiler would know what target it is compatible with, what sort of emulation or compat layer it might need, etc.

C and Haskell have very different execution models. In particular how do you handle laziness?

Basically the strategy so far is to translate "C" model to "Haskell" model, and then execute the "Haskell" model. I.e. we have a lazy program in the end, so compiler has to know how to do lazyness.

There can be a problem when interfacing between the two, I think it can be solved by representing more information about the function in the type system and then only allow safe constructions to be compiled / linked.

crundar

Do you intend for it to round-trip? That is, is compile2 :: LangA->LangB->LangA an identity function?

killerstorm [OP]

There might be different compilers which use this IR. Some might try to enable lossless translation as you described above, other might not care about this at all and focus on e.g. making a binary for a particular platform.

Currently I have some pragmatic use for IR as my company develops some programming languages / platforms we might benefit from having a flexible IR. Any-to-any is more of a pipe dream tbh.

WittyStick

While I doubt there can be a truly universal IR, I would recommend having a look at the Kernel programming language for an interesting alternative approach to managing bindings and handling control (and maybe a hint as to why call/lambda aren't sufficient for universality).

Kernel is very readable as it looks just like Scheme, but it introduces new abstractive capabilities which lend well to implementing different execution models and experimenting with language design at a very high-level, not a clutter of slots and magic numbers.


The means by which bindings are looked up is in particular one of the most interesting features. When a symbol referred to in code, it is looked up in the current environment and the corresponding expression is returned. These environments form DAGs, with symbol lookup performed as a depth-first-search, which by default gives the behavior of lexical environments, but this behavior can be overriden where desired.

Each node in the environment consists of a map of symbols to bindings (the local bindings), plus an ordered list of parent nodes, which are other environments. The ordering of parents is important as it can affect the result of the DFS.

An empty environment can be created with (make-environment), and an environment with parents can be created with (make-environment parent1 ... parentN), where bindings in parent1 would shadow any binding of the same symbol in later environments.

A set of local bindings can be created with ($bindings->environment (binding1 expr1) ... (bindingN exprN)). You can combine these features to make arbitrarily structured enviromnents. Eg,

(make-environment
    ($bindings->environment
        (binding1 expr1)
        (binding2 expr2))
    ($bindings->environment
        (binding1 expr3)))

In this environment, binding1 would map to expr1 due to the partial ordering on parents.

Environments are first-class, so you can chose to evaluate any symbol in some arbitrary environment. The two primary ways of doing this are with ($remote-eval expr env) or ($let-redirect env bindings body).

Additionally, every "function" is implicitly passed a reference to the dynamic environment of its caller, which can be (and usually is) ignored. The function can access any bindings in this dynamic environment, but importantly, may only mutate the local bindings of that environment (and none of the bindings of the parents).

I put function in quotes because they're not quite functions in the sense you'll be familiar with. Kernel has a distinction between two types of combiners, called operatives and applicatives. Operatives are those which receive their operands verbatim from the caller, with no prior-evaluation of them. Applicatives on the other hand, receive the arguments from the caller - the result of evaluating those operands. The order of evaluation of the arguments is not specified in the report and may be done for example, left-to-right or in parallel.

You can tell immediately that Kernel supports a style of programming that is not present in most other languages because those languages implement reduction semantics by default. When reduction is not what you want, some languages provide a way out via quotation, or special operators like if as you've mentioned. However, Kernel inverts the relationship by making operatives (non-reduction) the default. $if in kernel is an operative which will not reduce the operand for the branch not taken. There are no other special forms in Kernel - every combiner is either operative or applicative.

An applicative is merely a wrapped operative which forces reduction of the operand list before giving its result to the underlying operative. The primitive for creating applicatives is called wrap. The primitive for creating operatives is called $vau. In other functional languages the primitive for applicatives is called lambda, but since Kernel is more abstractly powerful, it is not required to be primitive and can be implemented as a regular library feature.

($define! $lambda
    ($vau (arguments . body) denv
        (wrap (eval (list* $vau arguments #ignore body) denv))))

The denv symbol as the additional parameter of the outer $vau is the means by which the caller's dynamic environment is passed to the operative. In the operative which underlies the applicative created by $lambda (the inner $vau), this is #ignored, because the behavior of regular functions is to not know about their caller. There are however, many useful cases where you want applicatives which do access or manipulate the local bindings of their caller.

That's just a preview of Kernel's features, but from this you can gather that operatives are clearly more powerful than applicatives, as you can implement applicatives directly on top of operatives. Attempting to do the opposite is fraught with problems though: You can't "un-reduce" an argument which has already been reduced, so you must suppress reduction to begin with - using quotes or special operators, which expand the language and become opinionated.

The execution model provided by Kernel is more similar to how things are typically done with an IR or instruction set. These don't implement reduction implicitly, but reduction is usually performed explicitly by the compiler. In Kernel, all reduction is explicit (though it doesn't need to be marked as such every time reduction occurs).

killerstorm [OP]

Thanks, this is interesting, but it's pretty much exactly the opposite of what I want. :)

To avoid a lengthy introduction I did not mention that one of goals for this IR is to support different execution targets, including some exotic contexts where you don't even have a heap, etc.

I used to like Lisp a lot, and it's amazing how you get some results quickly, but the problem is that it doesn't seem to be good with static typing (as in, I don't know any successful statically typed Lisp), which I think is a must nowadays.

SkiaElafris

The best way to look at Kernel is to think of it as a way of describing how source code is transformed into the target output form.

For a universal IR, Kernel would be a way of representing the current state of the code and how to further process it in the same manner. In other words the code is evaluated like a script and you can change the how by changing the evaluation environment.

And could allow the source code to define how it should be processed. Or have different stages of transformation embed such into their output to direct transformations that come later in the pipeline.

mamcx

Let's takes one of such things: Java bytecode.

If I target it, great, I get for what Java is good. But terrible: I get for what Java is good :(.

Even LLVM/C is a terrible target for many many langs.

And assembler, all of them.

The best IR is the one that matches closely the lang.

sebamestre

this feels a lot like SSA and a CFG

bluefourier

Executable UML is aiming to provide means to describe the structure of programs and the data they operate upon.

It carries over the concept of a Platform Independent Model (PIM) which is the high level description and the Platform Specific Model (PSM), which is basically a realisation of the PIM with specific constraints and assumptions applied to it.

The PIM describes a program free of any considerations of how entities are represented in memory or how they are accessed etc. All this comes into effect at the translation between PIM to PSM. And there are different levels of abstraction to describe concepts that may be given and cannot change.

A class diagram is a graph the describes the structure of the data and you can use it to do all kinds of checks as far as data structures are concerned. Similarly, you have a graph of state transitions which you can parse with lazy or eager evaluation ways. (This is a bit more involved).

Executable UML is not perfect at the moment but it seems to fit the bill of an IR to capture the "recipe", the structure of an algorithm, and then realise it in various different ways.

BanTheTrubllesome

So in a nutshell, json but for functions

killerstorm [OP]

That actually exists and was one of inspirations: https://github.com/MaiaVictor/LJSON

moon-chilled

Why? You've explained what you want to do, but I don't understand the motivation.

killerstorm [OP]

A full explanation of "why" would be long and I didn't want to bore people with it. But there are basically two aspects:

  1. Pragmatic: My company develops DSLs, and it would be nice to have a unified IR which can support different kinds of targets and work with highest level possible.
  2. Pipe dream: Develop a new programming languages which would allow a system to be developed from low-level components to high-level components in a consistent, high-level, secure way.
o11c

The problem with a "universal" IR is that it's going to require a lot of near-duplicate code with subtle semantic differences.

That doesn't mean it's not worth doing ... but it means a lot of effort, a lot of rarely-tested codepaths, and a lot of complexity that can easily hide bugs.

For example, I made a list of just ownership policies (and resource types). I've never seen a language that can imagine handling half of those. And chances are it's incomplete (patches welcome).

(I also have a list of test annotations/results)

tobega

Could this be related to what you are thinking of? http://www.cubix-framework.com/

Zkirmisher

Did you just reinvent Haskell?

No. But you re-discovered UNCOL, an idea from 1958.

johnfrazer783

UNCOL was never successfully implemented, despite the fact that some of the most famous people in the history of computers have worked on the problem. Several national governments (including the U.S. Department of Defense) have attempted a solution. Several large businesses (including IBM, Digital, and HP) have attempted a solution.—UNCOL

Well... good luck with that...

complyue

Did you just reinvent Haskell?

Yes, you did. And Haskell even already has your intended front grammar with comprehensible semantics implemented.

Btw, Haskell's take on imperative paradigm is not seq, but (>>=), that's the "programmable semicolon" under the surface syntax of do notations.

No types & manual memory management?

Just use mallocForeignPtr :: IO (ForeignPtr Word8) and/or friends with finer grained control, then peek/poke memory as you go.

Fallback to type-safe & auto memory management with just IORefs.

A more ambitious goal may target a super abstract machine of Actor model with multi-processing, instead of single-threaded Turing model, then stm can be a reliable friend.

killerstorm [OP]

Btw, Haskell's take on imperative paradigm is not seq, but (>>=)

I know. But the goal was not to re-create Haskell. So why do you think seq won't be sufficient for this?

There has to be a reason why Haskell is not used more often.

complyue

I guess similar reason exists for formal math language is used less often than natural languages in operation manuals - much more people are equipped with procedural mindset than mathematical mindset.

As for seq, it chains pure "evaluations" instead of "execution steps", if you make clearer distinction between evaluation vs execution, monad binding is a more formal and principled way to express sequential order w.r.t execution, and RealWorld in Haskell makes perfect sense for the supportive implementation of order constraints.

[deleted]

[deleted]

killerstorm [OP]

Only by presence of parentheses :)

WASM is imperative, implying execution of instructions one by one. While I start with lazy, compute on demand, pure functional basis and then add a mode for imperative execution.

joakims

My bad, I admit that I skipped down your post and only saw the last code example :)