Home

Awesome

Normalization-bench

This repo contains some benchmarks for normalizing untyped lambda calculus and doing beta-conversion checking on it. WIP, I plan to add more benchmarks and languages to this and also different implementations in particular languages. PRs are welcome.

My motivation is type checking, where the more sophisticated type systems, such as dependent type theories, require evaluating open lambda terms at type checking time (and comparing them for conversion).

I've been using relatively simple AST interpreters for this purpose, and I wanted to see what kind of difference compiled HOAS (higher-order abstract syntax) makes in performance. I thought that perhaps a modern JIT language would be good, because it would allow us to compile syntax to machine code via HOAS on the fly. In contrast, GHC would be clumsier for compiled HOAS because of the dependency on ghc and slow compilation. Or so I thought.

Setup

Test computer:

Scala:

nodejs:

F#:

GHC:

Coq

OCaml

smalltt

luajit

Benchmarks & implementations

Benchmarks are normalization and beta-conversion checking on Church-coded unary numbers and binary trees.

All deep HOAS implementations are virtually the same; they use idiomatic ADTs for terms and HOAS values, or straightforward ADT emulation in the case of javascript.

In GHC, we have a call-by-need and a call-by-value HOAS version. The difference in the source code is just a couple of bangs. We also have an AST interpreted CBV normalizer. The interpreter is fairly efficient, but we don't have any optimization on the object language.

In Coq, we use well-typed impredicative Church encodings, which are slightly more expensive than the untyped ones, because it also abstracts over a type parameter. We use eq_refl for conversion checking. Smalltt is similar to the Coq implementation.

I made a non-trivial amount of effort trying to tune runtime options, but I don't have much experience in tuning things besides GHC, so feel free to correct me.

Results

Times in milliseconds. For Coq & smalltt results are from a single run. For everything else results are averages of 20 runs.

GHC HOAS CBVGHC HOAS CBNGHC interp CBVOCaml HOASnodejs HOASScala HOASScala HOAS graalvmF# HOASCoq cbvCoq lazyCoq vm_computeCoq native_computesmallttluajit
Nat 5M conversion9011223426870013810131246N/A3166312083359500stack overflow
Nat 5M normalization1011081671319763742031695921507260443094790411stack overflow
Nat 10M conversion208224695610139596021454462N/AOOM696550811681stack overflow
Nat 10M normalization227269439965371839085833too long3340578011216131811148stack overflow
Tree 2M conversion136114274117396118137305N/A4775616914253576
Tree 2M normalization867616324432385841514124870296011033462533
Tree 4M conversion294229588150827271250630N/A646127613021429OOM
Tree 4M normalization19219434353463518116431191365148817291983745OOM
Tree 8M conversion723457126825317266255161232N/A1279242029012371OOM
Tree 8M normalization43652571612981398731332593032752871346434971544OOM

Commentary

F#. Performance here was the most disappointing. I had hopes for F# since it is the nicest language of the JIT pack by a significant margin, and I thought it would be tolerable or even superior to Haskell to implement everything in F#, because of the support for high-performance non-persistent data structures, monomorphization, unpacked structures, etc. Now, there could be some GC option which magically repairs this, but I've tried and have not found a way to make it go faster.

Scala. I had never used Scala before, and I found the language relatively pleasant, and definitely vastly better than Java or even Clojure. That said, performance seems good for trees but degrades sharply from 5M to 10M with natural numbers; perhaps there is some issue with deep stacks.

GraalVM Scala had excellent performance with trees, but there was a serious slowdown with natural numbers, indicating some issue with deep stacks.

nodejs. Somewhat disappointing all around, although better than F#.

GHC CBV interpreter is doing pretty well. It's already at worst half as fast as Scala, and there are a number of optimizations still on the table. I'd first try to add known call optimization.

OCaml HOAS is pretty good, with surprisingly fast tree conversion, although worse than GHC elsewhere. The effect of free memory RTS options was huge here: 2-10x speedup.

Coq. I note that while some benchmarks (e.g. cbv natural numbers) benefited enormously from the free memory for GC, some of them slowed down 10-30%. Overal the free memory RTS setting performed better than defaults. It's worth noting that native_compute performs significantly worse than OCaml HOAS, though the two should be similar; probably native_compute does not use flambda optimization options at all.

smalltt is slower than the barebones GHC interpreter, which is as expected, because smalltt has an evaluator which does significantly more bookkeeping (serving elaboration purposes).

luajit appears to be very poorly optimized for closure workload. I was not able to figure out a way to increase stack sizes, hence the natural number benchmarks don't finish. Moreover, the tree bencharks run out of heap space above 2 million tree sizes, on my 8GB RAM system.

General comments.

TODO

Preliminary analysis & conclusions

Modern JIT platforms don't seem to be good enough at lambda calculus. From the current benchmarking, JVM seems to be the best choice. However, I haven't yet benchmarked call-by-need, which could possibly be a significant performance hit on JVM. At the moment it does not seem worth to write high-performance proof assistant implementation in any of the JIT languages, because plain hand-written interpreters in GHC are much more convenient to maintain and develop and have comparable performance to JIT HOAS. Perhaps compiling directly to CLR/JVM bytecode would be better, but I am skeptical, and I would be hard pressed to implement that myself.

A very high-effort solution is the Lean way: implement runtime system from scratch. I would also like to do this once for a minimal untyped lambda calculus, and just try to make it as fast as possible. I am sure GHC HOAS can be beaten this way, but I am not sure that the effort is worth it.

I am entertaining more seriously the solution to use GHC HOAS in real-world type theory implementations, by primarily using interpretation but constantly doing compilation via GHC in the background, and switching out definitions. Combined with more sophisticated diffing and incrementalization, this could have good UX even in a single module.