High-order Virtual Machine (HVM): Massively parallel, optimal functional runtime
github.com
github.com
HVM is the ultimate conclusion to years of optimal evaluation research. I've been a great enthusiast of optimal runtimes. Until now, though, my most efficient implementation had barely passed 50 million rewrites per second. It did beat GHC in cases where optimality helped, like λ-encoded arithmetic, but in more real-world scenarios, it was still far behind. Thanks to a recent memory layout breakthrough, though, we managed to reach a peak performance of 2.5 billion rewrites per second, on the same machine. That's a ridiculous 50x improvement.
That is enough for it to enjoy roughly the same performance of GHC in normal programs, and even outperform it when automatic parallelism kicks in, if that counts! Of course, there are still cases where it will perform worse (by 2x at most usually, and some weird quadratic cases that should be fixed [see issues]), but remember it is a 1-month prototype versus the largest lazy functional compiler in the market. I'm confident HVM's current design is able to scale and become the fastest functional runtime in the world, because I believe the optimal algorithm is inherently superior.
I'm looking for partners! Check the notes at the end of the repository's README if you want to get involved.
So a HVM-like interpreter could be written in Java or variants thereof (instead of Rust), in order to take advantage of TornadoVM to run efficient lazy evaluation on exotic devices.
So, this dup primitive, is that explained in the book as well, or is this a new addition in your implementation?
```
fn f(x) { *x = 42 // lazily cloning on write };
let x = 1; f(x); // x still equals 1 here
```
Haskell doesn’t parallelize every independent expression because it’s a general purpose language and that’s unlikely to be the correct choice in all contexts.
I don’t have much of an intuition for whether the optimal evaluation stuff will be useful in practice for the kinds of programs people actually write. Like I get that it helps a lot if you’re doing multiplication with Church numerals… but can you give some more compelling examples?
I personally think Haskell's approach to parallelism is wrong, though, since it demands in-code annotations to work. The problem is that the decision on whether an expression should be parallelized doesn't depend on the code itself, but on where the expression is used. For example, if `fib(42)` is the topmost node of your program's normal form, you always want to parallelize its branches (`fib(41)` and `fib(40)`), but if it is called in a deeply nested list node, you don't. That information isn't available on the code of `fib`, so placing "spark" annotations seems conceptually wrong.
HVM parallelizes by distributing to-be-evaluated redexes of the normal form among available cores, prioritizing these close to root. Here is an animation: https://imgur.com/a/8NtnEa3. That always seems like the right choice to me. After all, it just returns the same result, faster. I could be wrong, though. In which case you think parallelism isn't the correct choice? Is it because you don't always want to use the entire CPU? Would love to hear your reasoning.
> Like I get that it helps a lot if you’re doing multiplication with Church numerals…
This isn't about multiplication with Church numerals. Don't you find it compelling to write binary addition as "increment N times" and have it be as efficient as the add-with-carry operation? Making mathematically elegant code fast matters, and HVM does that. See the overview [0] for instance. Also, this allows us to have all the "deforestation" optimizations that Haskell applies on List with hardcoded #rewrite pragmas, for any user-defined datatype, which is also quite useful. There are certainly many uses that I'm not creative enough to think of.
[0] https://github.com/Kindelia/HVM/blob/master/HOW.md#bonus-abu...
I would just report the results of a parallel Haskell program as well, and let people decide what programs they think are comparable. I don’t really think you come off well making the speed claims you do since many people who bother to dig in will conclude you are comparing apples to oranges.
> After all, it just returns the same result, faster.
That is not always true. There’s some overhead to introducing parallelization so it’s not always worth it unless you have a big enough chunk of work.
But the other reason fine grained parallelism isn’t often worth it even if it can provide a speedup for a single computation: you have other concurrent things going on, and plenty of potential parallelism from that. Think of a web server responding to many concurrent requests. In this situation, your program could easily have worse performance by injecting parallelism within each connection. Imagine we have k cores and k concurrent connections - we would likely be better off just giving each connection one core rather than having them all thrashing each other to use all the cores.
> Don't you find it compelling to write binary addition as "increment N times" and have it be as efficient as the add-with-carry operation?
It’s very cool in a “wow that’s neat” kind of way, but, well we already have machine ints that are even faster. :) I feel you need better examples to be compelling - people just don’t care very much about optimizing Peano arithmetic. If those speedups generalize to things people do care more about then you should show that and then people can be impressed! It sounds like you do have some ideas for examples, and I think your point about getting deforestation “for free” is cool… but, show us!
My other question - it seems like this approach requires giving up on separate compilation? Like you can’t compile a function in isolation, since it will do completely different things when composed with other functions? Or am I misunderstanding?
This isn't fine-grained parallelism though! As I said, HVM spawns threads on to-be-evaluated redexes closes to the root of the program's normal form. So, either there is a significant speedup, or the program is sequential (or too small), and there is no speedup, but the overhead is minuscule, since a bounded, small amount of spawn() occurs. To be clear: there is no situation where HVM's parallelism will make the program significantly slower, which *does* happen if you use too many sparks on Haskell. That will never happen on HVM.
> But the other reason fine grained parallelism isn’t often worth it even if it can provide a speedup for a single computation:
Yep that's definitely a good reason. Thanks for sharing. (You can just run HVM in a single thread, though.)
> It’s very cool in a “wow that’s neat” kind of way, but, well we already have machine ints that are even faster
I couldn't disagree more. Just because I used integers as an example, which happens to be optimized, it means an entire optimization technique isn't interesting? This applies to every data structure that can be defined algebraically.
> My other question - it seems like this approach requires giving up on separate compilation?
Compiling a function in isolation is fine, why wouldn't it be? Not sure I get it.
How easy would it be to share data structures between different threads? Say I have a large binary tree in one thread, can I send it to another thread without much cost? How about lambdas?
(Sum Nil ) = 0
(Sum (Cons x xs)) = (+ x (Sum xs))
(Head (Cons x xs)) = x
(Main x) =
let xs = (Cons 1 (Cons 2 (Cons 3 Nil)))
(Pair (Sum xs) (Head xs))
The result of this program is `(Pair 6 1)`. Since `Pair` is the topmost (root) node of the normal form, each of its elements will be computed in a separate thread. Because of that, the same `[1,2,3]` list will be accessed by each thread. But that's not actually a shared reference, because values only exist in one place; instead, the lazy cloner makes copies of the list, layer-by-layer, and sends these copies to the thread that will handle them.Note that if the same expression was written in a deeper position of the normal form, it would be evaluated sequentially. This is what avoids the fine-grained parallelism issue. It also means that in many cases HVM will not be as parallel as it could, though! But when the parallelism kicks in, it is always productive.
I'd be interested in the performance in Haskell, if it was implemented using a number of worker threads (each with their own reference to the map), versus the best implementation you can think of in HVM.
> This applies to every data structure that can be defined algebraically.
Is this a mutually true? I’d expect this to hold for inductive algebraic data types but not co-inductive types?
To me that sounds like overhead. Rather than just computing 1 + 1 as a single assembly language instruction you’re sending the 1 + 1 expression tree to a thread’s work queue or something? If I’m misunderstanding can you clarify?
> Compiling a function in isolation is fine, why wouldn't it be? Not sure I get it.
It seems like optimal evaluation is a whole program optimization - you need the whole program available in order to do it, you can’t just compile one function to assembly language in isolation, and then link that against other precompiled functions. Do I have the wrong idea here?
> Just because I used integers as an example, which happens to be optimized, it means an entire optimization technique isn't interesting? This applies to every data structure that can be defined algebraically.
I do think it sounds cool as I said, but I want to see it on an example I care about, not Peano arithmetic. Especially since deforestation is already an optimization that is used by languages that don’t do optimal evaluation. So I want to see how HVM does it better, if that’s the case. And not just you telling me how it’s theoretically better. :)
Lastly, a while ago I remember you mentioning that you couldn’t compile arbitrary lambda calculus terms. Is that still the case and if so can you better describe what can’t be compiled?
How does that make things more efficient? Naively, it seems like the fib(40) and fib(41) threads are going to do almost exactly the same computation, assuming they're also defined recursively.
Perhaps, with more experience, the performance of programa targeting HVM will be predictable, but I suspect it will mean a lot of relearning intuitions about what makes programs slow?
Do Haskell programmers have good intuition about performance? It seems like laziness would make it hard?
It’s very hard. I’ve found most experienced Haskell devs still get it wrong at times. It’s one of the things that makes me think that we need to rethink the overall approach to laziness
Laziness in data structures usually gives you good enough performance in most cases, but harder to make sure it's consistent and reason about when you need to adjust it. For common operations with an average requirement of performance, laziness seems to give the developer an easier chance of good performance, with the higher possibility to screw it up if the requirement is really strict.
The solution is to just not use laziness when you don't need it, then reasoning about Haskell performance is the same as in any other language.
http://h2.jaguarpaw.co.uk/posts/make-invalid-laziness-unrepr...
Perhaps you could team up?
EDIT: is there a license for this? Can I use it?
In all seriousness, thank you. I have exactly zero experience with Rust, but if I can help with anything, feel free to reach out: @lthibault on GitHub.
Moreover, I'll likely be spinning this off into a full-fledged repo of some sort. I'll keep you updated, and am more than happy to coordinate our efforts.
Thanks again, and congratulations on this incredible piece of engineering!
Yeah, it still needs a few changes on the HVM rust compiler to work, but we could figure it out together if you want.
Hit me up on @Cauef on telegram, caue.fcr@gmail.com, or let's talk on the issue pages of HVM!
You did mention that you pretty much implement pages 14-39 of The Optimal Implementation of Functional Programming Languages. Any idea why the authors of the book didn't implement it themselves?
Also, what lead you to this recent memory layout breakthrough. What was the journey of thinking towards this end?
Regardless, some people did try implementing this in practice, dozens of times, including myself. It just wasn't that fast except for these cases where the asymptotics are superior. A naive implementation just stores graphs/edges on memory, but most of these edges are redundant. For example, a lambda doesn't need to point to its parent. By trying to turn the graph in a tree, I've ended up with SIC [0], which, once implemented efficiently, cut down memory and computation costs significantly. Doing so was tricky, specially because 1. the missing edges complicated the transversal; 2. some upward edges can't disappear (variables); 3. DUP nodes aren't part of expressions, they just "float", which was counter intuitive to get right. Finally, I learned to appreciate the fact that global rewrite rules are better than case-trees for recursive functions, since they greatly reduce the total rewrite count.
So, in short, my journey was:
1. Learn the abstract algorithm
2. Implement it naively as a graph
3. Optimize it by making trees whenever possible
4. Favor rewrite equations over case-trees
These steps lead to the design of HVM, which does fairly well in practice.
[0] https://github.com/VictorTaelin/Symmetric-Interaction-Calcul...
In particular, a while back I came across an interesting tree-based hugenum representation in Haskell, Paul Tarau's "Hereditarily Binary Natural Numbers". It might be something (somewhat) practical that HVM can be used for right now. I'll see if I can translate it and get it to run.
> HVM files look like untyped Haskell.
Why not make it look exactly like Haskell? So that every valid Haskell program is automatically a valid Hvm program, while the reverse need not hold due to hvm being untyped.
Instead the syntax looks like a mix of Haskell and Lisp, with parentheses around application, and prefix operators. I prefer the cleaner Haskell syntax.
Some of the speedup examples look somewhat artificial:
The exponential speedup is on 2^n compositions of the identity function, which has no practical use. It would be nice to show such a speedup on a nontrivial function.
The arithmetical example uses a binary representation, but strangely uses unary implementation of operators, with addition implemented as repeated increment, and multiplication as repeated addition. Why not use binary implementation of operators? Does that not show a good speedup?
I’m not sure what the reasoning was here. I hope the author or others can shed some light.
From my point of view, the author almost made it into s-expressions, but decided not to go all the way, which (imo) would have been amazing and the "cleanest" (although I don't like using the term "clean" for code, it's so subjective). I'd like to have some light shed over the decision not to go all the way.
A `let` could assume un-even amount of forms in it and only evaluate the last one, using the other ones as bindings.
Example from your README:
(Main n) =
let size = (\* n 1000000)
let list = (Range size Nil)
(Fold list λaλb(+ a b) 0)
(define Main (n)
(let size (\* n 1000000)
list (Range size Nil)
(Fold list λaλb(+ a b) 0)))
Could even get rid of the parenthesis for arguments (`(n)` => `n`) and treating forms inside `define` the same as `let`.Good point.
> Could even get rid of the parenthesis for arguments (`(n)` => `n`) and treating forms inside `define` the same as `let`.
No, because non-curried functions are a feature. If we did that, every function would be curried. Which is nice, but non-curried functions are faster (lots of lambdas and wasteful copies are avoided using the equational rewrite notation), so they should be definitely accessible.
Many questions arise, for a small amount of benefit (imo), so not sure it's worth it.
For me, these projects (such as HVM) are great research stuff showing us how to do (or not do) things.
Idris2, I can't comment much. All I know is that I envy its unification algorithm and synthesis features. Its syntax is more haskellish, if you like that. It seems less mature in some aspects, though. Very promising language overall.
Wow; that certainly is impressive.
Is it just me, or does that look similar to the Fast Fourier Transform (FFT) [1] speedup (against the regular Fourier Transform implementation)?
My God, this is written so matter-of-factly that it hurts.
To me, Lisp is awesome BECAUSE of its uniform syntax!
To me, Lisp is waaaaaay cleaner and always unambiguous!
I guess what I'm saying is that it's subjective.
> I prefer the cleaner Haskell syntax.
"Haskell syntax" is a pretty bad design in my experience. Firstly, it isn't just one thing: there's Haskell 98 and Haskell 2010, but most "Haskell" code uses alternative syntax from GHC extensions (lambda case, GADTs, type applications, view patterns, tuple sections, etc.). (Frustratingly, those extensions are often applied implicitly, via a separate .cabal file, which makes parsing even harder; plus lots of "Haskell code" is actually written as input for a pre-processor, in which case all bets are off!)
GHC alone includes two separate representations of Haskell's syntax (one used by the compiler, one used by TemplateHaskell). Other projects tend to avoid both of them, in favour of packages like haskell-src-exts.
I've worked on projects which manipulate Haskell code, and getting it to parse was by far the hardest step; once I'd managed to dump it to an s-expression representation the rest was easy!
Up and coming Languages I am excited about -
1. Roc - https://www.youtube.com/watch?v=vzfy4EKwG_Y
2. Zig - https://ziglang.org/
3. Scopes - https://sr.ht/~duangle/scopes/
4. Gleam - https://gleam.run/
5. Odin - https://github.com/odin-lang/Odin
6. Koka - https://github.com/koka-lang/koka
7. Unison - https://github.com/unisonweb/unison
Lets you mix and match other libraries with their native ABI as it compiles to C, C++, ObjC and JS + has excellent FFI.
Interesting read about NIM-
https://www.quora.com/Why-hasnt-Nim-Nimrod-become-as-popular...
Key points:
- Nim lacks official support to multiple inheritance or interfaces (as that in Java) or mixin (as that in Ruby). It feels uneasy when implementing common design patterns, which is important for middle to large-sized projects.
- The error messages emitted from Nim compiler looks a bit obscure. Sometimes you have to guess or google to comprehend what really happens in your code.
These seem like pretty good problems compared to common annoyances and caveats in many other languages.
> Nim lacks official support to multiple inheritance or interfaces
Multiple inheritance is a bit of a trainwreck IMO (see diamond problem).
The language isn't designed around OOP, and is instead procedural with metaprogramming for extension. You get more bang for your buck this way, but for people with their head in inheritance it’s probably a shock.
Personally, from a conceptual level, I find that Koka and Roc provide some of the more interesting developments in PL design. For anyone wondering, as to Roc (which I don’t think has an implementation or even a full description yet) I am particularly interested in their concept and implementation of the ‘platform’/‘application’ layer idea. As to Koka, the papers the team has generated are almost all excellent. The paper describing Perseus, the GC/resource tracking system, and the compiler implementation review are stunning.
* Effect system
* Built in code distro distribution. As in: call a function on a different node that doesn't have that code yet
But most interesting: stops storing code as plain text files, but in a database instead. This breaks many workflows but could offer quite a few interesting properties.
Any idea how hard it would be to extend with a nondeterminism operator, as in Plotkin's parallel or? (fork a computation and terminate if either branch terminates)
Every third paragraph feels like the autogenerated flavour text gibberish of a kerbal space program mission description.
I actually feel like I kind of understand it now - kudos to @LightMachine!
> Notice also how, in some steps, lambdas were applied to arguments that appeared outside of their bodies. This is all fine, and, in the end, the result is correct.
But one of the example steps is this transformation:
dup a b = {x0 x1}
dup c d = {y0 y1}
(Pair λx0(λy0(Pair a c)) λx1(λy1(Pair b d)))
------------------------------------------------ Dup-Sup
dup c d = {y0 y1}
(Pair λx0(λy0(Pair x0 c)) λx1(λy1(Pair x1 d)))
I think I’m missing a subtlety. This step substitutes the a/b superposition into the lambdas, but I don’t see how it knows the the x0 goes into the first lambda and the x1 goes into the second one. After all, in HVM bizarro-land, lambda arguments have no scope, so the reverse substitution seems equally plausible and even syntactically valid: (Pair λx0(λy0(Pair x1 c)) λx1(λy1(Pair x0 d)))
This substitution produces two magically entangled lambdas, which seems fascinating but not actually correct. How does the runtime (and, more generally, the computational system it implements) know in which direction to resolve a superposition? (Pair λx0(λy0(Pair a c)) λx1(λy1(Pair b d)))
You get: (Pair λx0(λy0(Pair x0 c)) λx1(λy1(Pair x1 d)))
Does that make sense?As an aside it may be a convenient reference if you laid out the history of Formality/Kind as I think a large portion of your target audience already knew about Formality. The rename gets a little lost in IMHO, at first glance at least.
Of course one place I don't cache is in function application because the language is not pure (so we want side effects to occur again), but this makes me think it might be really good to derive which functions/subexpressions are pure and allow the cache to be retained through function calls for those.
I'm gonna keep a close watch on this. I should probably read more white papers :)
Sounds similar to the evaluation model of the Nix expression language (the pure functional programming language used by the Nix package manager). It caches evaluation results, which (a) avoids re-computing identical expressions, and (b) allows some infinite loops to be detected.
See https://www.researchgate.net/publication/222827743_Maximal_L...
As another commenter’s remarks imply; some of the so called benefits for what are called optimal reduction strategies are limited to efficiently manipulating data structures that are represented by lambdas. And that’s also probably why this is Untyped. Makes it easier to embed / support such an encoding
This evokes a similar feeling to when I learned that Lisp wasn't John McCarthy's target programming language. I began to wonder how powerful that intended language would have been, if everyone hadn't stopped at the bootstrap language.
There is great power in what is shown, and I suspect even more power in what it enables. I look forward to see where this goes, there clearly could be another similar leap.
Question: Does this mean it's possible to rework Rust to remove the accursed borrow checker? (I tried learning Rust, and stopped at the Borrow Checker, so I gotta ask)
They further imply that they are non-NULL and their pointee is dereferenceable (no segfault; correctly aligned) and a valid bitpattern for the pointee type. These assumptions are provided to LLVM, allowing a bunch of optimizations already. Mutable references further guarantee `noalias`/`restrict`, as a mutable reference to some memory location implies no other references to that memory exist, an exclusivity enabling aggressive store-to-load forwarding and elimination of redundant stores.
The non-NULL nature makes `Option<&T>` pointer-sized, so semantically nullable pointers have no memory cost and just force explicit handling of their NULL state when accessed (there are convenience method-syntax functions and `match` statements who's cases (patterns, in Rust terms) look like type constructors on the left side of a `=>` and have the inner values bound to the identifiers on the expression to the right of the `=>`). If a function itself returns an `Option`, `foo?` can be used to just unpack a `Some(bar)` and `return None;` on the nullable case. This syntax also works with `Result<T, E>` which allows conveying detailed errors (similar to exceptions, but with performance matching C's negative return codes instead of C++'s unwinding).
References are written as `&T` for immutable ones and `&mut T` for mutable ones. Raw pointers are written as `const T` for immutable ones and `mut T` for mutable ones.
These guarantees of no-mutable-shared-memory not guarded by explicit `Atomic` types (or concurrent datastructures (I count `Mutex` and `RwLock`)) carry semantic guarantees beyond what an auto-`.clone()` and/or GC can provide.
I highly suggest you to try Rust again, but this time using non-lexical-lifetimes (NLL(s)), which shift the lifetime enforcement from simple lexical scope to a control-flow-graph based reachability. They lack the unintuitively-restrictive limitations particularly notable with loops, while still simplifying to abstract over combinatorial path explosion.
BorrowChk is your friend, I promise. Just ask in e.g. the Community Discord if you're getting rejected for no good reason despite trying to understand/work around it's complaints. You're not really fighting it; it's more like a sparring partner than an enemy.
Also, `.clone()` and `#[derive(Debug, Clone, Copy)]` are your friends when fighting BorrowChk.
Sure, with the "unsafe" keyword. But you probably don't want to do that in most cases as it will open new failure modes the compiler can protect you from. As another commenter pointed out, the borrow checker has become more clever over time (with NLLs) so if you tried Rust years ago i definitely recommend to give it a new try.
At first i was a little confused with compiler errors, but they're pretty good (unique error code, and usually suggestions for fix) but over time i realized the compiler was not my enemy but rather a very friendly reviewer preventing me to make obvious mistakes in my code. Nowadays i pseudocode my Rust on paper then write the actual code, then spend some time arguing with the compiler, but the design-code-debug lifecycle has been much reduced (compared with other languages) because there are very good chances the program works perfectly as soon as it compiles, and when it doesn't in 99% of cases that's because my logic was wrong to begin with, not because of a programming error.
In general this "optimal" strategy seems to assume infinite memory that's all accessible at the same speed.
In a similar vein, if typical strategies for unstable sorting of arrays in place use quicksort splitting for long spans and insertion sort for short spans it doesn' mean that either algorithm is "better".
I don't think "massively parallel" is all that good for performance either, because battery life is part of that.
In laymen's terms, it means that you're doing a variant of call-by-need, except you can be absolutely sure you don't copy applications without need.
I am personally skeptical it's a useful idea at all. Machine computation is what we really care about (how fast can you push instructions through the pipeline) and graph reduction systems were already all the rage in the 80s. We gave up on them because they didn't interact with the cache well.
So, this is the "motherload" of all graph reduction strategies, yet it will get absolutely trounced in settings like numerical computation.
The cache failures was because the cache was to small. Whereas now we care about CPU OP data dependencies.
I suspect at most once per hardware thread is a faster choice - repeating some calculation for decreased communication.
Or you need a static assignment of reduction to threads which means predicting how long each reduction will take.
Basically, beta-reduction is the single primitive that performs all computation in the lambda calculus. When you apply the function mapping x to M, to argument N, the result is M with all occurences of x replaced by N.
Let's say I have a function `add1` `add1 = \x -> x + 1` (A function that takes a value `x` and then computes `x + 1` And then I write the program: `add1 2`
If we want to evaluate this program, first we substitute the variable to get `add1 2` -> `(\x -> x + 1) 2`
Then we perform "beta reduction" to get: `(\x -> x + 1) 2` -> `2 + 1`
Then it's simple arithmetic: `2 + 1` -> `3`
"The point" here is to have formal rules for defining what a computation means. This helps with writing proofs about what programs do, or what is possible for programs to do.
For example, say I have a simple program with static types. I might want to prove that if the programs type checks, type errors will never occur when you run the program. In order to do this proof, I have to have some formal notion of what it means to "run the program". For a functional language, beta reduction is one of those formal rules.
1 is typically defined in the Church encoding as \f -> \x -> f x. Addition is \m -> \n -> \f -> \x -> m f (n f x). Finding the Church encoding of 2 is left as an exceecise.
We can then use beta reduction to compute 1 + 1 by replacing m and n with 1:
1 = \f -> \x -> f x
(\m -> \n -> \f -> \x -> m f (n f x)) 1 1 =>beta
\f -> \x -> 1 f (1 f x)
\f -> \x -> 1 f x = (\f -> \x -> f x) f x =>beta
1 f x = f x
\f -> \x -> 1 f (1 f x) <=> \f -> \x -> 1 f (f x)
(\f -> \x -> f x) f (f x) =>beta
\f -> \x -> f f x - the Church encoding of 2.
This is how computation using beta reduction only looks like.If we expand the discussion from "lambda calculus" to "pretty much every programming language", then "beta reduction" becomes "calling a function with an argument"
> Is there any practical example on this?
Given the above, you'll be able to find beta-reduction in practically all software ever written ;)
In contrast, beta-reduction rewrites expressions in the same language. It's a "reduction" since it brings expressions closer and closer to their "normal form" (i.e. a halted program); although some expressions have no normal form (they run forever, i.e. the Halting Problem).
In Javascript syntax, the following expression is not in "normal form":
(function(x) { return x + 5; })(42)
If we apply beta-reduction to it, we get back the function body, with occurrences of 'x' substituted by '42': 42 + 5
That expression can no longer be beta-reduced; it is "beta-normal". Lambda calculus only uses beta-reduction (although there are "alpha" and "eta" replacements, which are more like refactorings).However, Javascript has more reduction rules than just beta-reduction; in this case we can use its numerical operations, to reduce the expression further:
47
That is in normal form for Javascript; i.e. the program has halted.Note that it's not "compilation", since we stayed in the same language at all times, just replacing one expression with an equivalent expression.
People attempt to program interactive systems with Haskell but then they spend most of their time adapting their program to GHC’s particular evaluation strategy. Usually the optimized program ends up convoluted and nothing like how Haskell code should look, additionally still with no guarantees on the run time.
Looking through that repository, I believe we have extremely different definitions of "vast majority."
> The vast majority of the code in that repository is in the IO monad and uses carefully placed “!” eager evaluation annotations.
Do you have anything to back that up?
First of all rendering is not by definition I/O except in the trivial sense that all functions take input and produce output. A pure 3D rendering function takes game state as input and produces a list of triangles + attributes to draw as output.
Even if that were true it would only further validate my point.
> Do you have anything to back that up?
Yes, I already showed that Render.hs validates my point.
This is the claim we're talking about. Since you're into facts, not subjective opinions, show some evidence that the vast majority of the code in the repository is in the IO monad and uses carefully placed "!" eager evaluation annotations. Just to be clear, you haven't done that yet. That's a fact, not a subjective opinion.
https://github.com/rainbyte/frag/blob/master/src/BSP.hs
On every source file:
{-# LANGUAGE BangPatterns #-}https://github.com/rainbyte/frag/blob/master/src/BitSet.hs
https://github.com/rainbyte/frag/blob/master/src/Command.hs
https://github.com/rainbyte/frag/blob/master/src/Curves.hs
BangPatterns is a normal language extension to have enabled, was this used heavily? (hint: it's not enabled on "every source file.") You listed two of 28 files there, I'm assuming to try to show that the vast majority of the code in that repository is in the IO monad? You went from "vast majority" from one file to now two files. I'm looking for objectivity here, as you seem to be into. Let's see some numbers. I think anyone that glances at that repo would need some convincing of your claims.
Render.hs is 211 of 5580 lines of Haskell in the repository, by the way.
The vast majority of systems people build do not have hard real-time requirements. People have built video games, real-time audio, and all sorts of other soft-real-time systems with Haskell
No they aren’t soft real time systems either, most applications in general a don’t satisfy soft real time requirements.
Most people hack it but hacking it with Haskell requires rewriting your pure functional code into a form that strongly resembles procedural languages like C. This defeats the purpose of a lazy pure functional programming language.
There's only one industrial Haskell codebase I've worked on, and although parts of it were very procedural, that was probably less than half the codebase, even including the IO-bound code. Sure it's one way to use the language, but it's certainly not the only one.
Most applications are buggy and crappy. Sure, if you’re churning out minimally passable garbage for a paycheck, then don’t worry about correctness or timing requirements and pray for the best. Cf. Slack desktop client. If you are producing something that necessarily has a higher quality bar then these things matter, e.g. an aircraft control interface. Haskell isn’t up to the challenge (imagine indefinitely waiting without feedback for a long-lived heavily nested thunk to evaluate when you press “land” ouch).
And for the record Java and other garbage collected languages are not generally suitable for interactive applications either. Anyone who has ever waited on a GC pause can attest to that. This is the exact reason Rust exists and why people continue to use C/C++ despite being inconvenient languages to use.