Graph Layout with Software Transactional Memory and Barriers (plus video!)
In my last post, I used barriers and shared channels to write a simple program for performing force-based graph layout. There were two things lacking in that example: firstly, repulsion between unconnected nodes and secondly, some cool videos. I’ve now solved both of these shortcomings.
In order to implement repulsion between all nodes, each node must know the position of all other nodes at every time-step. I could implement this with message-passing, but that would be O(N^2) communications each phase, which isn’t very efficient, and at that point, message-passing is probably not the right design. We need some shared variables instead. Software Transactional Memory (STM) provides a nice way to do shared variable concurrency in Haskell, so we will use that.
The idea is that we will have a transactional variable (TVar) per node, holding the node’s position. Every time-step, each node will read from all the TVars to discover the positions of all the other nodes, and will then update its own position based on the other nodes. If we are not careful, we will have the same race hazard that I warned against last time: one node could update its position before all the other nodes have read its previous position. Or, if left totally unconstrained, one node could perform several updates before other nodes have even performed one. To prevent these problems, we will again use barriers to break up the simulation into two phases: discover, and act. This means that we will be combining CHP and STM: using barriers from CHP to regulate access to shared variables from STM.
All the code before the node process is exactly the same as last time, except that I now import Control.Concurrent.STM and Control.Parallel.Strategies (for some strict application) — and I’ve made the nodes a bit smaller. Our node process no longer takes channels as its parameters, but TVars instead. It takes a list corresponding to the other nodes, of pairs of booleans (indicating if the other node is connected to this one) and transactional variables (to read the other node’s position). It also takes a single transactional variable, which it should use to write its own position to:
node :: NodeInfo -> [(Bool, TVar NodeInfo)] -> TVar NodeInfo -> Enrolled PhasedBarrier Phase -> CHP () node start neighbours me bar = node' start where (connected, tvs) = unzip neighbours node' cur = do Discover <- syncBarrier bar neighbourPos <- liftSTM (mapM readTVar tvs) let new = updatePos (zip connected neighbourPos) cur Act <- syncBarrier bar liftSTM (writeTVar me (new `using` rnf)) node' new
You can see that the node process is a bit simpler. In the Discover phase it reads from the TVars and in the Act phase it writes to its own. It is immediately apparent that the reads and writes are thus segregated. liftSTM is defined below (it is essentially the atomically function), and the `using` rnf is a strictness thing that you can ignore. The updatePos function is now more complicated as we have added repulsion (though it is all irrelevant to the concurrent logic):
updatePos poss cur = sum [cur ,0.05 * sum [ let v = p - cur in v - ideal * normalise v | (True, p) <- poss] -- connected nodes ,0.000002 * sum [let sc = (p `from` cur) ^^ (-2) in NodeInfo sc sc * normalise (cur - p) | (_, p) <- poss] -- all nodes ] where ideal = 0.02 (NodeInfo x2 y2) `from` (NodeInfo x1 y1) = sqrt $ (x2-x1)^^2 + (y2-y1)^^2 normalise (NodeInfo x y) = fmapNodeInfo (/ mag) (NodeInfo x y) where mag = sqrt (x*x + y*y) instance NFData GLfloat instance NFData NodeInfo where rnf (NodeInfo a b) = rnf a >| rnf b liftSTM :: MonadIO m => STM a -> m a liftSTM = liftIO . atomically
I’ve also had to add an instance of NFData to use rnf (strict evaluation) on NodeInfo, and the simple liftSTM function for executing STM transactions in the CHP monad. The draw process only has one change; instead of reading from a list of channels, we now read from a list of transactional variables:
drawProcess :: [TVar NodeInfo] -> Enrolled PhasedBarrier Phase -> CHP () drawProcess tvs bar = do displayIO <- embedCHP_ $ do syncAndWaitForPhase Discover bar xs <- liftSTM $ mapM readTVar tvs liftIO $ do startFrame mapM_ draw xs mapM_ (drawEdge xs) edges endFrame ...
Last time, I only had four nodes in my example, which was fairly boring. This time I have one hundred nodes, connected in a 10×10 grid. To make the demo interesting, all the nodes start with random positions:
startNodes :: IO [NodeInfo] startNodes = replicateM (10*10) $ liftM2 NodeInfo r r where r = liftM (fromRational . toRational) $ randomRIO (0 :: Float, 1) edges :: [(Int, Int)] edges = across ++ down where across = [(x+10*y, (x+1)+10*y) | x <- [0..8], y <- [0..9]] down = [(x+10*y, x+10*(y+1)) | x <- [0..9], y <- [0..8]]
Finally, our slightly adjusted main process:
main :: IO () main = runCHP_ $ do nodes <- liftIO startNodes vars <- liftSTM $ mapM newTVar nodes enrollAll_ (newPhasedBarrier Act) (drawProcess vars : [ let edgesOut = filter ((== i) . fst) edges edgesIn = filter ((== i) . snd) edges connectedNodes = map fst edgesIn ++ map snd edgesOut conn = [(ind `elem` connectedNodes, tv) | (tv, ind) <- zip vars [0..], tv /= mytv] in node n conn mytv | (n, mytv, i) <- zip3 nodes vars [0..] ])
There are not many changes from my previous shared channel-based version to use STM instead. My continued use of barriers alongside STM shows that you can mix STM and CHP quite nicely if you want to. Now, here’s what you’ve been waiting for — a video of the graph layout in action, first on my grid graph as defined above:
I also ran the algorithm on a simple loop graph:
The two videos have the algorithm running at 10 iterations per second, as that seems like a nice speed to view them at (and apologies for the slight garbage down the right-hand side of the videos). The grid example was inspired by an original example in the Java version of the Prefuse toolkit. Prefuse is an incredibly cool visualisation toolkit; some Java examples can be found online but more impressive are the more recent ActionScript examples. Try the Layouts tab, and also the dependency graph example if you have a spare minute.