Turn O(n^2) reverse into O(n)
github.com
github.com
bufOps is a dictionary which holds a bunch of functions accessed with the getters on it. For the sake of this comment, we can concretize and use (buf_empty bufOps) as [] and (buf_append bufOps) as ++.
This code then essentially performs:
foldr (flip (++)) [] xs
Which, if you look up the definition of foldr, is: ((([] ++ xN) ++ ... ) ++ x2) ++ x1
And a definition of ++ is of course: [] ++ ys = ys
(x:xs) ++ ys = x : (xs ++ ys)
This means that for lists of this sort a ++ b runs in time O(length a), because it has to descend down the leftmost list to find the empty list -- only once it finds [] can it "work its way backwards" to append elements from a onto b.If each of the x1, x2, ... xN has m elements, then we do 0 + m + 2m + ... + N m = m * N * (N + 1) / 2 operations. Each ++ will do about N operations and we'll do about N of them; it's O(N^2).
The new algorithm, `concat (reverse xs)`, works because `xs` is just a list which can be reversed by traversing down it in O(N) time, then those can be merged together in O(N * m) time.
If a Haskell expert (e.g., he authored hdirect -- an IDL compiler and interface with Win32 COM -- sadly defunct now) makes this kind of mistake, how are mere mortals supposed to reason about algorithmic efficiency?
edit: http://www.reddit.com/r/haskell/comments/1sh67u/the_reason_w... This comment by the patch author indicates that actually tracking it down was a fairly straightforward profiling job.
It'd be interesting to think how one could encode performance characteristics into equations.
C makes performance easy and correctness hard. Haskell makes correctness easy and performance hard.
The trick is, in most programs, you only need performance for a tiny subset of the program. You need correctness throughout the whole program.
We have to bridge that gap:
1) Either use a compiler that hides away the operational details
2) Or use a language that directly maps to the operational semantics, but then is necessarily far away from the denotational ones
That's not really an interesting property of Haskell, though. You can always write correct but slow code in any language.
> It'd be interesting to think how one could encode performance characteristics into equations.
I've seen the concept of encoding performance in types played around with, but I can't find actual work (if any) that's been done on it.
Yes, but Haskell makes that exceptionally easy.
Most of the Lisp hackers are usually knowledgeable about analysis of algorithm complexity.
Alan Perlis comment talks about inefficiency of Lisp programs due to a lot of (meta) abstraction and indirect mapping of abstract software to hardware.
You profile, and then you look at the suspicious parts.
http://www.reddit.com/r/haskell/comments/1sh67u/the_reason_w...
I can't tell if you're being facetious.
It's a good safety barrier against hype things.
Old version:
foldr (flip (buf_append bufOps)) (buf_empty bufOps) strs
foldr is a combination map/reduce in one. It takes a value (the accumulator), runs a function on the rightmost value in a list called str (that's why it's 'foldr'), and combines the two together into a new accumulator value.Let's say we have a list of numbers, the addition operator, and an accumulator of 0.
foldr op acc list
foldr + 0 [1, 2, 3, 4]
a = 0, list = [1, 2, 3, 4]
a = 0 + 4, list = [1, 2, 3]
a = 4 + 3, list = [1, 2]
a = 7 + 1, list = [1]
a = 8, list = []
Result is 8
One thing to note is that this walks the list 'backward', and in Haskell it's a singly linked list. That means that each time it has to walk the full list. I believe this is why it's O(n^2).It looks like the accumulator starts with (buf_empty bufOps), which from context I assume is an empty buffer structure. The operator is (flip (but_append bufOps)), which looks like it adds values onto the bufOps structure when called. Each time it goes through this process, it appends one element.
Because they're using foldr and the list is walked 'backward', this has the natural side effect that the order of the items in strs is reversed as it's added to bufOps.
New version:
buf_concat bufOps $ reverse strs
The $ is a way of controlling precedence in Haskell, you can replace it with parenthesis. So we get: buf_concat bufOps ( reverse strs )
So reverse just reverses the order of a list, getting things in the order the code wants them. Then it calls buf_concat with the current structure and the items it wants added. So instead of taking one existing list and adding 6 new elements at the end one at a time, it takes one existing list and adds a list of 6 elements to the end once.It seems like a relatively simple change. I wonder if using foldl or foldl' to avoid having to rewalk the list every time would have performance similar to the new line.
I find the new line simpler and cleaner either way, but was the choice of a right-fold the cause of the performance problem?
No, foldr walks through the list forward, and exactly once. Your example is evaluated as
foldr (+) 0 [1, 2, 3, 4]
= 1 + foldr (+) 0 [2,3,4]
= 1 + (2 + foldr (+) 0 [3,4])
= 1 + (2 + (3 + foldr (+) 0 [4]))
= 1 + (2 + (3 + (4 + foldr (+) 0 [])))
= 1 + (2 + (3 + (4 + 0)))Even if you understand lazy evaluation in theory, I recommend taking some non-trivial (but not too large) expression and :step'ing through the whole evaluation sometime, just to train your intuition.
$ is a way of controlling precedence in Haskell
But much more importantly, is `apply`. acc = "";
for (var i = 0; i < strings.length; i++) {
acc += strings[i]
}
The new version is something like this: strings.join("")
The second version can pre-allocate a string and copy characters into that string under the hood. The first version has to allocate a new string and recopy the characters on every iteration.If your array only has one future---ie there are no references to the unchanged array around---you can re-use the old array. That means you get to mutate in place but still pretend you have immutability.
That is not how modern persistent data structures are implemented. Please do not talk about immutability as if it necessarily means having a naive implementation like this.
Egregious mischaracterization.
Said "tricks" are an entire branch of research in CS.
Now, I realize that in my original post, I might have given the wrong impression. I thought that by my second post I was being clear enough, but perhaps I wasn't. Let's try take three:
Immutable data structures do not necessarily guarantee less copying, or necessarily imply a performance gain. A data structure which does not lend itself well to immutability, such as a C-style array, can lead to very inefficient code when used in an immutable fashion. The C-style array or a variation thereof is also the default in most current languages, including Java, Python, C++, Ruby, and many others, so this is hardly a thing of the past. It's important to be aware of the performance characteristics of the data structures one is using, respective to the way in which they are used.
Only in a naive implementation. Clojure, for example, has a persistent vector that only requires O(log32 n) copying, which grows so slowly as to be effectively O(1).
See: http://hypirion.com/musings/understanding-persistent-vector-...
In the C++ world, libstdc++ strings have copy-on-write semantics, which (as far as I heard) turned out to be terrible because you have to do reference counting instead, and with multithreading it requires atomic operations, which is slower than copying for small strings.
There's alternative representations with different trade offs, such as Data.Text
buf_append bufOps = (++)
buf_concat bufOps = concat
Since (++) has a running time that's linear in the length of its left argument, you want to treat it as a right-associative operator to prevent quadratic runtime when joining a bunch of strings.But, going back to the patch in question, the original code was effectively this:
foldr (flip (++)) [] strs
Since flip had been applied to (++), the resulting operator you now want to treat as left associative to avoid quadratic blow-up. The code, however, uses the right-associative fold, foldr, to join the list of strings str.The patch fixes this problem by replacing the code with the equivalent code
concat (reverse strs)
And how is concat defined in the libraries? Looking at the source [1], it's concat = foldr (++) []
Thus the fix is basically foldr (++) [] (reverse strs).
This version reverses strs, at linear-time cost, to be able to apply the normal, unflipped (++) with a right-associative fold and thus avoid the dreaded quadratic blow-up.[1] http://hackage.haskell.org/package/base-4.6.0.1/docs/src/GHC...
EDITED TO ADD: Also, if you knew the buffers were vanilla Strings, you could even eliminate the reverse overhead since
foldr op z xs == foldl (flip op) z (reverse xs)
for all finite lists xs. Thus you could flip (++) and use foldl instead of foldr to get an efficient implementation like this: foldl (flip (++)) [] strsYou can append in O(1) time using difference lists. They don't have all the niceties of Prolog difference lists, but they are still great if you only have to append:
Monads.
However, there aren't any rewrite rules for list-based code, at least with the standard library. Rewrite rules are generally used for libraries designed explicitly with performance in mind like Vector and Bytestring; the standard String type and the Prelude don't fall into this category.
[1]: http://stackoverflow.com/questions/578063/what-is-haskells-s...
[2]: http://www.haskell.org/ghc/docs/7.0.1/html/users_guide/rewri...
Hacker News.
From David Foster Wallace's "E Unibus Pluram".
But there comes a point in time where you have to project that stuff all away and get down to the brass tacks of what's happening in your system, leaving behind the Platonic heaven you've constructed for yourself. As the patch proves without a doubt, this is very possible to do in Haskell. But sadly, the general ethos of the Haskell community does not bend in that direction. I am a lover of the language, but at the same time a vocal critic of the community in that respect.
In this day and age, having everyone worry about performance is patently absurd. Most programs don't need to be heavily optimized, so prioritizing programmer efficiency and correctness just makes sense. And Haskell does have the libraries, language features and people to optimize the parts that need optimization!
(concat . reverse . map reverse) strs
I could easily replace it with the following code, which is simpler, faster, and reduces memory churn: (reverse . concat) strs
You can prove that these two implementations produce the same results (see [1] for a step-by-step proof). After you do the proofs for a while, you'll start seeing similar optimizations everywhere – and claiming them.(An example for something computers can come up with is free theorems, but in this case that wouldn't have helped.)
foldr (flip (buf_append bufOps)) (buf_empty bufOps) strs
hlint doesn't know what 'buf_append bufOps' is, or 'buf_empty bufOps' or 'strs'.If you saw 'foldr (flip (u v)) (x y) z', would you immediately go 'ah, n^2! better throw a warning'? If not, why do you expect hlint to warn?