Initial tests of Tries: Follow Up

18/04/2009

This is to wrap up my previous post about Tries and my attempts at an implementation, and to summarize some of the things I think I learned about implementing a Trie structure in Haskell.

Complexity is Slow

My initial idea of using a container type at each node to store the collection of branches was a good idea for testing (I could easily swap in Data.Map, or my own simple binary tree) and convenience (I could use Data.Map’s built-in functions to insert the element of the list key into the the container); but it turned out to be bad for performance.

One thing I noticed was that Data.Map is not strict in its ‘value’ field, which is where I was storing more Trie types with more Maps to hold more Trie types… this seems to be simply too complicated to be fast.

Simplicity wins over Balanced Trees

Magnus Jonsson replied with a complete re-working of my implementation which uses different constructors in the Trie type to handle both the linear node-to-node movement through the key, and the searching through the set of branches. This gives a good illustration of the concept. His is the winner in terms of performance.

He doesn’t use any techniques to keep the tree balanced but his implementation out-performs my Trie with its cumbersome (but balanced) Maps at each node. I also found that when I swapped in a module MyMap (a simple non-balanced tree) in place of Data.Map that the code was about twice as fast in both tests I performed.

I would like to play with approaches to keeping the Branch trees balanced (or almost-balanced) in a lightweight way. Perhaps a splay tree or an adaptation would make sense .

Radix Trees don’t make Sense

EDIT: Jake McArthur has pointed out that my characterization of traversing data structures in FP and conclusion in this paragraph are probably incorrect.

I chose to use a special “bucket” constructor to hold the tails of keys in their original list form when the tail was unique; e.g. when inserting the key “washing” into a Trie containing only the key “waste”, the “was” would overlap and each element 'w'--> 'a' --> 's' would reside in its own Trie constructor, but both the “te” and “hing” would be stored as intact lists in separate ValBucket constructors, since there is no need to touch them yet.I thought extending this idea and implementing a radix tree might be even more efficient.

But this doesn’t make much sense in FP. Imagine the list type [] was defined in a more verbose way as follows:


data List a = L a (List a)
            | X

In our Trie structure, a sequence of key elements which have no branches (which would be reduced to a bucket in the radix tree) might look something like this:

Key ‘w’ (Key ‘a’ (Key ’s’ (Branches … )))

…whereas the same situation in an attempted radix tree might look like:

KeyBucket (L ‘w’ (L ‘a’ (L ’s’ X))) (Branches …)

…which is essentially identical. And since in functional programming when we “traverse” a data structure (e.g. to compare two keys) we are actual destroying it and rebuilding it as we go, it stands to reason that it should be just as easy to rebuild our structure with a constructor of a different name (Key vs. List).

Storing the tail ends of keys when we can makes sense because we avoid destroying the structure until we have to inspect it.

Summary of Tests:

These are not scientific, but I include them to give you an idea of the performance differences I was seeing. Tests were run several times and averaged, and compiled with: ghc --make -prof -auto-all -O2 -funbox-strict-fields.

The first test frequency.hs is described in my last post. The second test dictLookup.hs reads an alphabetized list of words and builds a dictionary Trie from the word to the word’s line number. It then reads a random list of words from a file and prints (word, line_number) tuples.

Tests of frequency.hs:

MagnusTrie Trie (with MyMap) Data.Map Trie (with Data.Map)
ticks: 21 29 39 58
total alloc. (MB): 100 133 101 235

Tests of dictLookup.hs:

MagnusTrie Trie (with MyMap) Data.Map Trie (with Data.Map)
ticks: 17 20 18 46
total alloc. (MB): 99 133 103 215

The whole set of tests and files can be downloaded with:

$ darcs get http://coder.bsimmons.name/code/Trie/

Unpack the texts.tar.gz file before running any of the code. Thanks for the patience with the long post. Let me know your thoughts.

UPDATE:
I finally was able to test wren ng thornton’s bytestring-trie library with one of my tests. I couldn’t profile the code (GHC complained the libs were missing) but I ran dictLookup.hs with BenchPress and the bytestring-trie modified version looked about twice as fast.

15 Comments

Some initial tests of Tries

16/04/2009

I have been interested in writing a Trie implementation to see how its performance compares to using Data.Map for storing dictionary-like data. I wrote a minimal implementation of Tries which exports insert,insertWith, and lookup definitions and can be used in place of Data.Map for those functions. I tested the implementation using a program frequency.hs which reads a source file and uses insertWith on each word to count the frequency of the words in a file; we then use lookup to print out the frequencies of a list of arbitrary words.

The Implementation:

The Trie uses a Data.Map at each node to provide quick access to each branch; I performed a test with a simple unbalanced binary tree for storing branches, but the performance was slightly worse.

We also store the unused tail of a list-key in a special ValBucket constructor so that we don’t need to store a bunch of singleton Maps. I was curious if laziness would provide the same benefit automatically (as the remainder of the list should be stored as a thunk until another key with the same prefix comes along, forcing evaluation), but my version without the buckets was pretty significantly slower.

Here is the Trie type:


data Trie a v = Node    { branches :: M.Map a (Trie a v) }
              | ValNode { branches :: M.Map a (Trie a v),
                          val :: v }    
              | ValBucket { bucket :: [a], 
                            val :: v  }
              | Val { val :: v }
              | Empty
               deriving (Show)

Initial Testing Results

It is quite likely that there are obvious performance problems with my Trie implementation and I know that my test script doesn’t provide a very good look at the data structure (it doesn’t provide much of a benchmark for lookup, spends an inordinate amount of time preparing the text for insertion, etc.)

I was disappointed with the results which showed no improvement with Trie when compared with the same test using Data.Map. Here is a quick graph comparing execution times of frequency.hs using Map vs Trie as the size of the input text increases:

(note: tests compiled in GHC using -O1 optimization, and timed with Benchpress)

Based on the improvement I saw using the buckets for the string tails I want to see if extending the idea and turning the module into a radix trie will improve performance.

I’m hoping people will be interested in looking at my code and providing some feedback. You can use:

$ darcs get http://coder.bsimmons.name/code/Trie/

If you want to download just the Trie module you can get it here.

UPDATE:

Here are the results of a quick test in GHCi comparing the effects of using a Data.Map.Map vs. an Unbalanced Binary Tree vs. simple Lists to store the branches at each node:

test run-time (ms) for:
Input File Size (kB) | Data.Map Simple Tree List
96.7 618 1015 1998
596.5 10225 25255 71236

Data.Map, as expected, is the winner. It’s possible that with very small Tries that either lists or the simple trees would have slightly better performance.

Next on my agenda is to perform the following test: build up a Trie from a reduced english dictionary, then parse a random block of text, printing the definition of each word. I hope the Trie will fare better in this test.

UPDATE 2:
I just noticed one property of the Data.Map library that probably makes it less optimal than it could be for this application: the Map type is strict in all its constructors except the in the value of the key/value pair (which is where we are storing the recursive Trie paths from a node. I suppose this also means that -funbox-strict-fields has little effect on the structure.

In Trie.hs I replaced the M.insertWith function call (from Data.Map) with the strict insertWith' function and got some improvement in CPU usage.

UPDATE 3:

You can read some of my conclusions in this post.

10 Comments

Data.Map Conversion Functionality

29/03/2009

There are three identical functions in Data.Map for flattening a Map to a list of tuples in ascending order:

assocs = toList = toAscList

…but nothing to convert to a descending list.

No Comments