Skip to content

hls graph runtime engine experiment - #4927

Draft
soulomoon wants to merge 41 commits into
haskell:masterfrom
soulomoon:codex/hls-graph-runtime-engine
Draft

hls graph runtime engine experiment#4927
soulomoon wants to merge 41 commits into
haskell:masterfrom
soulomoon:codex/hls-graph-runtime-engine

Conversation

@soulomoon

@soulomoon soulomoon commented May 6, 2026

Copy link
Copy Markdown
Collaborator

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

@soulomoon
soulomoon requested review from fendor and wz1000 as code owners May 6, 2026 13:22
@soulomoon
soulomoon marked this pull request as draft May 6, 2026 13:24
@soulomoon soulomoon added the performance Issues about memory consumption, responsiveness, etc. label May 6, 2026
@soulomoon soulomoon changed the title Codex/hls graph runtime engine hls graph runtime engine May 6, 2026
@soulomoon soulomoon changed the title hls graph runtime engine hls graph runtime engine experiment May 6, 2026
@soulomoon

soulomoon commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author

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 crtschin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +521 to +527
-- | 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 = ()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why turn kick into a rule? I thought the reason it existed is because it signified a cache invalidation.

Comment on lines +92 to +96
-- | 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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment on lines +278 to +285
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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +286 to +291
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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment on lines 476 to +485
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@soulomoon soulomoon Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you break this line up? It does a lot of things.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 #-}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance Issues about memory consumption, responsiveness, etc.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants