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.

There are 10 comments in this article:

  1. 16/04/2009Magnus Jonsson says:

    Here are a few progressively faster implementations based on each other:

    http://magnus.hcoop.net/MagnusTrie.hs
    http://magnus.hcoop.net/MagnusTrie2.hs
    http://magnus.hcoop.net/MagnusTrie3.hs

    They are using unbalanced trees so they might go faster if balancing is added (I’m too lazy to do it).

    #2 and #3 are similar in performance. They both beat Data.Map on my box (ubuntu linux, x86-64, ghc 6.11.20090306). However, they use strictness annotations so it is not really fair play. I compiled with -O3 -funbox-strict-fields.

  2. 16/04/2009Arthur Chan says:

    I know that some of the GHC data structures use unboxed types to provide a boost in memory and speed performance. You could look into that. I am also unclear about the details of Data.Map. Is it advantageous to use a simple list below a certain threshold? I’ve been thinking of writing a Radix Tree myself recently.

  3. 16/04/2009Arthur Chan says:

    Oh and I must mention that Radix Trees seem to do much better on real world datasets as compared with Tries. The one node per element of the String bit really kills. Modern CPUs are IO bound yes? It’s important to keep the memory footprint of your data structures as small as possible, to reduce pointer jumps, etc., and Radix Trees help quite a bit for that.

  4. 16/04/2009Mark Wotton says:

    Have you tried Wren Thornton’s bytestring-trie package?
    http://hackage.haskell.org/cgi-bin/hackage-scripts/package/bytestring-trie

  5. 16/04/2009jberryman says:

    @Magnus Jonsson:

    Thanks for that code. It seems to run about twice as slow as my implementation in GHCi using my test program, but I am interested in looking at it closer as well as playing with compiler options.

    @Arthur Chan:

    Thanks for the input. I will look at the effects of using plain lists in place of Maps tomorrow, then approach radix trees. It will be tricky to really make sure I am performing the minimal number of necessary comparisons on list elements.

    @Mark Wotton:

    Thanks, I am looking into trying to understand that implementation.

  6. 16/04/2009augustss says:

    Never use ghci for testing performance. Use compilation with optimization.

  7. 16/04/2009Magnus Jonsson says:

    jberryman, I don’t think ghci does very many optimizations, so you may be giving yourself a harder challenge than necessary. Data.Map would lose against itself.

  8. 16/04/2009jberryman says:

    Updated post with results of testing association lists, vs Data.Maps vs. simple unbalanced binary trees for storing branches. The small input text had 17,000+ words. Maybe it would be interesting to see at what point unbalanced trees or lists become more efficient.

  9. 16/04/2009jberryman says:

    Another update. I realized that the Value field in Data.Map is not strict like I was assuming. This might be giving Data.Map an edge in this test.

  10. 18/04/2009Initial tests of Tries: Follow Up - LAMBDAPHONE says:

    [...] 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 [...]

Write a comment: