hls graph runtime engine experiment - #4927
Conversation
|
Here is the main hls-graph changes we talked about. @fendor hls-graph now has an explicit runtime engine for running actions, tracking runtime dependencies, and restarting selectively. The benchmark shows the intended tradeoff. Startup improves broadly, mostly around 25-32%, from skipping kills for non-dirty work. For very short single-edit operations, the central thread maintenance cost is visible in userT / totalT, so some “after edit” cases regress. In the typing-burst cases, which better match human editing, that overhead is amortized and is partly offset by avoiding unnecessary non-dirty kills. |
crtschin
left a comment
There was a problem hiding this comment.
Could you elaborate on the design and how it relates with the previous setup? This is all quite intricate concurrent code, and there's a lot that's unfinished here with the TODO comments and dead code.
| -- | A no-file rule that triggers the IDE "kick" action | ||
| data Kick = Kick | ||
| deriving (Eq, Show, Generic) | ||
| instance Hashable Kick | ||
| instance NFData Kick | ||
|
|
||
| type instance RuleResult Kick = () |
There was a problem hiding this comment.
Why turn kick into a rule? I thought the reason it existed is because it signified a cache invalidation.
| -- | Create a new key that is guaranteed not to collide with any other key. | ||
| -- This is useful for keys that are not based on user data, e.g., for | ||
| -- tracking temporary actions. | ||
| newDirectKey :: Int -> Key | ||
| newDirectKey i = UnsafeMkKey $ negate (abs i + 1) |
There was a problem hiding this comment.
I don't quite understand the point of this abstraction and the negate . abs looks like we're encoding some invariant?
Keys already don't collide with each other, unless they are specifically the same key right? newDirectKey n == newDirectKey m iff n == m, which is also true for newKey, no?
| databaseRuntimeDepRoot :: SMap.Map Key KeySet, | ||
|
|
||
| databaseRRuntimeDepRoot :: SMap.Map Key KeySet, | ||
| databaseRRuntimeDep :: SMap.Map Key KeySet, | ||
| -- it is used to compute the transitive reverse deps, so | ||
| -- if not in any of the transitive reverse deps of a dirty node, it is clean | ||
| -- we can skip clean the threads. | ||
| -- this is update right before we query the database for the key result. |
There was a problem hiding this comment.
The dependencies of a rule is already stored in the rule results, ResultDeps. What does this extra bookkeeping save us complexity-wise? And do we need all of these?
There was a problem hiding this comment.
Yes, having two sources of truth is problematic. I think we should consolidate them after this work is complete.
I’m implementing more selective thread cancellation across restarts, so only threads are actually dirty will be cancelled. To do that reliably, we need to track dependencies right in time at the point when a thread is started.
Rule results are produced too late for this purpose: the database values' deps are updated only after the rule finishes computing, leaving a significant gap between starting the thread and recording its dependencies.
| databaseTransitiveRRuntimeDepCache :: SMap.Map KeySet TransitiveDirtyKeys, | ||
| -- ^ this is a cache for transitive reverse deps if we have computed it before | ||
| -- and the databaseRRuntimeDep did not change since last time | ||
| -- it is very useful for large projects where many files depend on a few common files | ||
| -- e.g. we do not want to recompute the transitive reverse deps every time we enter a letter | ||
| -- to a file. |
There was a problem hiding this comment.
Having KeySet as the domain of the map makes me think that this can explode quite quickly in storage overhead, with too small hit-rate to justify having it.
If a user types a key, the previous setup induced a kick which did cleanup and ensured freshness of results. What's the difference with the new setup?
| viewDirty :: Step -> Status -> Status | ||
| viewDirty currentStep (Running s _ _ re) | currentStep /= s = Dirty re | ||
| -- viewDirty currentStep (Running s re _ _) | currentStep /= s = Dirty re | ||
| viewDirty _ other = other | ||
|
|
||
|
|
||
| viewToRun :: Maybe Status -> Status | ||
| -- viewToRun _currentStep (Dirty _) = Nothing | ||
| -- viewToRun currentStep (Running s _re _ _) | currentStep /= s = Nothing | ||
| viewToRun Nothing = (Dirty Nothing) | ||
| viewToRun (Just other) = other |
There was a problem hiding this comment.
Both of these commented out bits look very load-bearing. What's the bit that assumes responsibility for this behavior? Or is it not relevant anymore, and why?
There was a problem hiding this comment.
If we are doing selective thread cancel during shake restart, we do not need currentStep /= s. dirties is already marked correctly by other parts of the system(e.g. In builderOne', we roll back to make it dirty if the thread is cancelled during its startup).
| mempty = RunDependenciesSame | ||
| instance Semigroup RunMode where | ||
| RunDependenciesSame <> b = b | ||
| RunDependenciesChanged <> _ = RunDependenciesChanged |
There was a problem hiding this comment.
Are these instances appropriate? If runMode a == RunDependenciesChanged and runMode b == RunDependenciesSame, I can only assume I want to make independent decisions here and refresh only a, and ignore b, do you ever need a <> b?
| modifyTVar' databaseThreads ((deliver, a):) | ||
| -- make sure we only start after the restart | ||
| putTMVar startBarrier () | ||
| a <- asyncWithUnmask $ \restore -> (handler =<< ((restore $ atomically (readTMVar startBarrier) >> (Right <$> asyncBody)) `catch` \e@(SomeException _) -> return (Left e))) |
There was a problem hiding this comment.
Could you break this line up? It does a lot of things.
There was a problem hiding this comment.
asyncWithUnmask so we won't be interrupted after we enter handler. The handler is passed in here to alter the some key states outside to keep invariant of the system intact (e.g. changing databaseValues).
| -- 3. Controlled start coordination via barriers | ||
| -- 4. Exception safety with rollback on registration failure | ||
| -- @ inline | ||
| {-# INLINE spawnAsyncWithDbRegistration #-} |
There was a problem hiding this comment.
I don't think this inline pragma does much but increase code size at callsites, which also isn't great performance-wise. IIRC inlining IO functions doesn't expose significant GHC optimization opportunities.
|
|
||
| mkDelayedAction :: String -> Logger.Priority -> Action a -> IO (DelayedAction a) | ||
| mkDelayedAction s p a = do | ||
| u <- newUnique |
There was a problem hiding this comment.
Why mint uniques? Where does the need for global uniqueness come from? Isn't the String label enough, IIRC derived from the rule type-level keys? How does this interact with caching?
There was a problem hiding this comment.
After replacing the AIO, we do not have to cancel all the DelayedAction during restart but only the actually dirty ones. So we identify every DelayedAction with unique.
WIP/experimental
The main idea is to let hls graph runtime have finer control over the runtime threads and better dependency tracking.
reduce the number of times actual thread intrupt is need. It also enable us build a better test infrastructure to inspect the hls graph runtime information that we no longer need to wait unconditionally in testing