Vine: A programming language based on Interaction Nets
vine.dev
vine.dev
Made my head hurt a bit, it’s odd that it doesn’t really attempt to contextualise if the weirdness is for its own sake or some greater practical purpose.
Always fun to find a novel way to think about programming though.
And, I'm still not sure how that wacky stuff gets compiled down. It looks like syntax sugar as a language.
I guess I'm still in the edit window. I installed it. Naturally once of its hundreds of dependencies needed a newer rustc than the 22.04 PPA but whatever, got that sorted. Turns out it doesn't compile down into assembly but if I'd kept reading I would've seen that it goes to Ivy, an intermediate language that's run later. Running the example gives:
> vine run vine/examples/sub_min.vi [1, 0, 4, 6]
Interactions Total 948 Annihilate 491 Commute 21 Copy 88 Erase 102 Expand 142 Call 63 Branch 41
Memory Heap 1_184 B Allocated 19_520 B Freed 19_520 B
Performance Time 0 ms Speed 6_245_059 IPS
Apparently the stats are default. Which makes this feel like a toy but again, whatever. Running that first way gives:
Interactions Total 1_069 Annihilate 562 Commute 21 Copy 96 Erase 114 Expand 163 Call 67 Branch 46
Memory Heap 1_184 B Allocated 22_000 B Freed 22_000 B
Performance Time 0 ms Speed 7_088_859 IPS
IPS seems quite variable at this small an input size. So my conclusion is: if it helps one write code better, go for it. Who cares. But this certainly doesn't seem like some magic bullet that doesn't need to go through the array twice. The ivy code is not very human friendly so I couldn't wrap my head around what it's actually doing, but it looks like it's a bunch of functional programming. It really does look quite slow and mathematically pure. So as a math toy I think it's doing a good job.
https://vine.dev/docs/features/inverse
I was long curious about interaction nets. Can someone confirm if Vine will automagically derive an algorithm for doing this with a single pass or it will do one pass in "forward mode", but once the minimum is resolved, follow it up with a second implicit "book keeping and variable re-resolution pass" that will be equivalent to an actual full second pass you'd get in an imperative language?
It is all very much reminiscent of programming in tensorflow 0.x.
Yes, this is correct. Though it's worth noting that this is not some transformation being done by the compiler, but an emergent property of the interaction net.
find_min_and_subtract([H], [R], Min) :-
Min = H,
R is H - Min.
find_min_and_subtract([H|T], [R|Rs], Min) :-
find_min_and_subtract(T, Rs, TailMin),
(H < TailMin -> Min = H ; Min = TailMin),
R is H - Min,!.
Which [9,4,12,2] -> [7, 2, 10, 0].But this doesn't work for all cases anyway (just the ones where the last value is also the minimum). I feel it can be fully expressed in one pass with miniKanren instead but no time to figure that out now; it can defer the arithmetic by setting up constraints.
Always liked the idea of 'using variables in the past' (or creating them in the future) and the first amazement moment was with Prolog (there were quite a lot of wow! moments in Prolog for the young me who only knew basic, pascal & c, like when you have some 'write multiply in prolog', when you deliver f(x,y)=x*y, you can not only do f(2,3) and get 6, but also f(2,y)=6 and get y=3), but, so it made me think of it.
:- use_module(library(clpfd)).
min(Min, Y, Min) :- Min #=< Y, !.
min(X, Min, Min) :- Min #=< X.
find_min_and_subtract_([], [], Min, Min).
find_min_and_subtract_([H|T], [R|Rs], TempMin, Min) :-
min(TempMin, H, NewTempMin),
R #= H - Min,
find_min_and_subtract_(T, Rs, NewTempMin, Min).
find_min_and_subtract([H|Hs], Rs, Min) :-
find_min_and_subtract_([H|Hs], Rs, H, Min).
With that (and an additional variable for the temporary minimum) it works for me: ?- find_min_and_subtract([9,2,4,12], Rs, Min).
Min = 2,
Rs = [7, 0, 2, 10]
https://swish.swi-prolog.org/p/qgchfMfB.plIn the case where asynchrony was actually necessary, it seems like a great alternative to function coloring.
But whether you should actually use it for something like their `sub_min` example is highly dependent on how good the performance of their implementation is. Creating a graph of references rather than making two passes over an array of integers is not clearly faster ... or clearer, for that matter.
Do you have any thoughts on HVM's SupGen for program synthesis? I would love to understand how interaction nets makes the notion of inverse types and filling holes more natural and efficient.
a bit like Tree Calculus but with nets.
let y = None;
y is Some(value) => value > 0 // true
y is Some(value) => value == 0 // trueEDIT: btw, I knew I recognized your handle and it was from this amazing code golf answer:
https://codegolf.stackexchange.com/questions/108170/totally-...
Interaction nets are an alternate model of computation (along the lines of the lambda calculus or Turing machines). It has several interesting properties, one of the most notable being that it is fundamentally parallel. https://en.wikipedia.org/wiki/Interaction_nets https://t6.fyi/guides/inets
I think there are many potential applications for interaction nets in parallel & distributed computing, among other fields; and such applications will need a language – hence Vine.
(re your edit – that's fun lol; Totally Cubular is one of my favorite projects)
And synchronization primitives?
I wanted to also say I loved reading the documentation. Your idea of places, spaces and values feels like a waaay more intuitive naming scheme for than what's common in CS.
The time-travel example also felt oddly intuitive to me. I don't really care that it uses black magic underneath, it's quite elegant!
Of course, the intrinsic parallelism is useful there (especially since it is emergent from the structure of the program, rather than needing to be explicitly written). In terms of other interesting properties: interaction nets don't rely on linear memory, instead being based on a graph, which can naturally be chunked and distributed across machines, with synchronization only being necessary when wires span different chunks.
> And synchronization primitives?
The initial answer to this question is: interaction nets don't need synchronization primitives, as all parallelism is emergent from the net structure.
Now, in practice, one may want some additional synchronization-esque primitives. For example, a parallel shortcircuiting or is not expressible in vanilla interaction nets (as all computation is deterministic, and such an operation isn't deterministic). There are extensions to interaction nets for non-determinism, that allow some of these use-cases, which start to look more like synchronization primitives. Vine doesn't support any of these at the moment, but it may in the future.
> I wanted to also say I loved reading the documentation. Your idea of places, spaces and values feels like a waaay more intuitive naming scheme for than what's common in CS.
I'm glad to hear it! I spend a lot of time trying to come up with good names for things, so it's nice to know that that pays off. Though I suppose it's not too hard to improve on the status quo, when it's 'lvalue'/'rvalue', lol. (I did, however, steal 'place' and 'value' from Rust.)
> The time-travel example also felt oddly intuitive to me. I don't really care that it uses black magic underneath, it's quite elegant!
Yeah, the 'time-travel' stuff sounds wacky, but I do think it can be really intuitive if one is willing to accept it. I wrote a really cool algorithm a little while ago that really uses this 'time-travel' idea; I [wrote about it](https://discord.com/channels/1246152587883970662/12461564508...) on Discord. (I should really type that up into a blog post of sorts.
The key reason one may consider interaction nets to be more parallelisable than lambda calculus is that the key evaluation operation is global in lambda calculus, but local in interaction nets. The key evaluation operation in labmda calculus is (beta) reduction. For instance, if one evaluates a lambda term `(\n -> f n n) x`, reduction takes this to the term `f x x`. To do so, one must duplicate the entire term `x` from the argument to perform the computation, by either 1) physical duplication, or 2) keeping track of references. Both are unsatisfactory solutions with many properties hindering parallelism. As I shall explain, the term `x` may be of unbounded size or be intertwined non-locally with a large part of the control graphs of other terms.
If `x` is simply a ground term (i.e. a piece of data), then it seems like either duplication or keeping track of the references would be an inevitable and reasonable cost, with the usual managed-language issues of garbage collection. If one decides to solve the problem by attempting to force the argument to be a ground term, one would find the only method to be to impose eager evaluation, evaluating terms by always first evaluating the leaf of the expression, before evaluating internal nodes in the expression. Eager evaluation can easily become unboundedly wasteful when one strives to reuse a general computation for some more specific use cases, so one may not prefer an eager evaluation doctrine.
However, once one evaluates in an order that is not strictly eager (e.g. lazy evaluation), the terms that one is duplicating or referencing are no longer simple pieces of data, but pieces of a (not necessarily acyclic) control graph, and any referencing logic quickly becomes very complicated. Moreover, the argument `x` could also be a function, and keeping track of references would involve keeping track of closures over different variables and scopes, which complicates the problem of sharing even further.
Thus, either one follows an eager evaluation order, in which most of the nodes in a term's expression tree are not available for evaluation yet, and available pieces of work for evaluation are only generated as evaluation happens, which imposes a global and somewhat strict serialised order of execution, or one deals with a big complicated shared graph, which is also inconvenient to be distributed across computational resources.
In contrast with lambda calculus, the key evaluation operation in interaction nets is local. Interaction nets can be seen as more low-level than lambda calculus, and both code and data are represented as graphs of nodes to be evaluated. Thus, a large term is represented as a large net, and regardless of the unbounded size of a term, in one unit of evaluation, only one node from the term's graph is involved.
Given a graph of some 'net' to be evaluated, one can choose any "active" node and begin evaluating right there, and the result of computation in that unit of evaluation will be guaranteed to affect only the nodes originally connected to the evaluated node, no referencing involved. Thus, the problem of computation becomes almost embarrassingly parallel, where workers simply pick any piece of a graph and locally add or remove from that piece of the graph.
This is what is meant when one refers to interaction nets being more parallelisable than lambda calculus.
I'm interested in a wider comparison, and understanding why you didn't go with HVM, especially as the low-level runtime.
IVM is architecturally similar to HVM-64 (which I was the lead developer of). The most major difference is how they handle IO. IVM uses its extrinsic system, where all side effects are mediated through an IO handle, which provides a number of useful properties, and is greatly simpler to implement / use. Interactions with side effects are small and low-cost, and can happen in parallel with the rest of the program.
HVM-64 had built-in net definitions that had side-effects when expanded, which was very messy to use in practice. HVM2 has a monadic IO interface, which requires stopping the whole program on every single IO call. (And also requires writing things monadically.)
Using extrinsics for IO handles in IVM creates a very nice API for IO in Vine; side-effect-ful functions simply take a mutable reference to the IO handle. It's also very easy to support multiple 'threads' of parallel IO effects – simply duplicate the IO handles.
I don’t pretend to understand all the theory for nets stuff, but the premise seems super cool and I love languages that have experimental and unusual ideas!
Also liked the dedicated section about the compiler design: the description of the different passes is cool.
I’m also curious to think about how the “time travel” might interact (if at all) with (1) basic algebra and (2) building off that, systems of equations and (3) building off of that something like implicit/crank-nicholson finite difference schemes.
Without having thought about it much, it feels intuitively like there might be an elegant way of expressing such algorithms using this notion of information traveling backwards, but perhaps not.
As an aside, although I’m not really a fan, the representation of time travel in Tenet seems like a natural analogy to include in your docs for fun if desired. More relevant, talking about things like backprop in standard ML packages might be one way of providing a shared reference point for contextualizing information flowing backward through a computational graph.
Finally, echoing what others have said about not burying the lede in the docs, as well as wanting to see more motivation/examples/discussion of interaction nets.
Would one be able to model `~x` with, say, Rust channels?
In that context, then, the inverse operator switches which side of the wire you're talking about. If you have a parameter of type `N32`, you're on the 'consumer side'. But if you have a parameter of type `~N32`, that can be viewed as being the 'producer side' of an `N32` channel. Since `~` just swaps the sides, `~~T` is the same as `T`.
Should ~ be considered part of the type signature? Does `let ~x = y` work as destructuring?
Yes; you can take the producer side of an `N32` wire `x` and link it to the consumer side of an `N32` wire `y`; that just means that the value that is sent across `y` will be sent across `x`.
> Should ~ be considered part of the type signature? Does `let ~x = y` work as destructuring?
In `let ~x = y`, the `~` is part of the pattern, and is destructuring, yes. So assuming `y` is some `~N32`, that statement declares a new variable `x`, with no initial value, and whatever the final value of `x` is, that's what passed along the wire. So re: what happens when a variable is assigned more than once, the last assignment wins.
1. Since it is based on Interaction Net, is it parallelized well?
2. Will there be a borrow checker like Rust does?
Is it maybe possible to express the plot of "Tenet" using the inverse operator?
In 1s compliment we can have negative and positive 0.
0 is not a “natural” number in the traditional sense.
It’s an interestingly opinionated language.