The Disruptor, the design pattern at the core of the financial exchange I'm helping build at LMAX has been open sourced. If you've seen the presentation that Martin Thompson and I gave at QCon or the one I did for the LJC @Skillsmatter, this is the ring-buffer based code that we've been banging on about for a while.
What is the Disruptor?
At its simplest, it's an alternative to a queue, but can also can be thought of as a simple actor-like concurrency framework.
Why?
Performance, it's about 8-9 times higher throughput and 3 orders of magnitude lower latency than Java's ArrayBlockingQueue. There's a technical article on the Google code site with more details of the implementation and comprehensive performance test results.
Misadventures in Concurrent and Parallel programming, plus random comments on software performance and various OSS contributions.
Wednesday, 22 June 2011
Tuesday, 31 May 2011
Another LJC Lightning Talk
I'm doing a lightning talk (5 minutes) for the LJC tomorrow evening. I'm going to give a quick run down of JSR 334/Project Coin as part of our involvement in the JCP.
Tuesday, 10 May 2011
Yay for the LJC!
Congratulations to the London Java Community (disclaimer: I'm an associate member); they have just been elected to the JCP SE/EE Executive Committee. This is great opportunity for the community to be more involved with the JCP.
Saturday, 9 April 2011
More Complexity and Fork/Join
Firstly an apology. On my previous blog, I mentioned that a string splitting algorithm implemented in Scala had a complexity of $O(n^2)$. One commenter mentioned that they did not understand how I came to that calculation. I though I should revisit my guess work and actually do a more thorough analysis. What I found was interesting, I had overstated the complexity, in reality it was $O(n.log_{2}(n))$. I've included my working here.
Complexity of the Scala Implementation
In the Scala implementation the list concatenation operation is $O(n)$. I've simplified the model such that the factors applied are different, but that shouldn't have a bearing on the complexity. Fork/Join algorithms work on a divide and conquer model, in its simplest form looks much like a binary tree. To calculate the complexity of the reduction part of a Fork/Join algorithm we need to sum the the cost of all of operations to reduce the dataset to a single result. If we start with the base set of partial results, for the sake of an example assume there are 8, then the first step it to reduce them to 4. Then second step takes the 4 partial results to create 2. The third and final step takes the 2 results to create the final solution.
So if we have a dataset of size $n$ and were are using Scala's default list implementation, the cost to perform the reduction is:
$$1\frac{n}{2} + 2\frac{n}{4} + 4\frac{n}{8} + ... + \frac{2^k}{2}.\frac{n}{2^k}$$
where $k = \log_{2}(n)$. At step k, $\frac{n}{2^k}$ represents the number of operations, $\frac{2^k}{2}$ is the cost of each operation. We can eliminate $2^k$ and express the sum using sigma notation:
$$\sum_{k = 1}^{\log_{2}(n)} \frac{n}{2}$$
Applying the sum we get:
$$\frac{n}{2}.\log_{2}(n)$$
This gives a complexity of $O(n.\log_{2}(n))$. It is not nearly as bad as the $O(n^{2})$ I mentioned the previous blog. It is still worth avoiding as the benefit of applying a fixed number of multiple cores (i.e applying a constant factor to the cost) will be outweighed by the non-linear increase in cost as the dataset increases.
Complexity of the Fortress Implementation
However, the Fortress version presented by Guy Steele doesn't have $O(n)$ complexity for each of the reductions. It uses a PureList based on finger trees which has $O(\log_{2}(n))$ mutation operations.
The cost of the computation at each step breaks down differently, summing the cost of the computation looks like the following:
$$log_{2}(1)\frac{n}{2} + log_{2}(2)\frac{n}{4} + log_{2}(4)\frac{n}{8} + ... log_{2}(2^{k - 1})\frac{n}{2^k}$$
where $k = \log_{2}(n)$. At step k, $\frac{n}{2^k}$ represents the number of operations, $log_{2}(\frac{2^k}{2})$ is the cost of each operation, give the sum of:
$$T(n) = \sum_{k = 1}^{\log_{2}(n)} log_{2}(2^{k - 1})\frac{n}{2^k}$$
Fortunately this simplifies:
The pink link is the upper bound on the complexity $nS$ and the blue line is the sum of $T(n)$. So, contra to my statement in the previous post using an $O(log_{2}(n))$ operation to perform the reduction part of a Fork/Join algorithm won't introduce any complexity issues.
I learnt quite a lot in working through the complexity calculations, the most important of which, is not to jump to making a statement about the complexity of an algorithm. Often it's not a simple as you may think and it requires a little bit of rigour.
As to the original problem, I'm continuing to experiment with different parallel implementations to see how they perform on a couple of different multi-core systems. The fact remains that the simple imperative version is still much faster than the parallel implementation. I am working a couple of different approaching using mutable intermediate results.
For those who are interested, here is the math. I would like to say I did this all myself, but had a lot of help from the Internet elves at http://math.stackexchange.com/.
$T_n=nS_i(x)$ for $i=\lfloor \log_2(n)\rfloor$ and $x=\frac12$, where, for every $i$ and $x$,
Fortunately this simplifies:
$$T(n) = \sum_{k = 1}^{\log_{2}(n)} (k - 1)\frac{n}{2^k}$$
The following series $\displaystyle S = \sum_{k >= 1}^{\infty} \frac{k - 1}{2^k}$ converges to 1. So as $ n \rightarrow \infty$, $ T(n) \rightarrow nS$. There is some math (see below) to show that this translates into $O(n)$ complexity, however I find that it is easier to represent visually.The pink link is the upper bound on the complexity $nS$ and the blue line is the sum of $T(n)$. So, contra to my statement in the previous post using an $O(log_{2}(n))$ operation to perform the reduction part of a Fork/Join algorithm won't introduce any complexity issues.
I learnt quite a lot in working through the complexity calculations, the most important of which, is not to jump to making a statement about the complexity of an algorithm. Often it's not a simple as you may think and it requires a little bit of rigour.
As to the original problem, I'm continuing to experiment with different parallel implementations to see how they perform on a couple of different multi-core systems. The fact remains that the simple imperative version is still much faster than the parallel implementation. I am working a couple of different approaching using mutable intermediate results.
For those who are interested, here is the math. I would like to say I did this all myself, but had a lot of help from the Internet elves at http://math.stackexchange.com/.
$T_n=nS_i(x)$ for $i=\lfloor \log_2(n)\rfloor$ and $x=\frac12$, where, for every $i$ and $x$,
$$S_i(x)=\sum_{k=1}^i(k-1)x^k=\sum_{k=0}^{i-1}kx^{k+1}=x^2U'_i(x),\quad U_i(x)=\sum_{k=0}^{i-1}x^{k}.$$
The function $x\mapsto U_i(x)$ is the sum of a geometric series, hence, for every $x\ne1$,
$$U_i(x)=\frac{1-x^i}{1-x},\qquad U'_i(x)=\frac1{(1-x)^2}(1+ix^i-x^i-ix^{i-1}).$$
Using $x=\frac12$, this yields
$$T_n=n(1-(i+1)2^{-i}).$$
Since $i=\lfloor \log_2(n)\rfloor$, one gets
$$n-2(\log_2(n)+1)<T_n<n-\log_2(n),$$
hence $T_n=n-O(\log_2(n))$. The sequence of general term $(T_n-n)/\log_2(n)$ has the interval $[-2,-1]$ as limit set.
Saturday, 12 March 2011
Troubles with Parallelism
A couple of weeks ago I attended the Java Posse Round-up, during which, I spent a couple of hours with Joel Neely working with the new parallel collections in Scala. As a task we looked at the parallel string splitting that was defined by Guy Steele during his talk at the Strange Loop conference. The goal was to demonstrate how algebraic properties of an algorithm can be used to partition and combine a data set so that it can be parallelised.
The algorithm uses a divide and conquer approach where the combination of the partial results is associative, i.e. the grouping of the operations to recombine the results doesn't matter as long as the overall order is maintained (there are more technical descriptions). If we take the following string as an example:
"Here is a sesquipedalian string of words"
And it is partitioned arbitrarily resulting in:
case class Segment(prefix: String,
words: List[String],
trailer: String) extend WordState {
override def assoc(other: WordState) = {
other match {
case c:Chunk =>
Segment(prefix, words, trailer + c.part)
case s:Segment =>
Segment(prefix,
words ::: maybeWord(trailer + s.prefix) ::: s.words,
s.trailer)
}
}
}
And for the Chunk:
Where "maybeWord" is defined as:
def maybeWord(s:String) = if (s.isEmpty) List.empty[String] else List(s)
One of the nice features of this algorithm is that it is now possible to process the input sequentially using a fold right operation:
val wordStates = s.map(processChar).toArray
wordStates.foldRight(Chunk.empty)((x, y) => x.assoc(y)).toList()
Or in parallel using Scala 2.9's parallel arrays:
val wordStates = s.toCharArray.par.map(processChar)
wordStates.aggregate(Chunk.empty)(assoc, assoc).toList()
The ".par" method on array constructs a ParallelArray from the input array. From that point on the functions applied to the collection are executed in parallel using the Fork/Join framework. Once we had this all in place, we wanted to benchmark it. I created a very simple imperative implementation to serve as a baseline and a simple performance test harness. I ran a number of iterations using a copy of "Alice in Wonderland" as an input data set. I had access to a Core 2 Duo (2 Cores) laptop and a Nehalem EX (8 Cores + HT) workstation on which I ran the benchmarks. The results were very surprising:
The algorithm uses a divide and conquer approach where the combination of the partial results is associative, i.e. the grouping of the operations to recombine the results doesn't matter as long as the overall order is maintained (there are more technical descriptions). If we take the following string as an example:
"Here is a sesquipedalian string of words"
And it is partitioned arbitrarily resulting in:
"Here is a ", "sesquipeda", "lian strin", "g of words"
We can represent the individual parts as objects that look like the following:
Segment("Here", List("is", "a"), "")
Chunk("sesquipeda")
Segment("lian", List(), "strin")
Segment("g", List("of"), "words")
A segment consists of a leading and trailing part of a word and a list of all of the words that are enclosed by spaces within the partition. A chunk is a piece of text that contains no white space separators. These individual parts can be recombined using a polymorphic function, lets call it "assoc". The function is defined as follows:
case class Segment(prefix: String,
words: List[String],
trailer: String) extend WordState {
override def assoc(other: WordState) = {
other match {
case c:Chunk =>
Segment(prefix, words, trailer + c.part)
case s:Segment =>
Segment(prefix,
words ::: maybeWord(trailer + s.prefix) ::: s.words,
s.trailer)
}
}
}
And for the Chunk:
case class Chunk(part: String) extends WordState {
override def assoc(other: WordState) = {
other match {
case c:Chunk => Chunk(part + c.part)
case s:Segment => Segment(part + s.prefix, s.words, s.trailer)
}
}
}
Where "maybeWord" is defined as:
def maybeWord(s:String) = if (s.isEmpty) List.empty[String] else List(s)
One of the nice features of this algorithm is that it is now possible to process the input sequentially using a fold right operation:
val wordStates = s.map(processChar).toArray
wordStates.foldRight(Chunk.empty)((x, y) => x.assoc(y)).toList()
Or in parallel using Scala 2.9's parallel arrays:
val wordStates = s.toCharArray.par.map(processChar)
wordStates.aggregate(Chunk.empty)(assoc, assoc).toList()
The ".par" method on array constructs a ParallelArray from the input array. From that point on the functions applied to the collection are executed in parallel using the Fork/Join framework. Once we had this all in place, we wanted to benchmark it. I created a very simple imperative implementation to serve as a baseline and a simple performance test harness. I ran a number of iterations using a copy of "Alice in Wonderland" as an input data set. I had access to a Core 2 Duo (2 Cores) laptop and a Nehalem EX (8 Cores + HT) workstation on which I ran the benchmarks. The results were very surprising:
Core 2 Duo laptop, 2 Cores @ 2.40 GHz
==
Parallel - opsPerSecond: 6.89
Sequential - opsPerSecond: 49.81
Brute Force - opsPerSecond: 1388.89
Intel Xeon Workstation, 8 Cores +HT @ 2.40 GHz
==
Parallel - opsPerSecond: 120.48
Sequential - opsPerSecond: 73.26
Brute Force - opsPerSecond: 1886.79On the 2 core laptop the parallel version runs quite a bit slower, with 16 cores thrown at it, it just scrapes up to twice as fast as the sequential version. However in both cases it was absolutely dwarfed by the performance of the simple imperative version. Once I started digging using the profiler the reasons for this became obvious. The problem is Scala's immutable lists and the concatenation operator ':::'. In Scala the list on the left hand is copied, this is O(n) operation depending on the size of the list. So when the algorithm is parallelised the complexity increases from O(n) for the sequential version to O(n^2) for the parallel one. It's worth noting that in Fortress (which the initial algorithm was presented in) all list mutation operations are O(log n).
Sigh, it's back to Com. Sci. 101. An O(n^2) algorithm run on multiple cores is still O(n^2). The number of cores just serves a factor, so doesn't alter the complexity. My concerns with the current trend in software that is obsessed with parallelism and multi-core, is that the basics are being forgotten, a scientific/experimental approach to building faster software is not being applied and there is no demonstration of mechanical sympathy. The reason the simple imperative version is so much quicker than the sequential function version (both are O(n)) is that it is far more memory friendly i.e. no allocation/garbage reducing the amount data that needs to be pushed back and forth through the various CPU cache levels (and an old school decrement and compare trick). The functional version generates quite a bit of garbage and losses on memory efficiency.
Tuesday, 1 March 2011
Speaking at LJC about concurrency
I'm speaking at the next LJC event on concurrency (http://www.meetup.com/Londonjavacommunity/events/16701804/). I'll be doing the same talk that I gave with Martin Thompson at QCon. While it is the same material, the talk tends to take a different turn each time I give it.
Saturday, 5 February 2011
A Non-Blocking ConcurrentHash(Map|Trie)
Introducing ConcurrentHashTrie
While spending some time looking at Clojure's concurrency model, I did a little bit of research into their persistent collection implementation that uses Bagwell's Hash Array Mapped Tries. After a little bit of thought it occurred to me that you could apply the persistent collection model to an implementation of ConcurrentMap. The simple solution would be to maintain a reference to a persistent map within a single AtomicReference and apply a compareAndSet (CAS) operation each time an update to the map was performed. In Clojure terms, a ref and swap! operation. However a typical use of ConcurrentMap is for a simple cache in a web application scenario. With most web applications running with fairly significant numbers of threads (e.g. 100's) it's conceivable that the level of contention on a single compare and set operation would cause issues. Non-blocking concurrent operations like compare and set work really well at avoiding expensive user/kernel space transitions, but tend to break down when the number of mutator threads exceeds the number of available cores*. In fact thread contention on any concurrent access control structure is a killer for performance. The java.util.ConcurrentHashMap uses lock-striping as a mechanism to reduce contention on writes. If absolutely consistency across the ConcurrentMap was sacrificed (more information below), then a form of "CAS striping" could be applied to the ConcurrentHashTrie to reduce contention. This is implemented by replacing the AtomicReference with an AtomicReferenceArray and using a hash function to index into the array.
The Implementation
There are a number of implementations of the Bagwell Trie in JVM languages, however I couldn't find an implementation in plain old Java, so I wrote one myself. For the persistent collection it follows a fairly standard polymorphic tree design.
The core type of Node has 3 implementations. The key one is the BitMappedNode, which handles the vast majority of branch nodes and implements the funky hash code partition and population count operations that make up the Bagwell Trie structure. LeafNode holds the actual key/value pairs and ListNode is there to handle full collisions. The key mutator methods of ConcurrentMap: put, putIfAbsent, remove and replace are implemented using CAS operations on the rootTable, which is an AtomicReferenceArray .
As mentioned earlier, the ConcurrentHashTrie is not consistent for operations that occur across the entire structure. While at first look this seems terrible, in practical terms it is not really an issue. The 2 main operations this impacts are size and iteration. The size operation in the ConcurrentHashTrie is not O(1), but is O(n) where n is the size of the rootTable (or the number of stripes). Because it has to iterate across all of the nodes in the rootTable (it doesn't need to traverse down the trees) and add all of their sizes it possible for the size of a Node to change after it has been read but before the result has been calculated. Anyone that has worked a concurrent structure before has probably found that the size value is basically useless. It is only ever indicative, as the size could change right after the method call returns, so locking the entire structure while calculating the size doesn't bring any benefit. Iteration follows the same pattern. It is possible that a node could have changed after being iterated over or just before being reached. However, it doesn't really matter as it is not really any different to the map changing just before iteration started or just after it completes (as long as iteration doesn't break part way through). Note that Cliff Click's NonBlockingHashMap exhibits similar behaviour during iteration and size operations.
Performance, The Good, The Bad and the... well mostly Bad
Cliff Click kindly included a performance testing tool with his high scale library which I've shamelessly ripped off and used to benchmark my implementation. Apparently he borrowed some code from Doug Lea to implement it. I changed a sum total of 1 line. Writing benchmarks, especially for concurrent code, is very tough (probably harder than writing the collection itself), so borrowing from the experts gives me some confidence that the numbers I produce will be useful.
So onto the results:
Do'h!!
Quite a bit slower than the java.util.ConcurrentHashMap. I didn't even bother comparing to the NonBlockingHashMap from the high scale library, the numbers would be too embarrassing.
The ConcurrentHashTrie2 is an alternative implementation that I experimented with. I suspected the polymorphic behaviour of the tree nodes was causing a significant performance hit due to a high number of v-table calls. The alternate implementation avoids the v-table overhead by packing all three behaviours into a single class (in a slight dirty fashion). I stole a couple of bits from the level variable to store a discriminator value. The Node class switches on the discriminator to determine the appropriate behaviour. As the results show, it didn't help much. I ran a number of other permutations and the results were largely the same.
Conclusion
So a question remains, is the approach fundamentally flawed or is my code just crap? I suspect the major cost is caused by heavy amount of memory allocation, copying and churn through the CPU cache caused by the path-copy nature of mutation operations. Also the read path isn't particularly quick. With a reasonably full map, reads will likely need to travels a couple of levels down the tree. With the traditional map, its a single hash and index operation.
* I really need a proper citation for this. However imagine 16 cores and a 100 threads trying to apply a compare and set operation to the same reference. Unlike a lock which will park the threads that fail to acquire the lock, non-blocking algorithms require the thread that fails to apply its change to discard its result and recompute its result, effectively spinning until is succeeds. With more CPU bound threads than cores, its possible that the system will end up thrashing.
While spending some time looking at Clojure's concurrency model, I did a little bit of research into their persistent collection implementation that uses Bagwell's Hash Array Mapped Tries. After a little bit of thought it occurred to me that you could apply the persistent collection model to an implementation of ConcurrentMap. The simple solution would be to maintain a reference to a persistent map within a single AtomicReference and apply a compareAndSet (CAS) operation each time an update to the map was performed. In Clojure terms, a ref and swap! operation. However a typical use of ConcurrentMap is for a simple cache in a web application scenario. With most web applications running with fairly significant numbers of threads (e.g. 100's) it's conceivable that the level of contention on a single compare and set operation would cause issues. Non-blocking concurrent operations like compare and set work really well at avoiding expensive user/kernel space transitions, but tend to break down when the number of mutator threads exceeds the number of available cores*. In fact thread contention on any concurrent access control structure is a killer for performance. The java.util.ConcurrentHashMap uses lock-striping as a mechanism to reduce contention on writes. If absolutely consistency across the ConcurrentMap was sacrificed (more information below), then a form of "CAS striping" could be applied to the ConcurrentHashTrie to reduce contention. This is implemented by replacing the AtomicReference with an AtomicReferenceArray and using a hash function to index into the array.
The Implementation
There are a number of implementations of the Bagwell Trie in JVM languages, however I couldn't find an implementation in plain old Java, so I wrote one myself. For the persistent collection it follows a fairly standard polymorphic tree design.
The core type of Node has 3 implementations. The key one is the BitMappedNode, which handles the vast majority of branch nodes and implements the funky hash code partition and population count operations that make up the Bagwell Trie structure. LeafNode holds the actual key/value pairs and ListNode is there to handle full collisions. The key mutator methods of ConcurrentMap: put, putIfAbsent, remove and replace are implemented using CAS operations on the rootTable, which is an AtomicReferenceArray
As mentioned earlier, the ConcurrentHashTrie is not consistent for operations that occur across the entire structure. While at first look this seems terrible, in practical terms it is not really an issue. The 2 main operations this impacts are size and iteration. The size operation in the ConcurrentHashTrie is not O(1), but is O(n) where n is the size of the rootTable (or the number of stripes). Because it has to iterate across all of the nodes in the rootTable (it doesn't need to traverse down the trees) and add all of their sizes it possible for the size of a Node to change after it has been read but before the result has been calculated. Anyone that has worked a concurrent structure before has probably found that the size value is basically useless. It is only ever indicative, as the size could change right after the method call returns, so locking the entire structure while calculating the size doesn't bring any benefit. Iteration follows the same pattern. It is possible that a node could have changed after being iterated over or just before being reached. However, it doesn't really matter as it is not really any different to the map changing just before iteration started or just after it completes (as long as iteration doesn't break part way through). Note that Cliff Click's NonBlockingHashMap exhibits similar behaviour during iteration and size operations.
Performance, The Good, The Bad and the... well mostly Bad
Cliff Click kindly included a performance testing tool with his high scale library which I've shamelessly ripped off and used to benchmark my implementation. Apparently he borrowed some code from Doug Lea to implement it. I changed a sum total of 1 line. Writing benchmarks, especially for concurrent code, is very tough (probably harder than writing the collection itself), so borrowing from the experts gives me some confidence that the numbers I produce will be useful.
So onto the results:
Do'h!!
Quite a bit slower than the java.util.ConcurrentHashMap. I didn't even bother comparing to the NonBlockingHashMap from the high scale library, the numbers would be too embarrassing.
The ConcurrentHashTrie2 is an alternative implementation that I experimented with. I suspected the polymorphic behaviour of the tree nodes was causing a significant performance hit due to a high number of v-table calls. The alternate implementation avoids the v-table overhead by packing all three behaviours into a single class (in a slight dirty fashion). I stole a couple of bits from the level variable to store a discriminator value. The Node class switches on the discriminator to determine the appropriate behaviour. As the results show, it didn't help much. I ran a number of other permutations and the results were largely the same.
Conclusion
So a question remains, is the approach fundamentally flawed or is my code just crap? I suspect the major cost is caused by heavy amount of memory allocation, copying and churn through the CPU cache caused by the path-copy nature of mutation operations. Also the read path isn't particularly quick. With a reasonably full map, reads will likely need to travels a couple of levels down the tree. With the traditional map, its a single hash and index operation.
* I really need a proper citation for this. However imagine 16 cores and a 100 threads trying to apply a compare and set operation to the same reference. Unlike a lock which will park the threads that fail to acquire the lock, non-blocking algorithms require the thread that fails to apply its change to discard its result and recompute its result, effectively spinning until is succeeds. With more CPU bound threads than cores, its possible that the system will end up thrashing.
Subscribe to:
Posts (Atom)





