I’ve been for a long time experimenting with the Abstract Algorithm, a very elegant machine that executes functional languages “optimally”. Explaining how it works is out of the scope of this post, but it suffices to say it transforms λ-terms to a specific kind of graph, which is then reduced by consecutive atomic (constant-time) parallel rewrites.

From “The Optimal Implementation of Functional Programming Languages” by A. Asperti and S. Guerrini.

That algorithm is not only very elegant (beta-reduction is symmetric!), but also very fast, beating asymptotically Haskell, OCaml, Elm, JavaScript and any other “functional” programming language you can think of. It is so fast that, sometimes, it beats even common sense. As an example, I’ve observed it can often apply a function N times in O(log(n)) atomic operations (wut?).

If that sounds bizarre enough to you, I hope this recent experiment will leave you as perplex as I am. As far as complexity theory goes, the more things you do, the higher the costs of your algorithm. Common sense, right? Well, not for Absal. In some cases, a function can behave as if it had… negative complexity? Wut? Here is an example:

This snippet implements 3 Haskell functions:

copy recurses over a bitstring and returns itself (copy(“0000”) = “0000”).

recurses over a bitstring and returns itself (copy(“0000”) = “0000”). inc increments the bitstring by 1 (inc(“0000") = “0001”).

increments the bitstring by 1 (inc(“0000") = “0001”). add increments a bitstring by N (add(2,”0000") = “0010”).

The complexity of the functions above are pretty straightforward to analyze:

O(d) for copy (as it is just a recursive identity)

for (as it is just a recursive identity) O(1) for inc (amortized)

for (amortized) O(n) for add (because it applies inc n times)

So far, nothing unusual. But what if, instead of compiling it with GHC, we used Absal? Well, the first thing you’ll notice is that add suddenly behaves as if it had sub-linear complexity. Here is a table showing the amount of atomic graph rewrites required to compute add(N,ex) for different N s:

That is, for example, computing add(64,ex) (which returns the bit-string 00000010 ) requires 6159 atomic graph rewrites. As you can see, the function is clearly scaling sub-linearly, despite calling an operation N times. Strange, but that’s just the same kind of “magical asymptotic boost” that I’ve been observing for years already. Nothing new here. The real bizarreness, though, happens when we combine copy with add by composing them together. If add(64,ex) takes 6159 steps to compute, then copy(add(64,ex)) should take at least 6159 steps, right? Well, here is the former table, updated:

As you can see, copy(add(N,ex)) is actually much faster than add(N,ex) , despite doing the same thing plus an extra traversal. Wut? Not only that, its complexity seems to be logarithmic, making it actually as efficient as a standard “add with carry” implementation; despite being a dumb “inc n times” recursion. Wut? How can calling an extra function make your program run faster? How can a dumb, linear implementation run logarithmically? How do we even begin to analyze the complexity of copy in that context? Under which circumstances the asymptotics of a function can be magically reduced like that? Could that be applied to problems like the DLP (breaking most crypto-currencies)? What is even going on here?

Well, let me explain carefully. Just kidding. I have no idea. If anyone has, though, please let me know! Of course, there is no such a thing as negative complexity; that experiment just shows that composing a function with another can reduce its complexity in Absal, and my crypto is (probably) fine. But still, this experiment is absolutely anti-intuitive, if not bizarre, and begs an explanation. It might be helpful to render the graphs and watch them being reduced, but I’ll leave that exercise for later. Hopefully, though, this post will inspire some curious mind to research it further (and please warn me if you actually solve the DLP).