| CARVIEW |
Navigation Menu
-
Notifications
You must be signed in to change notification settings - Fork 1
Use stm-containers instead of TVar HashMap #279
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
| running <- readTVarIO w.runningActivities | ||
| running <- atomically $ StmMap.lookup tt w.runningActivities | ||
| let cancelReason = case msg ^. AT.reason of | ||
| AT.NOT_FOUND -> NotFound | ||
| AT.CANCELLED -> CancelRequested | ||
| AT.TIMED_OUT -> Timeout | ||
| AT.WORKER_SHUTDOWN -> WorkerShutdown | ||
| AT.ActivityCancelReason'Unrecognized _ -> UnknownCancellationReason | ||
| forM_ (HashMap.lookup tt running) $ \a -> | ||
| cancelWith a cancelReason `finally` atomically (modifyTVar' w.runningActivities (HashMap.delete tt)) | ||
| forM_ running $ \a -> | ||
| cancelWith a cancelReason `finally` atomically (StmMap.delete tt w.runningActivities) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another option with this block is to remove the item from the map first- this may prevent double-cancels.
running <- atomically $ StmMap.lookupAndDelete tt w.runningActivities
...
forM_ running $ \a ->
cancelWith a cancelReasonBut double cancels are pretty cheap. Unless there's a deadlock or some other reason why the a here isn't exiting promptly.
| , runningWorkflows :: {-# UNPACK #-} !(TVar (HashMap RunId WorkflowInstance)) | ||
| , runningWorkflows :: {-# UNPACK #-} !(StmMap.Map RunId WorkflowInstance) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This field and one other are changed, most of the changes are reacting to this in the types.
| runningWorkflows <- readTVarIO worker.runningWorkflows | ||
| mapM_ (cancel <=< readIORef . executionThread) runningWorkflows | ||
| runningWorkflows <- liftIO $ ListT.toList $ StmMap.listTNonAtomic $ worker.runningWorkflows | ||
| mapConcurrently_ (cancel <=< readIORef . executionThread . snd) runningWorkflows |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This change also does a concurrent cancellation on the jobs instead of a sequential one.
iand675
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice, long as the tests pass I'm fine with this.
📊 Code Coverage ReportCurrent PR CoverageOverall Coverage: 🟠 57.9%
Overall Summary
Coverage by Module
🟢 ≥80% 🟡 ≥60% 🟠 ≥40% 🔴 <40% 📈 Coverage Comparison vs. Main➡️ Coverage unchanged at 57.9% Main Branch Coverage (for comparison)Overall Coverage: 🟠 57.9%
Overall Summary
Coverage by Module
🟢 ≥80% 🟡 ≥60% 🟠 ≥40% 🔴 <40% |
|
Update from downstream: this completely fixed lag time with increased concurrent startup in our test suite, ~20x performance improvement with 16 cores (and actually completes successfully instead of hanging forever with 32 cores). |
|
Could you explain how the previous use of |
|
Temporal activities using stale database connections was fixed in this PR, which ensured that all workflows received the shutdown message. I believe the behavior we were observing was something like:
By doing concurrent shutdown, we ensure that each worker at least receives the message to As for this change - let me start with a brief overview on how Let's consider this bit of code: join $ atomically $ do
currentWorkflows <- readTVar worker.runningWorkflows
writeTVar worker.runningWorkflows $ HashMap.delete runId_ currentWorkflowsThis happens in Another potential example: liftIO $ atomically $ do
workflows <- readTVar worker.runningWorkflows
case HashMap.lookup r workflows of
Nothing -> do
let workflows' = HashMap.insert r inst workflows
writeTVar worker.runningWorkflows workflows'
pure inst
Just existingInstance -> do
writeTVar worker.runningWorkflows workflows
pure existingInstanceHere we are reading from and writing to the variable. If we have several of these transactions all attempting to complete, the There isn't exactly a "smoking gun" here on what transaction or combination of transactions caused the problem. There's the application of a simple rule (" |
|
With concurrency=32 how many temporal threads did we have contending for the one shared map? And roughly how often were they updating it? I believe STM guarantees that one contending transaction will always commit, so there would have to be a lot of contention for this to become a problem |
|
I don't have any idea, and we don't have good tooling to understand or diagnose things at this level. You're welcome to put further effort into the investigation here - I wouldn't be surprised if only one of these |
| Just exists -> | ||
| Just exists | ||
|
|
||
| StmMap.focus (Focus.alter modifier >> Focus.lookupWithDefault inst) r worker.runningWorkflows |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IIUC this is the only place where contention may be reduced: if it's a modification, then stm-containers will bypass the write to the top-level map. If it's an insertion, it still has to writeTVar the top-level map right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So the fundamental problem with TVar (Map k _) is that the entire Map structure is stored in the single TVar, so any write to any part of the Map is going to invalidate any other transaction. Consider TVar [a] vs TChan a - doing modifyTVar (<> [a]) invalidates the entire TVar, even though fmap head . readTVar only cares about the first element, and these should not interact. The solution is to put TVar in the spine of the data structure - now, TChan is doubly linked, but a TList it uses is basically like this:
type TList a = TVar (TList' a)
data TList' a = TNil | TCons a (TVar (TList' a))If we apply this insight to Map, then you'd replace the direct references to Map with TVar Map:
-- pure
data Map k a = Bin {-# UNPACK #-} !Size !k a !(Map k a) !(Map k a)
| Tip
data StmMap' k a = Bin !Size !k a !(TVar (StmMap k a)) !(TVar (StmMap k a))
| Nil
type StmMap k v = TVar (StmMap' k v)This is effectively what stm-containers does, but for HashMap instead of Map. The result is that the scope of contention is dramatically reduced.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When you insert a key into that StmMap you still have to write to the root TVar no? The size increases. Same story for delete
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suggest you study the implementation of stm-containers if you want to learn more about this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
insert always reads and then writes the top-level TVar in the Hamt, and lookup always reads it. How could it possibly be any other way while still being consistent?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(I'm looking at https://hackage-content.haskell.org/package/stm-hamt-1.2.1.1/docs/StmHamt-Hamt.html#v:insert , maybe that's not the one you're talking about?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Got it now, changes to different parts of the tree that don't require changing the tree structure can be done without contending
| join $ atomically $ do | ||
| currentWorkflows <- readTVar worker.runningWorkflows | ||
| writeTVar worker.runningWorkflows $ HashMap.delete runId_ currentWorkflows | ||
| mworkflow <- StmMap.focus Focus.lookupAndDelete runId_ worker.runningWorkflows |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't there still contention here? How can we delete a key from the map without causing any contention on other uses of the map?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The contention is scoped to this key in particular. The rest of the map structure is not under contention, so you can lookup/update/delete/insert at other keys without incurring any overhead.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lookupAndDelete suggests to me that it returns the value but then deletes the key/value from the map.
Suppose some other concurrent transaction updates the value at that key. Doesn't it have to go either strictly before or strictly after this transaction containing the lookupAndDelete? The result of lookupAndDelete will change if the modifying transaction goes before it, and the effect of the modifying transaction will change if the key is deleted before it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, an update at a key will invalidate other transactions that involve the same key. The cool thing is that it only invalidates transactions at that key. That is the point of stm-containers.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
focus :: (Hashable key) => B.Focus value STM result -> key -> Map key value -> STM result
focus valueFocus key (Map hamt) =
A.focus rowFocus (\(Product2 key _) -> key) key hamt
where
rowFocus =
B.mappingInput (\value -> Product2 key value) (\(Product2 _ value) -> value) valueFocusThat's a call into the stm-hamt library. Here's A.focus
-- module StmHamt.Hamt
focus :: (Hashable key) => Focus element STM result -> (element -> key) -> key -> Hamt element -> STM result
focus focus elementToKey key = focusExplicitly focus (hash key) ((==) key . elementToKey)
focusExplicitly :: Focus a STM b -> Int -> (a -> Bool) -> Hamt a -> STM b
focusExplicitly focus hash test hamt =
{-# SCC "focus" #-}
let Focus _ reveal = Focus.onHamtElement 0 hash test focus
in fmap fst (reveal hamt)So we're interested in Focus.onHamtElement, which is in another module from that library
-- module StmHamt.Focuses
onHamtElement :: Int -> Int -> (a -> Bool) -> Focus a STM b -> Focus (Hamt a) STM b
onHamtElement depth hash test focus =
let branchIndex = IntOps.indexAtDepth depth hash
Focus concealBranches revealBranches =
By6Bits.onElementAtFocus branchIndex
$ onBranchElement depth hash test focus
concealHamt =
let hamtChangeStm = \case
Leave -> return Leave
Set !branches -> Set . Hamt <$> newTVar branches
Remove -> Set . Hamt <$> newTVar By6Bits.empty
in concealBranches >>= traverse hamtChangeStm
-- this is the one we're calling in focusExplicitly ...
revealHamt (Hamt branchesVar) = do
-- ... it reads the top-level var ...
branches <- readTVar branchesVar
(result, branchesChange) <- revealBranches branches
case branchesChange of
-- ... and it always writes it, unless you said not to change it
Leave -> return (result, Leave)
Set !newBranches -> writeTVar branchesVar newBranches $> (result, Leave)
Remove -> writeTVar branchesVar By6Bits.empty $> (result, Leave)
in Focus concealHamt revealHamtThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it possible that somehow an update at a key doesn't read the top-level map var? What am I missing?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suppose you'd have to take the underlying value's TVar out of the map and then do in-place updates, but are we doing that anywhere in this patch?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it that an insertion or deletion will often result in a Leave at the top level?
A |
|
A
I'd be curious to hear your hypothesis on a) what was going on and b) why this patch fixed it. |
What evidence do we have that this fixed it? Did we put CI back to the original >16 concurrency with this patch? Didn't we also do other mitigations to get people unblocked on their day-to-day work? I'm just trying to get an understanding of why this patch would fix things, because I find it surprising, and I was hoping you'd have some insight. AFAICT the only place where the use of There's also the unrelated change to use concurrent
If we're talking about footguns then wouldn't you say using an
* assuming your workload is mostly updates at keys, not deletions or insertions. You still have to think about the problem and understand it before you choose a solution. Often a TVar containing a map will be just as good and much simpler since you're probably already using vanilla |
Yes, this patch fixed the problem. CI is back to >16 concurrency. This is locally verifiable by running tests and observing a superlinear in the number of cores slowndown prior to this patch in the runtime of temporal tests, and then running tests again with this patch and observing a totally linear test runtime regardless of the number of cores. I suggest reading through the incident channel - there's a lot of diagnosis and step-by-step information about what steps were taken and what impacts they had. It is possible that the concurrent shutdown is the true fix, but that would be weird - we are observing an infinite freeze somewhere, and issuing the order to shutdown concurrently vs serially should not impact a process that is frozen.
I believe this is addressed in the first part of the sentence you're responding to. Do you mind elaborating?
That is not true. I think you are confused about how
"Simple" is a subjective judgment, but I'd be hard pressed to view significant complexity difference between mv <- atomically $ StmMap.lookup k m
mv <- Map.lookup k <$> atomically (readTVar m)and, even if i were to decide that |
Yes that's what I said, but insertions and deletions still cause contention, even against updates at keys. Which is why it's so surprising to me that this patch would have had such an impact Tbh I think the concurrent cancel might be the thing that did it: one workflow thread getting hung up (probably due to the |
No, it doesn't. You evidently don't understand the guarantees of
You're welcome to run the experiment if you want to - please post results when you do. |
If one thread is unkillable then But with
I'm just looking at the source code for it and coming to my own conclusions. AFAICT insertions and deletions do contend with updates. Every |
I think I see it now: the focus data type finds the smallest branch to modify, therefore insertion/delete only contends with changes to nearby keys |
Yes - this is what this other PR accomplished. After that PR, and prior to this one, we were no longer observing use of old database connections, but we were observing the freeze and the dramatic degradation in performance with increased concurrency. In this PR, we no longer observe the freeze or degradation in performance with increased concurrency. While it is possible that |
|
IIUC that the test suite is like having 32+ mwb instances running on the same temporal shared state then I could see |
This PR uses
stm-containers- aTVar (HashMap k v)has much worse performance. We are observing some issues that look like livelock with increasing concurrency, and I believe - if this doesn't fix the problem outright - it should significantly improve it.There are many other places in the Temporal codebase that use
TVarof container. This PR only focuses on two points.