"As fast as C" has been buzzing around Haskell circles, so I figured I'd write a short blog post on a benchmark I stumbled on that's actually faster than its equivalent in Rust for small inputs. I have good things to say about both languages, so stick around.
Part I: The Benchmarks
The task is simple, if contrived: given a string, return all proper suffixes of a string. That is, if given "tails", we should return "ails", "ils", "ls", and "s".
The Haskell is simple, though advanced. I am quite convinced that it is the
fastest solution available (using String
, at least). Note that it is more
general than the Rust code.
import Data.Functor.Foldable
suffix :: [a] -> [[a]]
suffix = hylo algebra coalgebra . drop 1
algebra :: ListF [a] [[a]] -> [[a]]
algebra Nil = []
algebra (Cons x xs) = x:xs
coalgebra :: [a] -> ListF [a] [a]
coalgebra [] = Nil
coalgebra (x:xs) = Cons (x:xs) xs
The Rust is similarly simple, though I am less confident that it is the fastest
possible implementation; note that it must return a vector of owned String
s so
as to be comparable to the Haskell.
pub fn suffix_vec(s: &str) -> Vec {
let mut vec = Vec::new();
for (j, _) in s.char_indices().skip(1) {
vec.push((&s[j..]).to_string());
}
vec
}
The code to benchmark both of these is available here (or on github).
The results are quite dramatic on both ends:
Input Size | Lanuage | Time |
---|---|---|
5 | Haskell | 60.29 ns |
5 | Rust | 299 ns |
20 | Haskell | 430.6 ns |
20 | Rust | 1.374 μs |
100 | Haskell | 7.865 μs |
100 | Rust | 6.172 μs |
1000 | Haskell | 692.2 μs |
1000 | Rust | 97.88 μs |
Part II: Analysis
First, we see why treating String
s as linked lists of
Char
s is a bad idea. An O(n) algorithm becomes... not O(n).
Second, I think this is an apt illustration of why I like Rust despite Haskell being my main squeeze. Rust is the first language I've learned where you can do things that you couldn't in Haskell (here, distinguishing references and values).
But these results are nonetheless surprising. Not only is it generally held that C/Rust are fast on an order not touchable by higher-level languages, but also the fact that the overlap occurs in an entirely reasonable domain (most words are strings of length between 5 and 20) and results in Haskell code over four times faster on the lower end and around 1.5 faster on the upper end.
A small bit of this is the use of the hylomorphism. With pattern matching alone, Haskell is slower on short strings. I think it hints at an undervalued aspect of performance: abstractions.
Another bit is Haskell's much-derided laziness. I'm not sure how much this contributes, but I suspect that GHC is able to optimize away some of the control flow, while Rust allocates for the loop in all cases.
So what to conclude? Unfortunately, we can't make any sweeping statements.
Despite Rust's signal deficiency here, it's hard to imagine an application
where this particular function would be called repeatedly.
Moreover, if I'm right and the performance is due to optimizing away lists, it's
hard to imagine this being replicated on larger strings with something like Text
.
Of course, that's not the whole story: it's easy to imagine being blindsided by a similar function, especially as code complexity grows. Given that Rust is a systems programming language, I hope to see its performance to catch up to Haskell as things progress.