From 1ccf02782400a47626d0c8d321975e9f9a3e4c4e Mon Sep 17 00:00:00 2001 From: Michal Kozakiewicz Date: Fri, 10 Apr 2026 11:03:36 +0000 Subject: [PATCH 01/11] Replace push-based compilation with rock demand-driven query system Integrate the rock library for demand-driven incremental compilation, replacing the previous forkIO + MVar concurrent build system. New modules: - Make.Query: GADT defining compilation queries (InputModule, ModuleGraph, SortedModules, ModuleSugarEnv, ModuleTypeEnv, CompileModule) - Make.Rules: Rock rules mapping each query to its computation, with liftMake bridge between Make monad and rock's IO-based Task The rock pipeline automatically tracks dependencies via fetch calls and memoizes results within a build. This lays the foundation for cross-build incrementality via rock's verifyTraces (Phase 2). Co-Authored-By: Claude Opus 4.6 (1M context) --- purescript.cabal | 8 +- src/Language/PureScript/Make.hs | 272 +++++++------------------- src/Language/PureScript/Make/Query.hs | 124 ++++++++++++ src/Language/PureScript/Make/Rules.hs | 150 ++++++++++++++ src/Language/PureScript/Names.hs | 3 +- stack.yaml | 4 + stack.yaml.lock | 7 + 7 files changed, 367 insertions(+), 201 deletions(-) create mode 100644 src/Language/PureScript/Make/Query.hs create mode 100644 src/Language/PureScript/Make/Rules.hs diff --git a/purescript.cabal b/purescript.cabal index bf438578c1..43f54942a7 100644 --- a/purescript.cabal +++ b/purescript.cabal @@ -166,9 +166,8 @@ common defaults cborg >=0.2.7.0 && <0.3, cheapskate >=0.1.1.2 && <0.2, clock >=0.8.3 && <0.9, + constraints-extras >=0.4.0 && <0.5, containers >=0.6.5.1 && <0.7, - -- unordered-containers, - -- hashable, cryptonite ==0.30.*, data-ordlist >=0.4.7.0 && <0.5, deepseq >=1.4.6.1 && <1.5, @@ -178,6 +177,7 @@ common defaults file-embed >=0.0.15.0 && <0.1, filepath >=1.4.2.2 && <1.5, Glob >=0.10.2 && <0.11, + hashable >=1.4.3 && <1.5, haskeline ==0.8.2, language-javascript ==0.7.0.0, lens >=5.1.1 && <5.3, @@ -193,11 +193,13 @@ common defaults process >=1.6.19.0 && <1.7, protolude >=0.3.1 && <0.4, regex-tdfa >=1.3.1.2 && <1.4, + rock, safe >=0.3.19 && <0.4, scientific >=0.3.7.0 && <0.4, semialign >=1.2.0.1 && <1.4, semigroups ==0.20.*, serialise >=0.2.5.0 && <0.3, + some >=1.0.4 && <1.1, sourcemap >=0.1.7 && <0.2, stm >=2.5.0.2 && <2.6, stringsearch >=0.3.6.6 && <0.4, @@ -340,6 +342,8 @@ library Language.PureScript.Make.Cache Language.PureScript.Make.ExternsDiff Language.PureScript.Make.Monad + Language.PureScript.Make.Query + Language.PureScript.Make.Rules Language.PureScript.ModuleDependencies Language.PureScript.Names Language.PureScript.Options diff --git a/src/Language/PureScript/Make.hs b/src/Language/PureScript/Make.hs index 8da8a90d73..7665b0de10 100644 --- a/src/Language/PureScript/Make.hs +++ b/src/Language/PureScript/Make.hs @@ -10,28 +10,23 @@ module Language.PureScript.Make import Prelude -import Control.Concurrent.Lifted as C -import Control.DeepSeq (force) -import Control.Exception.Lifted (onException, bracket_, evaluate) -import Control.Monad (foldM, unless, void, when, (<=<)) -import Control.Monad.Base (MonadBase(liftBase)) +import Control.Exception (SomeException, fromException, throwIO, try) +import Control.Monad (foldM, void, when) import Control.Monad.Error.Class (MonadError(..)) import Control.Monad.IO.Class (MonadIO(..)) +import Control.Monad.Reader (ask) import Control.Monad.Supply (evalSupplyT, runSupply, runSupplyT) -import Control.Monad.Trans.Control (MonadBaseControl(..)) import Control.Monad.Trans.State (runStateT) import Control.Monad.Writer.Class (MonadWriter(..), censor) import Control.Monad.Writer.Strict (runWriterT) import Data.Function (on) import Data.Foldable (fold, for_) +import Data.IORef (newIORef, readIORef) import Data.List (foldl', sortOn) import Data.List.NonEmpty qualified as NEL -import Data.Maybe (fromMaybe, mapMaybe) import Data.Map qualified as M -import Data.Set qualified as S import Data.Text qualified as T -import Debug.Trace (traceMarkerIO) -import Language.PureScript.AST (ErrorMessageHint(..), Module(..), SourceSpan(..), getModuleName, getModuleSourceSpan, importPrim) +import Language.PureScript.AST (ErrorMessageHint(..), Module(..), getModuleName, getModuleSourceSpan, importPrim) import Language.PureScript.Crash (internalError) import Language.PureScript.CST qualified as CST import Language.PureScript.Docs.Convert qualified as Docs @@ -39,15 +34,10 @@ import Language.PureScript.Environment (initEnvironment) import Language.PureScript.Errors (MultipleErrors(..), SimpleErrorMessage(..), addHint, defaultPPEOptions, errorMessage', errorMessage'', prettyPrintMultipleErrors) import Language.PureScript.Externs (ExternsFile, applyExternsFileToEnvironment, moduleToExternsFile) import Language.PureScript.Linter (Name(..), lint, lintImports) -import Language.PureScript.ModuleDependencies (DependencyDepth(..), moduleSignature, sortModules) import Language.PureScript.Names (ModuleName(..), isBuiltinModuleName, runModuleName) import Language.PureScript.Renamer (renameInModule) import Language.PureScript.Sugar (Env, collapseBindingGroups, createBindingGroups, desugar, desugarCaseGuards, externsEnv, primEnv) import Language.PureScript.TypeChecker (CheckState(..), emptyCheckState, typeCheckModule) -import Language.PureScript.Make.BuildPlan (BuildJobResult(..), BuildPlan(..), getResult, isUpToDate) -import Language.PureScript.Make.BuildPlan qualified as BuildPlan -import Language.PureScript.Make.ExternsDiff (checkDiffs, emptyDiff, diffExterns) -import Language.PureScript.Make.Cache qualified as Cache import Language.PureScript.Make.Actions as Actions import Language.PureScript.Make.Monad as Monad ( Make(..), @@ -69,7 +59,10 @@ import Language.PureScript.Make.Monad as Monad getTimestamp, getCurrentTime, copyFile ) +import Language.PureScript.Make.Query (Query(..)) +import Language.PureScript.Make.Rules (makeRules, MakeError(..)) import Language.PureScript.CoreFn qualified as CF +import Rock qualified import System.Directory (doesFileExist) import System.FilePath (replaceExtension) import Language.PureScript.TypeChecker.Monad (liftTypeCheckM) @@ -154,114 +147,79 @@ rebuildModuleWithIndex MakeActions{..} exEnv externs m@(Module _ _ moduleName _ evalSupplyT nextVar'' $ codegen renamed docs exts return exts -data MakeOptions = MakeOptions - { moCollectAllExterns :: Bool - } - --- | Compiles in "make" mode, compiling each module separately to a @.js@ file --- and an @externs.cbor@ file. --- --- If timestamps or hashes have not changed, existing externs files can be used --- to provide upstream modules' types without having to typecheck those modules --- again. --- --- It collects and returns externs for all modules passed. -make :: forall m. (MonadIO m, MonadBaseControl IO m, MonadError MultipleErrors m, MonadWriter MultipleErrors m) - => MakeActions m - -> [CST.PartialResult Module] - -> m [ExternsFile] -make = make' (MakeOptions {moCollectAllExterns = True}) - --- | Compiles in "make" mode, compiling each module separately to a @.js@ file --- and an @externs.cbor@ file. +-- | Compiles in "make" mode using rock for demand-driven incremental compilation. +-- Each module is compiled separately to a @.js@ file and an @externs.cbor@ file. +-- Rock automatically memoizes query results within a build to avoid redundant work. -- --- This version of make returns nothing. -make_ :: forall m. (MonadIO m, MonadBaseControl IO m, MonadError MultipleErrors m, MonadWriter MultipleErrors m) - => MakeActions m - -> [CST.PartialResult Module] - -> m () -make_ ma ms = void $ make' (MakeOptions {moCollectAllExterns = False}) ma ms - -make' :: forall m. (MonadIO m, MonadBaseControl IO m, MonadError MultipleErrors m, MonadWriter MultipleErrors m) - => MakeOptions - -> MakeActions m +-- It collects and returns externs for all modules passed, in topological order. +make :: MakeActions Make -> [CST.PartialResult Module] - -> m [ExternsFile] -make' MakeOptions{..} ma@MakeActions{..} ms = do + -> Make [ExternsFile] +make ma ms = makeIncremental ma ms + +-- | Like 'make' but discards the result. +make_ :: MakeActions Make + -> [CST.PartialResult Module] + -> Make () +make_ ma ms = void $ makeIncremental ma ms + +-- | Rock-based incremental compilation. +-- Defines queries for each compilation phase and lets rock handle +-- memoization and dependency tracking. +makeIncremental + :: MakeActions Make + -> [CST.PartialResult Module] + -> Make [ExternsFile] +makeIncremental ma@MakeActions{..} ms = do + -- Validate module names (no Prim redefinitions, no duplicates) checkModuleNames - cacheDb <- readCacheDb - - (sorted, graph) <- sortModules Transitive (moduleSignature . CST.resPartial) ms - let opts = BuildPlan.Options {optPreloadAllExterns = moCollectAllExterns} - (buildPlan, newCacheDb) <- BuildPlan.construct opts ma cacheDb (sorted, graph) - - -- Limit concurrent module builds to the number of capabilities as - -- (by default) inferred from `+RTS -N -RTS` or set explicitly like `-N4`. - -- This is to ensure that modules complete fully before moving on, to avoid - -- holding excess memory during compilation from modules that were paused - -- by the Haskell runtime. - capabilities <- getNumCapabilities - let concurrency = max 1 capabilities - lock <- C.newQSem concurrency - - let sortedModuleNames = getModuleName . CST.resPartial <$> sorted - let toBeRebuilt = filter (BuildPlan.needsRebuild buildPlan . getModuleName . CST.resPartial) sorted - let totalModuleCount = length toBeRebuilt - for_ toBeRebuilt $ \m -> fork $ do - let moduleName = getModuleName . CST.resPartial $ m - let deps = fromMaybe (internalError "make: module not found in dependency graph.") (lookup moduleName graph) - buildModule lock buildPlan moduleName totalModuleCount - (spanName . getModuleSourceSpan . CST.resPartial $ m) - (fst $ CST.resFull m) - (fmap importPrim . snd $ CST.resFull m) - (deps `inOrderOf` sortedModuleNames) - - -- Prevent hanging on other modules when there is an internal error - -- (the exception is thrown, but other threads waiting on MVars are released) - `onException` BuildPlan.markComplete buildPlan moduleName (BuildJobFailed mempty) - - -- Wait for all threads to complete, and collect results (and errors). - (failures, successes) <- - let - splitResults = \case - BuildJobSucceeded _ exts _ -> - Right exts - BuildJobFailed errs -> - Left errs - BuildJobSkipped -> - Left mempty - in - M.mapEither splitResults <$> BuildPlan.collectResults buildPlan - -- Write the updated build cache database to disk - writeCacheDb $ Cache.removeModules (M.keysSet failures) newCacheDb - - writePackageJson - - -- If generating docs, also generate them for the Prim modules - outputPrimDocs - -- All threads have completed, rethrow any caught errors. - let errors = M.elems failures - unless (null errors) $ throwError (mconcat errors) - - -- Here we return all the ExternsFile in the ordering of the topological sort, - -- so they can be folded into an Environment. This result is used in the tests - -- and in PSCI. - let lookupResult mn@(ModuleName name) = - fromMaybe (internalError $ "make: module not found in results: " <> T.unpack name) - $ M.lookup mn successes - - pure $ - if moCollectAllExterns then - map lookupResult sortedModuleNames - else - mapMaybe (flip M.lookup successes) sortedModuleNames + -- Get compiler options from the Make monad's Reader environment + opts <- ask + + -- Build the module map for the rules to close over + let moduleMap = M.fromList + [ (getModuleName (CST.resPartial pr), pr) | pr <- ms ] + + -- IORef to accumulate warnings from rock Task executions + warningsRef <- liftIO $ newIORef mempty + -- IORef for rock's within-build memoization cache + memoVar <- liftIO $ newIORef mempty + + -- The per-module compilation function, partially applied with MakeActions + let compileFn = rebuildModule' ma + + -- Construct memoized rock rules + let rules :: Rock.Rules Query + rules = Rock.memoise memoVar + $ makeRules moduleMap opts warningsRef compileFn + + -- Run the rock task: sort modules and compile each one. + -- We catch SomeException because errors from liftMake are wrapped in MakeError, + -- but other exceptions (IOException, etc.) might also propagate. + let rockTask = Rock.runTask rules $ do + sorted <- Rock.fetch SortedModules + traverse (\mn -> Rock.fetch (CompileModule mn)) sorted + result <- liftIO (try rockTask) :: Make (Either SomeException [ExternsFile]) + + -- Collect warnings accumulated during rock execution and emit them + extraWarnings <- liftIO $ readIORef warningsRef + tell extraWarnings + + case result of + Left exc + | Just (MakeError errs) <- fromException exc -> throwError errs + | otherwise -> liftIO $ throwIO exc + Right externs -> do + writePackageJson + outputPrimDocs + pure externs where - checkModuleNames :: m () + checkModuleNames :: Make () checkModuleNames = checkNoPrim *> checkModuleNamesAreUnique - checkNoPrim :: m () + checkNoPrim :: Make () checkNoPrim = for_ ms $ \m -> let mn = getModuleName $ CST.resPartial m @@ -270,7 +228,7 @@ make' MakeOptions{..} ma@MakeActions{..} ms = do . errorMessage' (getModuleSourceSpan $ CST.resPartial m) $ CannotDefinePrimModules mn - checkModuleNamesAreUnique :: m () + checkModuleNamesAreUnique :: Make () checkModuleNamesAreUnique = for_ (findDuplicates (getModuleName . CST.resPartial) ms) $ \mss -> throwError . flip foldMap mss $ \ms' -> @@ -284,88 +242,6 @@ make' MakeOptions{..} ma@MakeActions{..} ms = do [] -> Nothing xss -> Just xss - -- Sort a list so its elements appear in the same order as in another list. - inOrderOf :: (Ord a) => [a] -> [a] -> [a] - inOrderOf xs ys = let s = S.fromList xs in filter (`S.member` s) ys - - buildModule :: QSem -> BuildPlan -> ModuleName -> Int -> FilePath -> [CST.ParserWarning] -> Either (NEL.NonEmpty CST.ParserError) Module -> [ModuleName] -> m () - buildModule lock buildPlan moduleName cnt fp pwarnings mres deps = do - result <- flip catchError (return . BuildJobFailed) $ do - let pwarnings' = CST.toMultipleWarnings fp pwarnings - tell pwarnings' - m <- CST.unwrapParserError fp mres - -- We need to wait for dependencies to be built, before checking if the current - -- module should be rebuilt, so the first thing to do is to wait on the - -- MVars for the module's dependencies. - mexterns <- fmap unzip . sequence <$> traverse (getResult buildPlan) deps - - case mexterns of - Just (_, depsDiffExterns) -> do - let externs = fst <$> depsDiffExterns - let prevResult = BuildPlan.getPrevResult buildPlan moduleName - let depsDiffs = traverse snd depsDiffExterns - let maySkipBuild moduleIndex - -- We may skip built only for up-to-date modules. - | Just (status, exts) <- prevResult - , isUpToDate status - -- Check if no dep's externs have changed. If any of the diffs - -- is Nothing means we can not check and need to rebuild. - , Just False <- checkDiffs m <$> depsDiffs = do - -- We should update modification times to mark existing - -- compilation results as actual. If it fails to update timestamp - -- on any of exiting codegen targets, it will run the build process. - updated <- updateOutputTimestamp moduleName - if updated then do - progress $ SkippingModule moduleName moduleIndex - pure $ Just (exts, MultipleErrors [], Just (emptyDiff moduleName)) - else - pure Nothing - | otherwise = pure Nothing - - -- We need to ensure that all dependencies have been included in Env. - C.modifyMVar_ (bpEnv buildPlan) $ \env -> do - let - go :: Env -> ModuleName -> m Env - go e dep = case lookup dep (zip deps externs) of - Just exts - | not (M.member dep e) -> externsEnv e exts - _ -> return e - foldM go env deps - env <- C.readMVar (bpEnv buildPlan) - idx <- C.takeMVar (bpIndex buildPlan) - C.putMVar (bpIndex buildPlan) (idx + 1) - - (exts, warnings, diff) <- do - let doBuild = do - -- Bracket all of the per-module work behind the semaphore, including - -- forcing the result. This is done to limit concurrency and keep - -- memory usage down; see comments above. - (exts, warnings) <- bracket_ (C.waitQSem lock) (C.signalQSem lock) $ do - -- Eventlog markers for profiling; see debug/eventlog.js - liftBase $ traceMarkerIO $ T.unpack (runModuleName moduleName) <> " start" - -- Force the externs and warnings to avoid retaining excess module - -- data after the module is finished compiling. - extsAndWarnings <- evaluate . force <=< listen $ do - rebuildModuleWithIndex ma env externs m (Just (idx, cnt)) - liftBase $ traceMarkerIO $ T.unpack (runModuleName moduleName) <> " end" - return extsAndWarnings - let diff = diffExterns exts <$> (snd <$> prevResult) <*> depsDiffs - pure (exts, warnings, diff) - maySkipBuild (Just (idx, cnt)) >>= maybe doBuild pure - return $ BuildJobSucceeded (pwarnings' <> warnings) exts diff - - -- If we got Nothing for deps externs, that means one of the deps failed - -- to compile. Though if we have a previous built result we will keep to - -- avoid potentially unnecessary recompilation next time. - Nothing -> return $ - case BuildPlan.getPrevResult buildPlan moduleName of - Just (_, exts) -> - BuildJobSucceeded (MultipleErrors []) exts (Just (emptyDiff moduleName)) - Nothing -> - BuildJobSkipped - - BuildPlan.markComplete buildPlan moduleName result - -- | Infer the module name for a module by looking for the same filename with -- a .js extension. inferForeignModules diff --git a/src/Language/PureScript/Make/Query.hs b/src/Language/PureScript/Make/Query.hs new file mode 100644 index 0000000000..8100602550 --- /dev/null +++ b/src/Language/PureScript/Make/Query.hs @@ -0,0 +1,124 @@ +{-# LANGUAGE GADTs #-} +{-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeFamilies #-} +{-# LANGUAGE UndecidableInstances #-} + +module Language.PureScript.Make.Query + ( Query(..) + ) where + +import Prelude + +import Data.Constraint.Extras.TH (deriveArgDict) +import Data.GADT.Compare (GEq(..), GCompare(..), GOrdering(..)) +import Data.GADT.Show (GShow(..)) +import Data.Hashable (Hashable(..)) +import Data.Map qualified as M +import Data.Some (Some(..)) +import Data.Type.Equality ((:~:)(..)) + +import Language.PureScript.AST (Module) +import Language.PureScript.Environment (Environment) +import Language.PureScript.Externs (ExternsFile) +import Language.PureScript.Names (ModuleName(..)) +import Language.PureScript.Sugar.Names.Env (Env) + +-- | Queries for the rock-based incremental compilation pipeline. +-- +-- Each constructor represents a computation that can depend on other queries +-- via @fetch@. Rock automatically tracks these dependencies for memoization +-- and incremental recomputation. +data Query a where + -- | Input query: the pre-parsed module provided by the caller. + -- In rock's verifyTraces, this would be marked as 'Input' (can change between builds). + InputModule :: ModuleName -> Query Module + + -- | Dependency graph: maps each module to its (transitively) sorted dependencies. + ModuleGraph :: Query (M.Map ModuleName [ModuleName]) + + -- | Sorted module names in topological order (leaves first). + SortedModules :: Query [ModuleName] + + -- | Build the sugar names Env for a module from its dependencies' externs. + ModuleSugarEnv :: ModuleName -> Query Env + + -- | Build the typechecker Environment from dependency externs. + ModuleTypeEnv :: ModuleName -> Query Environment + + -- | Full per-module compilation: desugar, typecheck, corefn, codegen. + -- Returns the module's ExternsFile. + CompileModule :: ModuleName -> Query ExternsFile + +deriving instance Show (Query a) + +instance Eq (Query a) where + InputModule a == InputModule b = a == b + ModuleGraph == ModuleGraph = True + SortedModules == SortedModules = True + ModuleSugarEnv a == ModuleSugarEnv b = a == b + ModuleTypeEnv a == ModuleTypeEnv b = a == b + CompileModule a == CompileModule b = a == b + +instance GShow Query where + gshowsPrec = showsPrec + +-- | GEq instance: structural equality on the query key, returning type-level +-- proof (Refl) when two queries are identical. +instance GEq Query where + geq (InputModule a) (InputModule b) + | a == b = Just Refl + geq (ModuleGraph) (ModuleGraph) = Just Refl + geq (SortedModules) (SortedModules) = Just Refl + geq (ModuleSugarEnv a) (ModuleSugarEnv b) + | a == b = Just Refl + geq (ModuleTypeEnv a) (ModuleTypeEnv b) + | a == b = Just Refl + geq (CompileModule a) (CompileModule b) + | a == b = Just Refl + geq _ _ = Nothing + +-- | GCompare instance required by some rock operations. +instance GCompare Query where + gcompare (InputModule a) (InputModule b) = case compare a b of + EQ -> GEQ; LT -> GLT; GT -> GGT + gcompare (InputModule _) _ = GLT + gcompare _ (InputModule _) = GGT + + gcompare ModuleGraph ModuleGraph = GEQ + gcompare ModuleGraph _ = GLT + gcompare _ ModuleGraph = GGT + + gcompare SortedModules SortedModules = GEQ + gcompare SortedModules _ = GLT + gcompare _ SortedModules = GGT + + gcompare (ModuleSugarEnv a) (ModuleSugarEnv b) = case compare a b of + EQ -> GEQ; LT -> GLT; GT -> GGT + gcompare (ModuleSugarEnv _) _ = GLT + gcompare _ (ModuleSugarEnv _) = GGT + + gcompare (ModuleTypeEnv a) (ModuleTypeEnv b) = case compare a b of + EQ -> GEQ; LT -> GLT; GT -> GGT + gcompare (ModuleTypeEnv _) _ = GLT + gcompare _ (ModuleTypeEnv _) = GGT + + gcompare (CompileModule a) (CompileModule b) = case compare a b of + EQ -> GEQ; LT -> GLT; GT -> GGT + +-- | Hashable instance for individual queries. +instance Hashable (Query a) where + hashWithSalt salt = \case + InputModule mn -> hashWithSalt salt (0 :: Int, mn) + ModuleGraph -> hashWithSalt salt (1 :: Int) + SortedModules -> hashWithSalt salt (2 :: Int) + ModuleSugarEnv mn -> hashWithSalt salt (3 :: Int, mn) + ModuleTypeEnv mn -> hashWithSalt salt (4 :: Int, mn) + CompileModule mn -> hashWithSalt salt (5 :: Int, mn) + +-- | Hashable for existentially-wrapped queries (required by rock's memoise). +instance Hashable (Some Query) where + hashWithSalt salt (Some q) = hashWithSalt salt q + +-- | ArgDict derivation for constraints-extras (needed for verifyTraces). +deriveArgDict ''Query diff --git a/src/Language/PureScript/Make/Rules.hs b/src/Language/PureScript/Make/Rules.hs new file mode 100644 index 0000000000..ae1d6796b0 --- /dev/null +++ b/src/Language/PureScript/Make/Rules.hs @@ -0,0 +1,150 @@ +{-# LANGUAGE GADTs #-} + +module Language.PureScript.Make.Rules + ( makeRules + , MakeError(..) + , liftMake + ) where + +import Prelude + +import Control.Exception (Exception, throwIO) +import Control.Monad.IO.Class (liftIO) +import Control.Monad.Writer.Class (tell) +import Control.Monad (foldM) +import Data.IORef (IORef, atomicModifyIORef') +import Data.List (foldl') +import Data.Map qualified as M +import Data.Maybe (fromMaybe) +import Data.Set qualified as S + +import Rock qualified + +import Language.PureScript.AST (Module(..), getModuleName, getModuleSourceSpan) +import Language.PureScript.AST.SourcePos (spanName) +import Language.PureScript.Crash (internalError) +import Language.PureScript.CST qualified as CST +import Language.PureScript.Environment (initEnvironment) +import Language.PureScript.Errors (MultipleErrors) +import Language.PureScript.Externs (ExternsFile, applyExternsFileToEnvironment) +import Language.PureScript.Make.Monad (Make, runMake) +import Language.PureScript.Make.Query (Query(..)) +import Language.PureScript.ModuleDependencies (DependencyDepth(..), moduleSignature, sortModules) +import Language.PureScript.Names (ModuleName, runModuleName) +import Language.PureScript.Options (Options) +import Language.PureScript.Sugar (Env, externsEnv, primEnv) + +import Control.Monad.Writer.Strict (runWriterT) + +-- | Exception wrapper for compilation errors, used to propagate errors from +-- the 'Make' monad through rock's IO-based 'Task'. +newtype MakeError = MakeError MultipleErrors + deriving (Show) + +instance Exception MakeError + +-- | Run a 'Make' action inside rock's 'Task' monad. +-- Errors become IO exceptions; warnings are accumulated in the IORef. +liftMake :: Options -> IORef MultipleErrors -> Make a -> Rock.Task Query a +liftMake opts warningsRef action = liftIO $ do + (result, warnings) <- runMake opts action + atomicModifyIORef' warningsRef (\w -> (w <> warnings, ())) + case result of + Left errs -> throwIO (MakeError errs) + Right a -> pure a + +-- | The type of a single-module compilation function. +-- This is passed as a parameter to avoid circular module imports +-- (the implementation lives in Language.PureScript.Make). +type CompileFn = Env -> [ExternsFile] -> Module -> Make ExternsFile + +-- | Define the rock rules for the incremental compilation pipeline. +-- +-- The rules close over: +-- * @modules@: map from module name to pre-parsed partial result +-- * @opts@: compiler options +-- * @warningsRef@: accumulator for compilation warnings +-- * @compileFn@: the per-module compilation function (desugar + typecheck + codegen) +makeRules + :: M.Map ModuleName (CST.PartialResult Module) + -> Options + -> IORef MultipleErrors + -> CompileFn + -> Rock.Rules Query +makeRules modules opts warningsRef compileFn = \case + + InputModule mn -> + case M.lookup mn modules of + Just pr -> pure (CST.resPartial pr) + Nothing -> liftIO . throwIO . MakeError $ internalError + ("makeRules: InputModule: module not found: " <> show (runModuleName mn)) + + SortedModules -> do + -- Fetch all input modules to establish rock dependency tracking + let allNames = M.keys modules + _ <- traverse (\mn -> Rock.fetch (InputModule mn)) allNames + -- Use existing sortModules (may throw on circular deps) + liftMake opts warningsRef $ do + let prs = M.elems modules + (sorted, _graph) <- sortModules Transitive (moduleSignature . CST.resPartial) prs + pure $ map (getModuleName . CST.resPartial) sorted + + ModuleGraph -> do + -- Fetch all input modules to establish rock dependency tracking + let allNames = M.keys modules + _ <- traverse (\mn -> Rock.fetch (InputModule mn)) allNames + -- Use existing sortModules to build the graph + liftMake opts warningsRef $ do + let prs = M.elems modules + (_sorted, graph) <- sortModules Transitive (moduleSignature . CST.resPartial) prs + pure $ M.fromList graph + + ModuleSugarEnv mn -> do + graph <- Rock.fetch ModuleGraph + sorted <- Rock.fetch SortedModules + let deps = fromMaybe [] $ M.lookup mn graph + -- Sort deps in topological order (from SortedModules). + -- This is critical because externsEnv resolves each module's imports + -- against the accumulated Env, so dependencies must be processed + -- before their dependents. + let depsSet = S.fromList deps + sortedDeps = filter (`S.member` depsSet) sorted + depExterns <- traverse (\dep -> Rock.fetch (CompileModule dep)) sortedDeps + -- Build sugar Env from dependency externs + liftMake opts warningsRef $ + fmap fst . runWriterT $ foldM externsEnv primEnv depExterns + + ModuleTypeEnv mn -> do + graph <- Rock.fetch ModuleGraph + let deps = fromMaybe [] $ M.lookup mn graph + depExterns <- traverse (\dep -> Rock.fetch (CompileModule dep)) deps + -- Build typechecker Environment (pure computation) + pure $ foldl' (flip applyExternsFileToEnvironment) initEnvironment depExterns + + CompileModule mn -> do + -- Establish dependency on the input module (for rock tracking) + _inputModule <- Rock.fetch (InputModule mn) + -- Fetch the sugar environment from dependencies + sugarEnv <- Rock.fetch (ModuleSugarEnv mn) + -- Fetch dependency externs (in topological order) + graph <- Rock.fetch ModuleGraph + sorted <- Rock.fetch SortedModules + let deps = fromMaybe [] $ M.lookup mn graph + depsSet = S.fromList deps + sortedDeps = filter (`S.member` depsSet) sorted + depExterns <- traverse (\dep -> Rock.fetch (CompileModule dep)) sortedDeps + + -- Get the full parse result from the closed-over map + let pr = fromMaybe (internalError $ "makeRules: CompileModule: module not found: " <> show (runModuleName mn)) + (M.lookup mn modules) + fp = spanName . getModuleSourceSpan . CST.resPartial $ pr + (pwarnings, mres) = CST.resFull pr + + -- Run compilation in the Make monad + liftMake opts warningsRef $ do + -- Emit parser warnings + tell $ CST.toMultipleWarnings fp pwarnings + -- Unwrap parse result (throws on parse error) + m <- CST.unwrapParserError fp mres + -- Run the full compilation pipeline + compileFn sugarEnv depExterns m diff --git a/src/Language/PureScript/Names.hs b/src/Language/PureScript/Names.hs index 094ae5773d..807b9f3187 100644 --- a/src/Language/PureScript/Names.hs +++ b/src/Language/PureScript/Names.hs @@ -12,6 +12,7 @@ import Control.Applicative ((<|>)) import Control.Monad.Supply.Class (MonadSupply(..)) import Control.DeepSeq (NFData) import Data.Functor.Contravariant (contramap) +import Data.Hashable (Hashable) import Data.Vector qualified as V import GHC.Generics (Generic) @@ -190,7 +191,7 @@ coerceProperName = ProperName . runProperName -- newtype ModuleName = ModuleName Text deriving (Show, Eq, Ord, Generic) - deriving newtype Serialise + deriving newtype (Serialise, Hashable) instance NFData ModuleName diff --git a/stack.yaml b/stack.yaml index afbac89bca..e5e46856f7 100644 --- a/stack.yaml +++ b/stack.yaml @@ -2,8 +2,10 @@ # (or the CI build will fail) resolver: lts-22.43 pvp-bounds: both +system-ghc: true packages: - '.' +- '../rock' ghc-options: # Build with advanced optimizations enabled by default "$locals": -O2 -Werror @@ -17,6 +19,8 @@ extra-deps: - haskeline-0.8.2 - these-1.2.1 - aeson-better-errors-0.9.1.3 +# Rock incremental computation dependencies +- dependent-hashmap-0.1.0.1 - github: purescript/cheapskate commit: 8bfaf4beeb108e97a274ed51303f278905979e87 diff --git a/stack.yaml.lock b/stack.yaml.lock index 0af2cebb41..50a3504824 100644 --- a/stack.yaml.lock +++ b/stack.yaml.lock @@ -39,6 +39,13 @@ packages: size: 492 original: hackage: aeson-better-errors-0.9.1.3 +- completed: + hackage: dependent-hashmap-0.1.0.1@sha256:6d1c20bd79f32d8daebd3cc741f884cc3d093118e3b876eb957defd4c594a966,1892 + pantry-tree: + sha256: 817b36b81735d96696c5b165357c431e0b396dcf45afb8b4384fbfd4f4499cbb + size: 334 + original: + hackage: dependent-hashmap-0.1.0.1 - completed: name: cheapskate pantry-tree: From 576f0012911f7904e57e37454b196d6663bc90be Mon Sep 17 00:00:00 2001 From: Michal Kozakiewicz Date: Fri, 10 Apr 2026 11:35:14 +0000 Subject: [PATCH 02/11] Add cross-build incremental recompilation with CacheDb and ExternsDiff Integrate the existing CacheDb (source hash checking) and ExternsDiff (externs change analysis) systems into the rock query pipeline to support cross-build incremental compilation. When a module's source is unchanged: - Load cached externs from disk instead of recompiling - Use ExternsDiff to determine if dependency changes affect this module - Skip recompilation when dependency externs changes don't impact imports On build failure, remove only the failed modules from CacheDb so the next build correctly recompiles them while preserving cache for unaffected modules. 48/51 make tests pass. 2 remaining edge cases: - Docs target freshness check (docs.json outdated detection) - Separately-rebuilt module detection (IDE rebuild scenario) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Language/PureScript/Make.hs | 85 ++++++++++++++++++-- src/Language/PureScript/Make/Rules.hs | 108 ++++++++++++++++---------- 2 files changed, 147 insertions(+), 46 deletions(-) diff --git a/src/Language/PureScript/Make.hs b/src/Language/PureScript/Make.hs index 7665b0de10..d8f98084ff 100644 --- a/src/Language/PureScript/Make.hs +++ b/src/Language/PureScript/Make.hs @@ -23,15 +23,17 @@ import Data.Function (on) import Data.Foldable (fold, for_) import Data.IORef (newIORef, readIORef) import Data.List (foldl', sortOn) +import Data.Maybe (mapMaybe) import Data.List.NonEmpty qualified as NEL import Data.Map qualified as M +import Data.Set qualified as S import Data.Text qualified as T import Language.PureScript.AST (ErrorMessageHint(..), Module(..), getModuleName, getModuleSourceSpan, importPrim) import Language.PureScript.Crash (internalError) import Language.PureScript.CST qualified as CST import Language.PureScript.Docs.Convert qualified as Docs import Language.PureScript.Environment (initEnvironment) -import Language.PureScript.Errors (MultipleErrors(..), SimpleErrorMessage(..), addHint, defaultPPEOptions, errorMessage', errorMessage'', prettyPrintMultipleErrors) +import Language.PureScript.Errors (MultipleErrors(..), SimpleErrorMessage(..), addHint, defaultPPEOptions, errorMessage', errorMessage'', errorModule, prettyPrintMultipleErrors) import Language.PureScript.Externs (ExternsFile, applyExternsFileToEnvironment, moduleToExternsFile) import Language.PureScript.Linter (Name(..), lint, lintImports) import Language.PureScript.Names (ModuleName(..), isBuiltinModuleName, runModuleName) @@ -39,6 +41,7 @@ import Language.PureScript.Renamer (renameInModule) import Language.PureScript.Sugar (Env, collapseBindingGroups, createBindingGroups, desugar, desugarCaseGuards, externsEnv, primEnv) import Language.PureScript.TypeChecker (CheckState(..), emptyCheckState, typeCheckModule) import Language.PureScript.Make.Actions as Actions +import Language.PureScript.Make.Cache qualified as Cache import Language.PureScript.Make.Monad as Monad ( Make(..), writeTextFile, @@ -63,7 +66,7 @@ import Language.PureScript.Make.Query (Query(..)) import Language.PureScript.Make.Rules (makeRules, MakeError(..)) import Language.PureScript.CoreFn qualified as CF import Rock qualified -import System.Directory (doesFileExist) +import System.Directory (doesFileExist, getCurrentDirectory) import System.FilePath (replaceExtension) import Language.PureScript.TypeChecker.Monad (liftTypeCheckM) @@ -181,10 +184,22 @@ makeIncremental ma@MakeActions{..} ms = do let moduleMap = M.fromList [ (getModuleName (CST.resPartial pr), pr) | pr <- ms ] + -- Read cache database for incremental build support + cacheDb <- readCacheDb + + -- Compute cache status for each module: check which modules have + -- unchanged source files and valid cached externs on disk. + -- Also load all previously cached externs (for ExternsDiff computation). + (cacheStatus, allCachedExterns) <- computeCacheStatus ma cacheDb (M.keys moduleMap) + -- Compute new CacheDb entries while checking + newCacheDb <- computeNewCacheDb ma cacheDb (M.keys moduleMap) + -- IORef to accumulate warnings from rock Task executions warningsRef <- liftIO $ newIORef mempty -- IORef for rock's within-build memoization cache memoVar <- liftIO $ newIORef mempty + -- IORef for tracking ExternsDiff of recompiled modules + diffsRef <- liftIO $ newIORef M.empty -- The per-module compilation function, partially applied with MakeActions let compileFn = rebuildModule' ma @@ -192,11 +207,9 @@ makeIncremental ma@MakeActions{..} ms = do -- Construct memoized rock rules let rules :: Rock.Rules Query rules = Rock.memoise memoVar - $ makeRules moduleMap opts warningsRef compileFn + $ makeRules moduleMap opts ma warningsRef compileFn cacheStatus allCachedExterns diffsRef -- Run the rock task: sort modules and compile each one. - -- We catch SomeException because errors from liftMake are wrapped in MakeError, - -- but other exceptions (IOException, etc.) might also propagate. let rockTask = Rock.runTask rules $ do sorted <- Rock.fetch SortedModules traverse (\mn -> Rock.fetch (CompileModule mn)) sorted @@ -208,9 +221,17 @@ makeIncremental ma@MakeActions{..} ms = do case result of Left exc - | Just (MakeError errs) <- fromException exc -> throwError errs + | Just (MakeError errs) <- fromException exc -> do + -- On failure, remove ONLY the failed modules from CacheDb. + -- This ensures the failed modules are rebuilt next time, while + -- preserving cache entries for modules that didn't fail. + let failedModules = S.fromList $ mapMaybe errorModule (runMultipleErrors errs) + writeCacheDb $ Cache.removeModules failedModules newCacheDb + throwError errs | otherwise -> liftIO $ throwIO exc Right externs -> do + -- Write updated cache database + writeCacheDb newCacheDb writePackageJson outputPrimDocs pure externs @@ -242,6 +263,58 @@ makeIncremental ma@MakeActions{..} ms = do [] -> Nothing xss -> Just xss +-- | Compute cache status for each module: determine which modules have +-- unchanged source files and valid cached externs on disk. +-- Returns: +-- 1. CacheStatus: map from module name to Maybe ExternsFile (Just = can reuse, Nothing = needs rebuild) +-- 2. AllCachedExterns: map of ALL previously cached externs (for ExternsDiff computation) +computeCacheStatus + :: MakeActions Make + -> Cache.CacheDb + -> [ModuleName] + -> Make (M.Map ModuleName (Maybe ExternsFile), M.Map ModuleName ExternsFile) +computeCacheStatus MakeActions{..} cacheDb moduleNames = do + results <- traverse checkModule moduleNames + let cacheStatusMap = M.fromList [(mn, status) | (mn, status, _) <- results] + allCached = M.fromList [(mn, exts) | (mn, _, Just exts) <- results] + pure (cacheStatusMap, allCached) + where + checkModule mn = do + -- Always try to load cached externs (needed for diff computation) + (_, mbExterns) <- readExterns mn + inputInfo <- getInputTimestampsAndHashes mn + case inputInfo of + Left RebuildAlways -> pure (mn, Nothing, mbExterns) + Left RebuildNever -> pure (mn, mbExterns, mbExterns) + Right timestamps -> do + cwd <- liftIO getCurrentDirectory + (_newCacheInfo, upToDate) <- Cache.checkChanged cacheDb mn cwd timestamps + if upToDate then do + outputTs <- getOutputTimestamp mn + case outputTs of + Nothing -> pure (mn, Nothing, mbExterns) + Just _ -> pure (mn, mbExterns, mbExterns) + else + pure (mn, Nothing, mbExterns) + +-- | Compute the updated CacheDb entries for all modules. +computeNewCacheDb + :: MakeActions Make + -> Cache.CacheDb + -> [ModuleName] + -> Make Cache.CacheDb +computeNewCacheDb MakeActions{..} cacheDb moduleNames = do + foldM updateModule cacheDb moduleNames + where + updateModule db mn = do + inputInfo <- getInputTimestampsAndHashes mn + case inputInfo of + Left _ -> pure db -- RebuildPolicy modules don't update cache + Right timestamps -> do + cwd <- liftIO getCurrentDirectory + (newCacheInfo, _) <- Cache.checkChanged db mn cwd timestamps + pure $ M.insert mn newCacheInfo db + -- | Infer the module name for a module by looking for the same filename with -- a .js extension. inferForeignModules diff --git a/src/Language/PureScript/Make/Rules.hs b/src/Language/PureScript/Make/Rules.hs index ae1d6796b0..3061845b54 100644 --- a/src/Language/PureScript/Make/Rules.hs +++ b/src/Language/PureScript/Make/Rules.hs @@ -4,15 +4,16 @@ module Language.PureScript.Make.Rules ( makeRules , MakeError(..) , liftMake + , CacheStatus ) where import Prelude import Control.Exception (Exception, throwIO) +import Control.Monad (foldM) import Control.Monad.IO.Class (liftIO) import Control.Monad.Writer.Class (tell) -import Control.Monad (foldM) -import Data.IORef (IORef, atomicModifyIORef') +import Data.IORef (IORef, atomicModifyIORef', readIORef) import Data.List (foldl') import Data.Map qualified as M import Data.Maybe (fromMaybe) @@ -27,6 +28,8 @@ import Language.PureScript.CST qualified as CST import Language.PureScript.Environment (initEnvironment) import Language.PureScript.Errors (MultipleErrors) import Language.PureScript.Externs (ExternsFile, applyExternsFileToEnvironment) +import Language.PureScript.Make.Actions (MakeActions(..), ProgressMessage(..)) +import Language.PureScript.Make.ExternsDiff (ExternsDiff, checkDiffs, diffExterns, emptyDiff) import Language.PureScript.Make.Monad (Make, runMake) import Language.PureScript.Make.Query (Query(..)) import Language.PureScript.ModuleDependencies (DependencyDepth(..), moduleSignature, sortModules) @@ -44,7 +47,6 @@ newtype MakeError = MakeError MultipleErrors instance Exception MakeError -- | Run a 'Make' action inside rock's 'Task' monad. --- Errors become IO exceptions; warnings are accumulated in the IORef. liftMake :: Options -> IORef MultipleErrors -> Make a -> Rock.Task Query a liftMake opts warningsRef action = liftIO $ do (result, warnings) <- runMake opts action @@ -54,24 +56,26 @@ liftMake opts warningsRef action = liftIO $ do Right a -> pure a -- | The type of a single-module compilation function. --- This is passed as a parameter to avoid circular module imports --- (the implementation lives in Language.PureScript.Make). type CompileFn = Env -> [ExternsFile] -> Module -> Make ExternsFile +-- | Pre-computed cache status for a module. +-- Nothing = needs rebuild, Just externs = source unchanged, cached externs available. +type CacheStatus = M.Map ModuleName (Maybe ExternsFile) + -- | Define the rock rules for the incremental compilation pipeline. --- --- The rules close over: --- * @modules@: map from module name to pre-parsed partial result --- * @opts@: compiler options --- * @warningsRef@: accumulator for compilation warnings --- * @compileFn@: the per-module compilation function (desugar + typecheck + codegen) makeRules :: M.Map ModuleName (CST.PartialResult Module) -> Options + -> MakeActions Make -> IORef MultipleErrors -> CompileFn + -> CacheStatus + -> M.Map ModuleName ExternsFile + -- ^ All previously cached externs (for ExternsDiff computation) + -> IORef (M.Map ModuleName ExternsDiff) + -- ^ IORef for tracking externs diffs of recompiled modules -> Rock.Rules Query -makeRules modules opts warningsRef compileFn = \case +makeRules modules opts actions warningsRef compileFn cacheStatus allCachedExterns diffsRef = \case InputModule mn -> case M.lookup mn modules of @@ -80,20 +84,16 @@ makeRules modules opts warningsRef compileFn = \case ("makeRules: InputModule: module not found: " <> show (runModuleName mn)) SortedModules -> do - -- Fetch all input modules to establish rock dependency tracking let allNames = M.keys modules _ <- traverse (\mn -> Rock.fetch (InputModule mn)) allNames - -- Use existing sortModules (may throw on circular deps) liftMake opts warningsRef $ do let prs = M.elems modules (sorted, _graph) <- sortModules Transitive (moduleSignature . CST.resPartial) prs pure $ map (getModuleName . CST.resPartial) sorted ModuleGraph -> do - -- Fetch all input modules to establish rock dependency tracking let allNames = M.keys modules _ <- traverse (\mn -> Rock.fetch (InputModule mn)) allNames - -- Use existing sortModules to build the graph liftMake opts warningsRef $ do let prs = M.elems modules (_sorted, graph) <- sortModules Transitive (moduleSignature . CST.resPartial) prs @@ -103,14 +103,9 @@ makeRules modules opts warningsRef compileFn = \case graph <- Rock.fetch ModuleGraph sorted <- Rock.fetch SortedModules let deps = fromMaybe [] $ M.lookup mn graph - -- Sort deps in topological order (from SortedModules). - -- This is critical because externsEnv resolves each module's imports - -- against the accumulated Env, so dependencies must be processed - -- before their dependents. - let depsSet = S.fromList deps + depsSet = S.fromList deps sortedDeps = filter (`S.member` depsSet) sorted depExterns <- traverse (\dep -> Rock.fetch (CompileModule dep)) sortedDeps - -- Build sugar Env from dependency externs liftMake opts warningsRef $ fmap fst . runWriterT $ foldM externsEnv primEnv depExterns @@ -118,15 +113,11 @@ makeRules modules opts warningsRef compileFn = \case graph <- Rock.fetch ModuleGraph let deps = fromMaybe [] $ M.lookup mn graph depExterns <- traverse (\dep -> Rock.fetch (CompileModule dep)) deps - -- Build typechecker Environment (pure computation) pure $ foldl' (flip applyExternsFileToEnvironment) initEnvironment depExterns CompileModule mn -> do - -- Establish dependency on the input module (for rock tracking) _inputModule <- Rock.fetch (InputModule mn) - -- Fetch the sugar environment from dependencies sugarEnv <- Rock.fetch (ModuleSugarEnv mn) - -- Fetch dependency externs (in topological order) graph <- Rock.fetch ModuleGraph sorted <- Rock.fetch SortedModules let deps = fromMaybe [] $ M.lookup mn graph @@ -134,17 +125,54 @@ makeRules modules opts warningsRef compileFn = \case sortedDeps = filter (`S.member` depsSet) sorted depExterns <- traverse (\dep -> Rock.fetch (CompileModule dep)) sortedDeps - -- Get the full parse result from the closed-over map - let pr = fromMaybe (internalError $ "makeRules: CompileModule: module not found: " <> show (runModuleName mn)) - (M.lookup mn modules) - fp = spanName . getModuleSourceSpan . CST.resPartial $ pr - (pwarnings, mres) = CST.resFull pr - - -- Run compilation in the Make monad - liftMake opts warningsRef $ do - -- Emit parser warnings - tell $ CST.toMultipleWarnings fp pwarnings - -- Unwrap parse result (throws on parse error) - m <- CST.unwrapParserError fp mres - -- Run the full compilation pipeline - compileFn sugarEnv depExterns m + let cachedExterns = case M.lookup mn cacheStatus of + Just (Just exts) -> Just exts + _ -> Nothing + + case cachedExterns of + Just cached -> do + -- Source unchanged. Check if dep changes affect this module. + diffs <- liftIO $ readIORef diffsRef + let depDiffs = map (\dep -> fromMaybe (emptyDiff dep) (M.lookup dep diffs)) sortedDeps + pr = fromMaybe (internalError "makeRules: missing module") + (M.lookup mn modules) + -- Use the full module (not just the header) for checkDiffs, + -- since it needs to inspect declarations to find usage of changed refs. + fullModule = case snd (CST.resFull pr) of + Right m -> m + Left _ -> CST.resPartial pr -- fallback to header if parse failed + needsRebuild = checkDiffs fullModule depDiffs + + if needsRebuild then do + exts <- doCompile mn sugarEnv depExterns + let diff = diffExterns exts cached depDiffs + liftIO $ atomicModifyIORef' diffsRef (\d -> (M.insert mn diff d, ())) + pure exts + else do + liftMake opts warningsRef $ + progress actions $ SkippingModule mn Nothing + liftIO $ atomicModifyIORef' diffsRef (\d -> (M.insert mn (emptyDiff mn) d, ())) + pure cached + + Nothing -> do + exts <- doCompile mn sugarEnv depExterns + -- Record diff against old cached externs (from allCachedExterns) + diffs <- liftIO $ readIORef diffsRef + let depDiffs = map (\dep -> fromMaybe (emptyDiff dep) (M.lookup dep diffs)) sortedDeps + diff = case M.lookup mn allCachedExterns of + Just old -> diffExterns exts old depDiffs + Nothing -> emptyDiff mn + liftIO $ atomicModifyIORef' diffsRef (\d -> (M.insert mn diff d, ())) + pure exts + + where + doCompile mn sugarEnv depExterns = do + let pr = fromMaybe (internalError $ "makeRules: CompileModule: module not found: " <> show (runModuleName mn)) + (M.lookup mn modules) + fp = spanName . getModuleSourceSpan . CST.resPartial $ pr + (pwarnings, mres) = CST.resFull pr + + liftMake opts warningsRef $ do + tell $ CST.toMultipleWarnings fp pwarnings + m <- CST.unwrapParserError fp mres + compileFn sugarEnv depExterns m From cbd1dbd880a4fe86c03d48f2d743e43385ef73ea Mon Sep 17 00:00:00 2001 From: Michal Kozakiewicz Date: Fri, 10 Apr 2026 12:16:26 +0000 Subject: [PATCH 03/11] Fix remaining make test failures and docs generation bug - Fix docs generation: pass module with Prim import to Docs.convertModule (was passing original module without importPrim, causing "Unknown type" errors for Prim types like Int) - Detect separately-rebuilt dependencies via output timestamp comparison: if a dependency's output is newer than the current module's, trigger recompilation even if source is unchanged - Handle RebuildNever modules correctly: use epoch timestamp to avoid forcing bottom values from test MakeActions All 51 make tests and 829 compiler tests now pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Language/PureScript/Make.hs | 22 ++++++--- src/Language/PureScript/Make/Rules.hs | 69 +++++++++++++++++---------- 2 files changed, 58 insertions(+), 33 deletions(-) diff --git a/src/Language/PureScript/Make.hs b/src/Language/PureScript/Make.hs index d8f98084ff..abbe052b0c 100644 --- a/src/Language/PureScript/Make.hs +++ b/src/Language/PureScript/Make.hs @@ -63,9 +63,10 @@ import Language.PureScript.Make.Monad as Monad getCurrentTime, copyFile ) import Language.PureScript.Make.Query (Query(..)) -import Language.PureScript.Make.Rules (makeRules, MakeError(..)) +import Language.PureScript.Make.Rules (makeRules, MakeError(..), CacheStatus) import Language.PureScript.CoreFn qualified as CF import Rock qualified +import Data.Time.Clock (UTCTime(..)) import System.Directory (doesFileExist, getCurrentDirectory) import System.FilePath (replaceExtension) import Language.PureScript.TypeChecker.Monad (liftTypeCheckM) @@ -141,7 +142,7 @@ rebuildModuleWithIndex MakeActions{..} exEnv externs m@(Module _ _ moduleName _ -- a bug in the compiler, which should be reported as such. -- 2. We do not want to perform any extra work generating docs unless the -- user has asked for docs to be generated. - let docs = case Docs.convertModule externs exEnv env' m of + let docs = case Docs.convertModule externs exEnv env' withPrim of Left errs -> internalError $ "Failed to produce docs for " ++ T.unpack (runModuleName moduleName) ++ "; details:\n" ++ prettyPrintMultipleErrors defaultPPEOptions errs @@ -272,7 +273,7 @@ computeCacheStatus :: MakeActions Make -> Cache.CacheDb -> [ModuleName] - -> Make (M.Map ModuleName (Maybe ExternsFile), M.Map ModuleName ExternsFile) + -> Make (CacheStatus, M.Map ModuleName ExternsFile) computeCacheStatus MakeActions{..} cacheDb moduleNames = do results <- traverse checkModule moduleNames let cacheStatusMap = M.fromList [(mn, status) | (mn, status, _) <- results] @@ -285,15 +286,22 @@ computeCacheStatus MakeActions{..} cacheDb moduleNames = do inputInfo <- getInputTimestampsAndHashes mn case inputInfo of Left RebuildAlways -> pure (mn, Nothing, mbExterns) - Left RebuildNever -> pure (mn, mbExterns, mbExterns) + Left RebuildNever -> do + -- RebuildNever modules are pinned — always use cached externs. + -- Use epoch as timestamp since these are never compared. + let epoch = UTCTime (toEnum 0) 0 + status = fmap (\exts -> (exts, epoch)) mbExterns + pure (mn, status, mbExterns) Right timestamps -> do cwd <- liftIO getCurrentDirectory (_newCacheInfo, upToDate) <- Cache.checkChanged cacheDb mn cwd timestamps if upToDate then do outputTs <- getOutputTimestamp mn - case outputTs of - Nothing -> pure (mn, Nothing, mbExterns) - Just _ -> pure (mn, mbExterns, mbExterns) + let status = do + exts <- mbExterns + ts <- outputTs + pure (exts, ts) + pure (mn, status, mbExterns) else pure (mn, Nothing, mbExterns) diff --git a/src/Language/PureScript/Make/Rules.hs b/src/Language/PureScript/Make/Rules.hs index 3061845b54..8bd5cf136e 100644 --- a/src/Language/PureScript/Make/Rules.hs +++ b/src/Language/PureScript/Make/Rules.hs @@ -18,6 +18,7 @@ import Data.List (foldl') import Data.Map qualified as M import Data.Maybe (fromMaybe) import Data.Set qualified as S +import Data.Time.Clock (UTCTime) import Rock qualified @@ -59,8 +60,8 @@ liftMake opts warningsRef action = liftIO $ do type CompileFn = Env -> [ExternsFile] -> Module -> Make ExternsFile -- | Pre-computed cache status for a module. --- Nothing = needs rebuild, Just externs = source unchanged, cached externs available. -type CacheStatus = M.Map ModuleName (Maybe ExternsFile) +-- Nothing = needs rebuild, Just (externs, timestamp) = source unchanged, cached externs available. +type CacheStatus = M.Map ModuleName (Maybe (ExternsFile, UTCTime)) -- | Define the rock rules for the incremental compilation pipeline. makeRules @@ -125,34 +126,50 @@ makeRules modules opts actions warningsRef compileFn cacheStatus allCachedExtern sortedDeps = filter (`S.member` depsSet) sorted depExterns <- traverse (\dep -> Rock.fetch (CompileModule dep)) sortedDeps - let cachedExterns = case M.lookup mn cacheStatus of - Just (Just exts) -> Just exts - _ -> Nothing - - case cachedExterns of - Just cached -> do - -- Source unchanged. Check if dep changes affect this module. - diffs <- liftIO $ readIORef diffsRef - let depDiffs = map (\dep -> fromMaybe (emptyDiff dep) (M.lookup dep diffs)) sortedDeps - pr = fromMaybe (internalError "makeRules: missing module") - (M.lookup mn modules) - -- Use the full module (not just the header) for checkDiffs, - -- since it needs to inspect declarations to find usage of changed refs. - fullModule = case snd (CST.resFull pr) of - Right m -> m - Left _ -> CST.resPartial pr -- fallback to header if parse failed - needsRebuild = checkDiffs fullModule depDiffs - - if needsRebuild then do + let cachedInfo = case M.lookup mn cacheStatus of + Just (Just (exts, ts)) -> Just (exts, ts) + _ -> Nothing + + case cachedInfo of + Just (cached, myTimestamp) -> do + -- Source unchanged. Check if any dep's output is newer than ours + -- (indicates the dep was rebuilt separately, e.g. by IDE). + let depTimestamps = map (\dep -> case M.lookup dep cacheStatus of + Just (Just (_, ts)) -> Just ts + _ -> Nothing) sortedDeps + depsNewerThanMe = any (\mts -> maybe False (> myTimestamp) mts) depTimestamps + + if depsNewerThanMe then do + -- A dep was rebuilt after us → must recompile exts <- doCompile mn sugarEnv depExterns - let diff = diffExterns exts cached depDiffs + diffs <- liftIO $ readIORef diffsRef + let depDiffs = map (\dep -> fromMaybe (emptyDiff dep) (M.lookup dep diffs)) sortedDeps + diff = case M.lookup mn allCachedExterns of + Just old -> diffExterns exts old depDiffs + Nothing -> emptyDiff mn liftIO $ atomicModifyIORef' diffsRef (\d -> (M.insert mn diff d, ())) pure exts else do - liftMake opts warningsRef $ - progress actions $ SkippingModule mn Nothing - liftIO $ atomicModifyIORef' diffsRef (\d -> (M.insert mn (emptyDiff mn) d, ())) - pure cached + -- Check if dep externs changes affect this module (ExternsDiff). + diffs <- liftIO $ readIORef diffsRef + let depDiffs = map (\dep -> fromMaybe (emptyDiff dep) (M.lookup dep diffs)) sortedDeps + pr = fromMaybe (internalError "makeRules: missing module") + (M.lookup mn modules) + fullModule = case snd (CST.resFull pr) of + Right m -> m + Left _ -> CST.resPartial pr + needsRebuild = checkDiffs fullModule depDiffs + + if needsRebuild then do + exts <- doCompile mn sugarEnv depExterns + let diff = diffExterns exts cached depDiffs + liftIO $ atomicModifyIORef' diffsRef (\d -> (M.insert mn diff d, ())) + pure exts + else do + liftMake opts warningsRef $ + progress actions $ SkippingModule mn Nothing + liftIO $ atomicModifyIORef' diffsRef (\d -> (M.insert mn (emptyDiff mn) d, ())) + pure cached Nothing -> do exts <- doCompile mn sugarEnv depExterns From 8e31a0394c0d894f6cd454dc98899738ed3e2576 Mon Sep 17 00:00:00 2001 From: Michal Kozakiewicz Date: Fri, 10 Apr 2026 13:39:42 +0000 Subject: [PATCH 04/11] Add parallel module compilation via forConcurrently Fork all module compilations concurrently using async's forConcurrently. Rock's memoise handles synchronization automatically - when module B depends on A, B's thread blocks on A's MVar until A completes. Performance on pr-admin (full rebuild): - Baseline (old make): 72s wall, 678s CPU, 968% utilization - Rock parallel: 117s wall, 1444s CPU, 1304% utilization Wall clock is 1.6x slower due to CPU overhead from memoization contention (many threads competing on atomicModifyIORef). The actual compilation work is the same; the overhead is in rock's coordination layer. This can be optimized further by batching modules by dependency depth or reducing memoization overhead. Co-Authored-By: Claude Opus 4.6 (1M context) --- purescript.cabal | 1 + src/Language/PureScript/Make.hs | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/purescript.cabal b/purescript.cabal index 43f54942a7..d614e83d67 100644 --- a/purescript.cabal +++ b/purescript.cabal @@ -155,6 +155,7 @@ common defaults -- specific version. aeson >=2.0.3.0 && <2.2, aeson-better-errors >=0.9.1.3 && <0.10, + async >=2.2.4 && <2.3, ansi-terminal >=0.11.3 && <1.1, array >=0.5.4.0 && <0.6, base >=4.16.2.0 && <4.19, diff --git a/src/Language/PureScript/Make.hs b/src/Language/PureScript/Make.hs index abbe052b0c..37c254f778 100644 --- a/src/Language/PureScript/Make.hs +++ b/src/Language/PureScript/Make.hs @@ -10,6 +10,7 @@ module Language.PureScript.Make import Prelude +import Control.Concurrent.Async (forConcurrently) import Control.Exception (SomeException, fromException, throwIO, try) import Control.Monad (foldM, void, when) import Control.Monad.Error.Class (MonadError(..)) @@ -210,10 +211,16 @@ makeIncremental ma@MakeActions{..} ms = do rules = Rock.memoise memoVar $ makeRules moduleMap opts ma warningsRef compileFn cacheStatus allCachedExterns diffsRef - -- Run the rock task: sort modules and compile each one. + -- Run the rock task: sort modules, then compile all in parallel. + -- Rock's memoise handles synchronization: if module B depends on A, + -- B's thread blocks on A's MVar until A completes. This gives us + -- natural parallelism bounded by the dependency graph. let rockTask = Rock.runTask rules $ do sorted <- Rock.fetch SortedModules - traverse (\mn -> Rock.fetch (CompileModule mn)) sorted + -- Fork all module compilations concurrently within the same Task. + -- Each fork shares the same Fetch function (and thus memoization). + liftIO $ forConcurrently sorted $ \mn -> + Rock.runTask rules $ Rock.fetch (CompileModule mn) result <- liftIO (try rockTask) :: Make (Either SomeException [ExternsFile]) -- Collect warnings accumulated during rock execution and emit them From 2bd8ea7aaca7cfdbf604a94301f4bcd591d86a78 Mon Sep 17 00:00:00 2001 From: Michal Kozakiewicz Date: Sat, 11 Apr 2026 08:54:54 +0000 Subject: [PATCH 05/11] Eliminate perf regression: shared cumulative Env and merged liftMake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profile-driven optimization addressing two hotspots: 1. Control.Monad.Logger bind overhead (21% of CPU): each liftMake call created a fresh runMake/Logger context. Fixed by merging sugar Env construction into the CompileModule liftMake call, halving the number of runMake invocations per module. 2. resolveImport/resolveExports redundancy (22% of CPU): each module rebuilt its sugar Env from scratch via foldM externsEnv. Fixed by using a shared IORef Env that accumulates incrementally — each module only processes deps not already in the env. Performance on pr-admin full rebuild: Baseline: 72s wall, 678s CPU Rock before fix: 115s wall, 1447s CPU Rock after fix: 76s wall, 688s CPU (matches baseline) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Language/PureScript/Make.hs | 4 +- src/Language/PureScript/Make/Rules.hs | 96 +++++++++++++++++---------- 2 files changed, 63 insertions(+), 37 deletions(-) diff --git a/src/Language/PureScript/Make.hs b/src/Language/PureScript/Make.hs index 37c254f778..1461ed86b6 100644 --- a/src/Language/PureScript/Make.hs +++ b/src/Language/PureScript/Make.hs @@ -202,6 +202,8 @@ makeIncremental ma@MakeActions{..} ms = do memoVar <- liftIO $ newIORef mempty -- IORef for tracking ExternsDiff of recompiled modules diffsRef <- liftIO $ newIORef M.empty + -- Shared cumulative sugar Env (like the old bpEnv MVar) + sharedEnvRef <- liftIO $ newIORef primEnv -- The per-module compilation function, partially applied with MakeActions let compileFn = rebuildModule' ma @@ -209,7 +211,7 @@ makeIncremental ma@MakeActions{..} ms = do -- Construct memoized rock rules let rules :: Rock.Rules Query rules = Rock.memoise memoVar - $ makeRules moduleMap opts ma warningsRef compileFn cacheStatus allCachedExterns diffsRef + $ makeRules moduleMap opts ma warningsRef compileFn cacheStatus allCachedExterns diffsRef sharedEnvRef -- Run the rock task: sort modules, then compile all in parallel. -- Rock's memoise handles synchronization: if module B depends on A, diff --git a/src/Language/PureScript/Make/Rules.hs b/src/Language/PureScript/Make/Rules.hs index 8bd5cf136e..75f7ac3f3e 100644 --- a/src/Language/PureScript/Make/Rules.hs +++ b/src/Language/PureScript/Make/Rules.hs @@ -36,7 +36,7 @@ import Language.PureScript.Make.Query (Query(..)) import Language.PureScript.ModuleDependencies (DependencyDepth(..), moduleSignature, sortModules) import Language.PureScript.Names (ModuleName, runModuleName) import Language.PureScript.Options (Options) -import Language.PureScript.Sugar (Env, externsEnv, primEnv) +import Language.PureScript.Sugar (Env, externsEnv) import Control.Monad.Writer.Strict (runWriterT) @@ -72,11 +72,13 @@ makeRules -> CompileFn -> CacheStatus -> M.Map ModuleName ExternsFile - -- ^ All previously cached externs (for ExternsDiff computation) -> IORef (M.Map ModuleName ExternsDiff) - -- ^ IORef for tracking externs diffs of recompiled modules + -> IORef Env + -- ^ Shared cumulative sugar Env (like the old bpEnv MVar). + -- Built up incrementally as modules compile, avoiding redundant + -- externsEnv calls. -> Rock.Rules Query -makeRules modules opts actions warningsRef compileFn cacheStatus allCachedExterns diffsRef = \case +makeRules modules opts actions warningsRef compileFn cacheStatus allCachedExterns diffsRef sharedEnvRef = \case InputModule mn -> case M.lookup mn modules of @@ -100,16 +102,10 @@ makeRules modules opts actions warningsRef compileFn cacheStatus allCachedExtern (_sorted, graph) <- sortModules Transitive (moduleSignature . CST.resPartial) prs pure $ M.fromList graph - ModuleSugarEnv mn -> do - graph <- Rock.fetch ModuleGraph - sorted <- Rock.fetch SortedModules - let deps = fromMaybe [] $ M.lookup mn graph - depsSet = S.fromList deps - sortedDeps = filter (`S.member` depsSet) sorted - depExterns <- traverse (\dep -> Rock.fetch (CompileModule dep)) sortedDeps - liftMake opts warningsRef $ - fmap fst . runWriterT $ foldM externsEnv primEnv depExterns - + -- ModuleSugarEnv and ModuleTypeEnv are no longer used directly; + -- their logic is inlined into CompileModule for performance. + -- Kept for API compatibility. + ModuleSugarEnv _mn -> liftIO $ readIORef sharedEnvRef ModuleTypeEnv mn -> do graph <- Rock.fetch ModuleGraph let deps = fromMaybe [] $ M.lookup mn graph @@ -118,7 +114,6 @@ makeRules modules opts actions warningsRef compileFn cacheStatus allCachedExtern CompileModule mn -> do _inputModule <- Rock.fetch (InputModule mn) - sugarEnv <- Rock.fetch (ModuleSugarEnv mn) graph <- Rock.fetch ModuleGraph sorted <- Rock.fetch SortedModules let deps = fromMaybe [] $ M.lookup mn graph @@ -132,25 +127,16 @@ makeRules modules opts actions warningsRef compileFn cacheStatus allCachedExtern case cachedInfo of Just (cached, myTimestamp) -> do - -- Source unchanged. Check if any dep's output is newer than ours - -- (indicates the dep was rebuilt separately, e.g. by IDE). let depTimestamps = map (\dep -> case M.lookup dep cacheStatus of Just (Just (_, ts)) -> Just ts _ -> Nothing) sortedDeps depsNewerThanMe = any (\mts -> maybe False (> myTimestamp) mts) depTimestamps if depsNewerThanMe then do - -- A dep was rebuilt after us → must recompile - exts <- doCompile mn sugarEnv depExterns - diffs <- liftIO $ readIORef diffsRef - let depDiffs = map (\dep -> fromMaybe (emptyDiff dep) (M.lookup dep diffs)) sortedDeps - diff = case M.lookup mn allCachedExterns of - Just old -> diffExterns exts old depDiffs - Nothing -> emptyDiff mn - liftIO $ atomicModifyIORef' diffsRef (\d -> (M.insert mn diff d, ())) + exts <- doCompile mn sortedDeps depExterns + recordDiff mn exts sortedDeps pure exts else do - -- Check if dep externs changes affect this module (ExternsDiff). diffs <- liftIO $ readIORef diffsRef let depDiffs = map (\dep -> fromMaybe (emptyDiff dep) (M.lookup dep diffs)) sortedDeps pr = fromMaybe (internalError "makeRules: missing module") @@ -161,35 +147,73 @@ makeRules modules opts actions warningsRef compileFn cacheStatus allCachedExtern needsRebuild = checkDiffs fullModule depDiffs if needsRebuild then do - exts <- doCompile mn sugarEnv depExterns + exts <- doCompile mn sortedDeps depExterns let diff = diffExterns exts cached depDiffs liftIO $ atomicModifyIORef' diffsRef (\d -> (M.insert mn diff d, ())) pure exts else do + -- Skip: update shared env with our deps and report + updateSharedEnv sortedDeps depExterns liftMake opts warningsRef $ progress actions $ SkippingModule mn Nothing liftIO $ atomicModifyIORef' diffsRef (\d -> (M.insert mn (emptyDiff mn) d, ())) pure cached Nothing -> do - exts <- doCompile mn sugarEnv depExterns - -- Record diff against old cached externs (from allCachedExterns) - diffs <- liftIO $ readIORef diffsRef - let depDiffs = map (\dep -> fromMaybe (emptyDiff dep) (M.lookup dep diffs)) sortedDeps - diff = case M.lookup mn allCachedExterns of - Just old -> diffExterns exts old depDiffs - Nothing -> emptyDiff mn - liftIO $ atomicModifyIORef' diffsRef (\d -> (M.insert mn diff d, ())) + exts <- doCompile mn sortedDeps depExterns + recordDiff mn exts sortedDeps pure exts where - doCompile mn sugarEnv depExterns = do + -- | Compile a module. Builds the sugar Env incrementally from the shared + -- cumulative env (only processing deps not yet in the env), then runs + -- the full compilation — all in a single liftMake call. + doCompile :: ModuleName -> [ModuleName] -> [ExternsFile] -> Rock.Task Query ExternsFile + doCompile mn sortedDeps depExterns = do + -- Read current shared env snapshot + currentEnv <- liftIO $ readIORef sharedEnvRef + let pr = fromMaybe (internalError $ "makeRules: CompileModule: module not found: " <> show (runModuleName mn)) (M.lookup mn modules) fp = spanName . getModuleSourceSpan . CST.resPartial $ pr (pwarnings, mres) = CST.resFull pr + -- Only process deps not already in the shared env + missingExterns = [ exts + | (dep, exts) <- zip sortedDeps depExterns + , not (M.member dep currentEnv) + ] + -- Single liftMake call: extend env + compile liftMake opts warningsRef $ do + -- Extend env with missing deps only + sugarEnv <- fmap fst . runWriterT $ foldM externsEnv currentEnv missingExterns + -- Update shared env for subsequent modules + liftIO $ atomicModifyIORef' sharedEnvRef (\_ -> (sugarEnv, ())) + -- Emit parser warnings and compile tell $ CST.toMultipleWarnings fp pwarnings m <- CST.unwrapParserError fp mres compileFn sugarEnv depExterns m + + -- | Update the shared env with deps (used when skipping compilation) + updateSharedEnv :: [ModuleName] -> [ExternsFile] -> Rock.Task Query () + updateSharedEnv sortedDeps depExterns = do + currentEnv <- liftIO $ readIORef sharedEnvRef + let missingExterns = [ exts + | (dep, exts) <- zip sortedDeps depExterns + , not (M.member dep currentEnv) + ] + if null missingExterns then pure () + else do + newEnv <- liftMake opts warningsRef $ + fmap fst . runWriterT $ foldM externsEnv currentEnv missingExterns + liftIO $ atomicModifyIORef' sharedEnvRef (\_ -> (newEnv, ())) + + -- | Record ExternsDiff for a freshly compiled module + recordDiff :: ModuleName -> ExternsFile -> [ModuleName] -> Rock.Task Query () + recordDiff mn exts sortedDeps = do + diffs <- liftIO $ readIORef diffsRef + let depDiffs = map (\dep -> fromMaybe (emptyDiff dep) (M.lookup dep diffs)) sortedDeps + diff = case M.lookup mn allCachedExterns of + Just old -> diffExterns exts old depDiffs + Nothing -> emptyDiff mn + liftIO $ atomicModifyIORef' diffsRef (\d -> (M.insert mn diff d, ())) From 00306ff459a653c18482995431650a725910bdc0 Mon Sep 17 00:00:00 2001 From: Michal Kozakiewicz Date: Sat, 11 Apr 2026 09:23:54 +0000 Subject: [PATCH 06/11] Optimize incremental rebuild: lazy externs loading + parallel cache check - Don't eagerly load externs for ALL modules in computeCacheStatus. Only load for modules that are up-to-date (need their cached externs) or changed (need old externs for ExternsDiff). - Skip cache checking entirely when CacheDb is empty (fresh build). - Parallelize cache status computation with forConcurrently. Performance on pr-admin (~1200 modules): Full rebuild: 69s (baseline: 72s) No changes: 3s (baseline: 0.5s) Touch one leaf: 3s (baseline: 0.6s) Incremental overhead is from hash-checking all 1200 modules + sortModules. Full rebuild is now slightly faster than baseline. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Language/PureScript/Make.hs | 54 ++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/src/Language/PureScript/Make.hs b/src/Language/PureScript/Make.hs index 1461ed86b6..38b5e41ee7 100644 --- a/src/Language/PureScript/Make.hs +++ b/src/Language/PureScript/Make.hs @@ -276,43 +276,61 @@ makeIncremental ma@MakeActions{..} ms = do -- | Compute cache status for each module: determine which modules have -- unchanged source files and valid cached externs on disk. -- Returns: --- 1. CacheStatus: map from module name to Maybe ExternsFile (Just = can reuse, Nothing = needs rebuild) --- 2. AllCachedExterns: map of ALL previously cached externs (for ExternsDiff computation) +-- 1. CacheStatus: map from module name to Maybe (ExternsFile, UTCTime) +-- (Just = source unchanged and cached externs available; Nothing = needs rebuild) +-- 2. AllCachedExterns: map of previously cached externs for modules that changed +-- (needed for ExternsDiff computation). Only loaded for modules that need rebuild. computeCacheStatus :: MakeActions Make -> Cache.CacheDb -> [ModuleName] -> Make (CacheStatus, M.Map ModuleName ExternsFile) computeCacheStatus MakeActions{..} cacheDb moduleNames = do - results <- traverse checkModule moduleNames - let cacheStatusMap = M.fromList [(mn, status) | (mn, status, _) <- results] - allCached = M.fromList [(mn, exts) | (mn, _, Just exts) <- results] - pure (cacheStatusMap, allCached) + -- If CacheDb is empty (fresh build), skip all checks — nothing is cached. + if M.null cacheDb then + pure (M.fromList [(mn, Nothing) | mn <- moduleNames], M.empty) + else do + opts <- ask + results <- liftIO $ forConcurrently moduleNames (\mn -> do + (r, _) <- runMake opts (checkModule mn) + case r of + Left _ -> pure (mn, Nothing) + Right v -> pure v) + let cacheStatusMap = M.fromList [(mn, status) | (mn, status) <- results] + -- Only load old cached externs for modules that CHANGED (for ExternsDiff). + let changedModules = [mn | (mn, Nothing) <- results] + changedExterns <- fmap (M.fromList . mapMaybe id) $ traverse loadExterns changedModules + let upToDateExterns = M.fromList [(mn, exts) | (mn, Just (exts, _)) <- results] + allCached = M.union upToDateExterns changedExterns + pure (cacheStatusMap, allCached) where checkModule mn = do - -- Always try to load cached externs (needed for diff computation) - (_, mbExterns) <- readExterns mn inputInfo <- getInputTimestampsAndHashes mn case inputInfo of - Left RebuildAlways -> pure (mn, Nothing, mbExterns) + Left RebuildAlways -> pure (mn, Nothing) Left RebuildNever -> do - -- RebuildNever modules are pinned — always use cached externs. - -- Use epoch as timestamp since these are never compared. + -- RebuildNever: load externs (these are pinned modules, few of them) + (_, mbExterns) <- readExterns mn let epoch = UTCTime (toEnum 0) 0 status = fmap (\exts -> (exts, epoch)) mbExterns - pure (mn, status, mbExterns) + pure (mn, status) Right timestamps -> do cwd <- liftIO getCurrentDirectory (_newCacheInfo, upToDate) <- Cache.checkChanged cacheDb mn cwd timestamps if upToDate then do outputTs <- getOutputTimestamp mn - let status = do - exts <- mbExterns - ts <- outputTs - pure (exts, ts) - pure (mn, status, mbExterns) + case outputTs of + Nothing -> pure (mn, Nothing) + Just ts -> do + -- Source unchanged and output exists: load externs + (_, mbExterns) <- readExterns mn + pure (mn, fmap (\exts -> (exts, ts)) mbExterns) else - pure (mn, Nothing, mbExterns) + pure (mn, Nothing) + + loadExterns mn = do + (_, mbExterns) <- readExterns mn + pure $ fmap (\exts -> (mn, exts)) mbExterns -- | Compute the updated CacheDb entries for all modules. computeNewCacheDb From 8d06fb6c2838f151417eb841a3d1fb4dd6090f61 Mon Sep 17 00:00:00 2001 From: Michal Kozakiewicz Date: Sat, 11 Apr 2026 10:01:23 +0000 Subject: [PATCH 07/11] Move cache checks into rock queries for demand-driven evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of eagerly checking all 1200 modules' cache status before rock starts, check each module lazily inside CompileModule when rock demands it. This is the key architectural win of demand-driven computation: only modules that are actually needed get their cache checked. - Delete computeCacheStatus and computeNewCacheDb from Make.hs - Add checkModuleCache inside CompileModule rule (runs in IO directly) - CacheDb and timestamps accumulated via IORefs during the build - Fix separately-rebuilt dep detection via stored output timestamps Performance on pr-admin (~1200 modules): Full rebuild: 73s (baseline: 72s) — matches No changes: 2.6s (baseline: 0.5s) — remaining cost is sortModules Touch 1 leaf: 2.7s (baseline: 0.6s) All 51 make tests + 829 compiler tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Language/PureScript/Make.hs | 108 +++--------------------- src/Language/PureScript/Make/Rules.hs | 117 ++++++++++++++++---------- 2 files changed, 86 insertions(+), 139 deletions(-) diff --git a/src/Language/PureScript/Make.hs b/src/Language/PureScript/Make.hs index 38b5e41ee7..c116b60a78 100644 --- a/src/Language/PureScript/Make.hs +++ b/src/Language/PureScript/Make.hs @@ -64,11 +64,10 @@ import Language.PureScript.Make.Monad as Monad getCurrentTime, copyFile ) import Language.PureScript.Make.Query (Query(..)) -import Language.PureScript.Make.Rules (makeRules, MakeError(..), CacheStatus) +import Language.PureScript.Make.Rules (makeRules, MakeError(..)) import Language.PureScript.CoreFn qualified as CF import Rock qualified -import Data.Time.Clock (UTCTime(..)) -import System.Directory (doesFileExist, getCurrentDirectory) +import System.Directory (doesFileExist) import System.FilePath (replaceExtension) import Language.PureScript.TypeChecker.Monad (liftTypeCheckM) @@ -189,29 +188,25 @@ makeIncremental ma@MakeActions{..} ms = do -- Read cache database for incremental build support cacheDb <- readCacheDb - -- Compute cache status for each module: check which modules have - -- unchanged source files and valid cached externs on disk. - -- Also load all previously cached externs (for ExternsDiff computation). - (cacheStatus, allCachedExterns) <- computeCacheStatus ma cacheDb (M.keys moduleMap) - -- Compute new CacheDb entries while checking - newCacheDb <- computeNewCacheDb ma cacheDb (M.keys moduleMap) - - -- IORef to accumulate warnings from rock Task executions + -- IORefs for state accumulated during the rock build warningsRef <- liftIO $ newIORef mempty - -- IORef for rock's within-build memoization cache memoVar <- liftIO $ newIORef mempty - -- IORef for tracking ExternsDiff of recompiled modules diffsRef <- liftIO $ newIORef M.empty - -- Shared cumulative sugar Env (like the old bpEnv MVar) sharedEnvRef <- liftIO $ newIORef primEnv + -- New CacheDb entries accumulated lazily as modules are checked + newCacheDbRef <- liftIO $ newIORef cacheDb + -- Output timestamps for dep freshness comparison + timestampsRef <- liftIO $ newIORef M.empty -- The per-module compilation function, partially applied with MakeActions let compileFn = rebuildModule' ma - -- Construct memoized rock rules + -- Construct memoized rock rules. + -- Cache checks happen lazily inside CompileModule — only when a module + -- is actually demanded by rock, not eagerly for all 1200 modules. let rules :: Rock.Rules Query rules = Rock.memoise memoVar - $ makeRules moduleMap opts ma warningsRef compileFn cacheStatus allCachedExterns diffsRef sharedEnvRef + $ makeRules moduleMap opts ma warningsRef compileFn cacheDb diffsRef sharedEnvRef newCacheDbRef timestampsRef -- Run the rock task: sort modules, then compile all in parallel. -- Rock's memoise handles synchronization: if module B depends on A, @@ -233,14 +228,14 @@ makeIncremental ma@MakeActions{..} ms = do Left exc | Just (MakeError errs) <- fromException exc -> do -- On failure, remove ONLY the failed modules from CacheDb. - -- This ensures the failed modules are rebuilt next time, while - -- preserving cache entries for modules that didn't fail. + newCacheDb <- liftIO $ readIORef newCacheDbRef let failedModules = S.fromList $ mapMaybe errorModule (runMultipleErrors errs) writeCacheDb $ Cache.removeModules failedModules newCacheDb throwError errs | otherwise -> liftIO $ throwIO exc Right externs -> do -- Write updated cache database + newCacheDb <- liftIO $ readIORef newCacheDbRef writeCacheDb newCacheDb writePackageJson outputPrimDocs @@ -273,83 +268,6 @@ makeIncremental ma@MakeActions{..} ms = do [] -> Nothing xss -> Just xss --- | Compute cache status for each module: determine which modules have --- unchanged source files and valid cached externs on disk. --- Returns: --- 1. CacheStatus: map from module name to Maybe (ExternsFile, UTCTime) --- (Just = source unchanged and cached externs available; Nothing = needs rebuild) --- 2. AllCachedExterns: map of previously cached externs for modules that changed --- (needed for ExternsDiff computation). Only loaded for modules that need rebuild. -computeCacheStatus - :: MakeActions Make - -> Cache.CacheDb - -> [ModuleName] - -> Make (CacheStatus, M.Map ModuleName ExternsFile) -computeCacheStatus MakeActions{..} cacheDb moduleNames = do - -- If CacheDb is empty (fresh build), skip all checks — nothing is cached. - if M.null cacheDb then - pure (M.fromList [(mn, Nothing) | mn <- moduleNames], M.empty) - else do - opts <- ask - results <- liftIO $ forConcurrently moduleNames (\mn -> do - (r, _) <- runMake opts (checkModule mn) - case r of - Left _ -> pure (mn, Nothing) - Right v -> pure v) - let cacheStatusMap = M.fromList [(mn, status) | (mn, status) <- results] - -- Only load old cached externs for modules that CHANGED (for ExternsDiff). - let changedModules = [mn | (mn, Nothing) <- results] - changedExterns <- fmap (M.fromList . mapMaybe id) $ traverse loadExterns changedModules - let upToDateExterns = M.fromList [(mn, exts) | (mn, Just (exts, _)) <- results] - allCached = M.union upToDateExterns changedExterns - pure (cacheStatusMap, allCached) - where - checkModule mn = do - inputInfo <- getInputTimestampsAndHashes mn - case inputInfo of - Left RebuildAlways -> pure (mn, Nothing) - Left RebuildNever -> do - -- RebuildNever: load externs (these are pinned modules, few of them) - (_, mbExterns) <- readExterns mn - let epoch = UTCTime (toEnum 0) 0 - status = fmap (\exts -> (exts, epoch)) mbExterns - pure (mn, status) - Right timestamps -> do - cwd <- liftIO getCurrentDirectory - (_newCacheInfo, upToDate) <- Cache.checkChanged cacheDb mn cwd timestamps - if upToDate then do - outputTs <- getOutputTimestamp mn - case outputTs of - Nothing -> pure (mn, Nothing) - Just ts -> do - -- Source unchanged and output exists: load externs - (_, mbExterns) <- readExterns mn - pure (mn, fmap (\exts -> (exts, ts)) mbExterns) - else - pure (mn, Nothing) - - loadExterns mn = do - (_, mbExterns) <- readExterns mn - pure $ fmap (\exts -> (mn, exts)) mbExterns - --- | Compute the updated CacheDb entries for all modules. -computeNewCacheDb - :: MakeActions Make - -> Cache.CacheDb - -> [ModuleName] - -> Make Cache.CacheDb -computeNewCacheDb MakeActions{..} cacheDb moduleNames = do - foldM updateModule cacheDb moduleNames - where - updateModule db mn = do - inputInfo <- getInputTimestampsAndHashes mn - case inputInfo of - Left _ -> pure db -- RebuildPolicy modules don't update cache - Right timestamps -> do - cwd <- liftIO getCurrentDirectory - (newCacheInfo, _) <- Cache.checkChanged db mn cwd timestamps - pure $ M.insert mn newCacheInfo db - -- | Infer the module name for a module by looking for the same filename with -- a .js extension. inferForeignModules diff --git a/src/Language/PureScript/Make/Rules.hs b/src/Language/PureScript/Make/Rules.hs index 75f7ac3f3e..06e2db9ab9 100644 --- a/src/Language/PureScript/Make/Rules.hs +++ b/src/Language/PureScript/Make/Rules.hs @@ -4,7 +4,6 @@ module Language.PureScript.Make.Rules ( makeRules , MakeError(..) , liftMake - , CacheStatus ) where import Prelude @@ -18,7 +17,6 @@ import Data.List (foldl') import Data.Map qualified as M import Data.Maybe (fromMaybe) import Data.Set qualified as S -import Data.Time.Clock (UTCTime) import Rock qualified @@ -29,7 +27,9 @@ import Language.PureScript.CST qualified as CST import Language.PureScript.Environment (initEnvironment) import Language.PureScript.Errors (MultipleErrors) import Language.PureScript.Externs (ExternsFile, applyExternsFileToEnvironment) -import Language.PureScript.Make.Actions (MakeActions(..), ProgressMessage(..)) +import Language.PureScript.Make.Actions (MakeActions(..), ProgressMessage(..), RebuildPolicy(..)) +import Language.PureScript.Make.Cache (CacheDb) +import Language.PureScript.Make.Cache qualified as Cache import Language.PureScript.Make.ExternsDiff (ExternsDiff, checkDiffs, diffExterns, emptyDiff) import Language.PureScript.Make.Monad (Make, runMake) import Language.PureScript.Make.Query (Query(..)) @@ -39,9 +39,10 @@ import Language.PureScript.Options (Options) import Language.PureScript.Sugar (Env, externsEnv) import Control.Monad.Writer.Strict (runWriterT) +import Data.Time.Clock (UTCTime(..)) +import System.Directory (getCurrentDirectory) --- | Exception wrapper for compilation errors, used to propagate errors from --- the 'Make' monad through rock's IO-based 'Task'. +-- | Exception wrapper for compilation errors. newtype MakeError = MakeError MultipleErrors deriving (Show) @@ -59,9 +60,14 @@ liftMake opts warningsRef action = liftIO $ do -- | The type of a single-module compilation function. type CompileFn = Env -> [ExternsFile] -> Module -> Make ExternsFile --- | Pre-computed cache status for a module. --- Nothing = needs rebuild, Just (externs, timestamp) = source unchanged, cached externs available. -type CacheStatus = M.Map ModuleName (Maybe (ExternsFile, UTCTime)) +-- | Per-module cache info, computed lazily on demand. +-- (Just (externs, outputTimestamp)) = source unchanged, cached externs available +-- Nothing = needs rebuild +data CacheInfo = CacheInfo + { ciCachedExterns :: !(Maybe (ExternsFile, UTCTime)) + , ciOldExterns :: !(Maybe ExternsFile) + -- ^ Old externs for ExternsDiff (loaded even for changed modules) + } -- | Define the rock rules for the incremental compilation pipeline. makeRules @@ -70,15 +76,14 @@ makeRules -> MakeActions Make -> IORef MultipleErrors -> CompileFn - -> CacheStatus - -> M.Map ModuleName ExternsFile + -> CacheDb -> IORef (M.Map ModuleName ExternsDiff) -> IORef Env - -- ^ Shared cumulative sugar Env (like the old bpEnv MVar). - -- Built up incrementally as modules compile, avoiding redundant - -- externsEnv calls. + -> IORef CacheDb + -> IORef (M.Map ModuleName UTCTime) + -- ^ Output timestamps for modules checked so far (for dep freshness) -> Rock.Rules Query -makeRules modules opts actions warningsRef compileFn cacheStatus allCachedExterns diffsRef sharedEnvRef = \case +makeRules modules opts actions warningsRef compileFn cacheDb diffsRef sharedEnvRef newCacheDbRef timestampsRef = \case InputModule mn -> case M.lookup mn modules of @@ -102,9 +107,6 @@ makeRules modules opts actions warningsRef compileFn cacheStatus allCachedExtern (_sorted, graph) <- sortModules Transitive (moduleSignature . CST.resPartial) prs pure $ M.fromList graph - -- ModuleSugarEnv and ModuleTypeEnv are no longer used directly; - -- their logic is inlined into CompileModule for performance. - -- Kept for API compatibility. ModuleSugarEnv _mn -> liftIO $ readIORef sharedEnvRef ModuleTypeEnv mn -> do graph <- Rock.fetch ModuleGraph @@ -121,22 +123,22 @@ makeRules modules opts actions warningsRef compileFn cacheStatus allCachedExtern sortedDeps = filter (`S.member` depsSet) sorted depExterns <- traverse (\dep -> Rock.fetch (CompileModule dep)) sortedDeps - let cachedInfo = case M.lookup mn cacheStatus of - Just (Just (exts, ts)) -> Just (exts, ts) - _ -> Nothing + -- Lazy cache check: only done when this module is actually demanded + cache <- checkModuleCache mn - case cachedInfo of + case ciCachedExterns cache of Just (cached, myTimestamp) -> do - let depTimestamps = map (\dep -> case M.lookup dep cacheStatus of - Just (Just (_, ts)) -> Just ts - _ -> Nothing) sortedDeps - depsNewerThanMe = any (\mts -> maybe False (> myTimestamp) mts) depTimestamps + -- Source unchanged. Check if any dep was rebuilt after us. + timestamps <- liftIO $ readIORef timestampsRef + let depsNewerThanMe = any (\dep -> + maybe False (> myTimestamp) (getDepTimestamp timestamps dep)) sortedDeps if depsNewerThanMe then do exts <- doCompile mn sortedDeps depExterns - recordDiff mn exts sortedDeps + recordDiff mn exts (ciOldExterns cache) sortedDeps pure exts else do + -- Check ExternsDiff diffs <- liftIO $ readIORef diffsRef let depDiffs = map (\dep -> fromMaybe (emptyDiff dep) (M.lookup dep diffs)) sortedDeps pr = fromMaybe (internalError "makeRules: missing module") @@ -152,7 +154,6 @@ makeRules modules opts actions warningsRef compileFn cacheStatus allCachedExtern liftIO $ atomicModifyIORef' diffsRef (\d -> (M.insert mn diff d, ())) pure exts else do - -- Skip: update shared env with our deps and report updateSharedEnv sortedDeps depExterns liftMake opts warningsRef $ progress actions $ SkippingModule mn Nothing @@ -161,40 +162,69 @@ makeRules modules opts actions warningsRef compileFn cacheStatus allCachedExtern Nothing -> do exts <- doCompile mn sortedDeps depExterns - recordDiff mn exts sortedDeps + recordDiff mn exts (ciOldExterns cache) sortedDeps pure exts where - -- | Compile a module. Builds the sugar Env incrementally from the shared - -- cumulative env (only processing deps not yet in the env), then runs - -- the full compilation — all in a single liftMake call. + -- | Lazily check a single module's cache status. + -- This is the key difference from the eager approach: only called + -- when rock actually demands this module. + checkModuleCache :: ModuleName -> Rock.Task Query CacheInfo + checkModuleCache mn = liftIO $ do + -- Run the cache check directly in IO via runMake, avoiding + -- the overhead of accumulating into warningsRef (cache checks + -- don't produce warnings). + (result, _warnings) <- runMake opts $ do + inputInfo <- getInputTimestampsAndHashes actions mn + case inputInfo of + Left RebuildAlways -> do + (_, mbOld) <- readExterns actions mn + pure $ CacheInfo Nothing mbOld + Left RebuildNever -> do + (_, mbExterns) <- readExterns actions mn + let epoch = UTCTime (toEnum 0) 0 + pure $ CacheInfo (fmap (\e -> (e, epoch)) mbExterns) mbExterns + Right timestamps -> do + cwd <- liftIO getCurrentDirectory + (newCacheInfo, upToDate) <- Cache.checkChanged cacheDb mn cwd timestamps + liftIO $ atomicModifyIORef' newCacheDbRef (\db -> (M.insert mn newCacheInfo db, ())) + if upToDate then do + outputTs <- getOutputTimestamp actions mn + case outputTs of + Nothing -> pure $ CacheInfo Nothing Nothing + Just ts -> do + liftIO $ atomicModifyIORef' timestampsRef (\m -> (M.insert mn ts m, ())) + (_, mbExterns) <- readExterns actions mn + pure $ CacheInfo (fmap (\e -> (e, ts)) mbExterns) mbExterns + else do + (_, mbOld) <- readExterns actions mn + pure $ CacheInfo Nothing mbOld + case result of + Left _errs -> pure $ CacheInfo Nothing Nothing + Right info -> pure info + + -- | Get a dep's output timestamp (recorded during cache check). + getDepTimestamp :: M.Map ModuleName UTCTime -> ModuleName -> Maybe UTCTime + getDepTimestamp timestamps dep = M.lookup dep timestamps + doCompile :: ModuleName -> [ModuleName] -> [ExternsFile] -> Rock.Task Query ExternsFile doCompile mn sortedDeps depExterns = do - -- Read current shared env snapshot currentEnv <- liftIO $ readIORef sharedEnvRef - let pr = fromMaybe (internalError $ "makeRules: CompileModule: module not found: " <> show (runModuleName mn)) (M.lookup mn modules) fp = spanName . getModuleSourceSpan . CST.resPartial $ pr (pwarnings, mres) = CST.resFull pr - -- Only process deps not already in the shared env missingExterns = [ exts | (dep, exts) <- zip sortedDeps depExterns , not (M.member dep currentEnv) ] - - -- Single liftMake call: extend env + compile liftMake opts warningsRef $ do - -- Extend env with missing deps only sugarEnv <- fmap fst . runWriterT $ foldM externsEnv currentEnv missingExterns - -- Update shared env for subsequent modules liftIO $ atomicModifyIORef' sharedEnvRef (\_ -> (sugarEnv, ())) - -- Emit parser warnings and compile tell $ CST.toMultipleWarnings fp pwarnings m <- CST.unwrapParserError fp mres compileFn sugarEnv depExterns m - -- | Update the shared env with deps (used when skipping compilation) updateSharedEnv :: [ModuleName] -> [ExternsFile] -> Rock.Task Query () updateSharedEnv sortedDeps depExterns = do currentEnv <- liftIO $ readIORef sharedEnvRef @@ -208,12 +238,11 @@ makeRules modules opts actions warningsRef compileFn cacheStatus allCachedExtern fmap fst . runWriterT $ foldM externsEnv currentEnv missingExterns liftIO $ atomicModifyIORef' sharedEnvRef (\_ -> (newEnv, ())) - -- | Record ExternsDiff for a freshly compiled module - recordDiff :: ModuleName -> ExternsFile -> [ModuleName] -> Rock.Task Query () - recordDiff mn exts sortedDeps = do + recordDiff :: ModuleName -> ExternsFile -> Maybe ExternsFile -> [ModuleName] -> Rock.Task Query () + recordDiff mn exts mbOldExterns sortedDeps = do diffs <- liftIO $ readIORef diffsRef let depDiffs = map (\dep -> fromMaybe (emptyDiff dep) (M.lookup dep diffs)) sortedDeps - diff = case M.lookup mn allCachedExterns of + diff = case mbOldExterns of Just old -> diffExterns exts old depDiffs Nothing -> emptyDiff mn liftIO $ atomicModifyIORef' diffsRef (\d -> (M.insert mn diff d, ())) From 5d9eb4292e863df75a8af4452c5bd35f85f6c927 Mon Sep 17 00:00:00 2001 From: Michal Kozakiewicz Date: Sat, 11 Apr 2026 12:37:30 +0000 Subject: [PATCH 08/11] Add graph caching infrastructure (disabled pending test fix) Add framework for persisting the module dependency graph to disk: - New Traces module with CachedGraph type and JSON serialization - getOutputDir field added to MakeActions for locating cache files - SortedModules/ModuleGraph rules can use cached graph when available - Graph is validated against module set and CacheDb content hashes Graph caching is currently disabled (always passes Nothing) because it causes 2 compiler test failures when the cached graph from a beforeAll hook interferes with individual test cases. The module set validation catches most cases but not all. When enabled, graph caching eliminates the ~2s sortModules overhead on no-changes builds. This is the remaining gap to baseline perf. Co-Authored-By: Claude Opus 4.6 (1M context) --- purescript.cabal | 1 + src/Language/PureScript/Make.hs | 27 +++++--- src/Language/PureScript/Make/Actions.hs | 3 + src/Language/PureScript/Make/Rules.hs | 50 ++++++++++----- src/Language/PureScript/Make/Traces.hs | 82 +++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 25 deletions(-) create mode 100644 src/Language/PureScript/Make/Traces.hs diff --git a/purescript.cabal b/purescript.cabal index d614e83d67..45a1fb546d 100644 --- a/purescript.cabal +++ b/purescript.cabal @@ -345,6 +345,7 @@ library Language.PureScript.Make.Monad Language.PureScript.Make.Query Language.PureScript.Make.Rules + Language.PureScript.Make.Traces Language.PureScript.ModuleDependencies Language.PureScript.Names Language.PureScript.Options diff --git a/src/Language/PureScript/Make.hs b/src/Language/PureScript/Make.hs index c116b60a78..1efe7bd7b7 100644 --- a/src/Language/PureScript/Make.hs +++ b/src/Language/PureScript/Make.hs @@ -65,10 +65,11 @@ import Language.PureScript.Make.Monad as Monad copyFile ) import Language.PureScript.Make.Query (Query(..)) import Language.PureScript.Make.Rules (makeRules, MakeError(..)) +import Language.PureScript.Make.Traces qualified as Traces import Language.PureScript.CoreFn qualified as CF import Rock qualified import System.Directory (doesFileExist) -import System.FilePath (replaceExtension) +import System.FilePath (replaceExtension, ()) import Language.PureScript.TypeChecker.Monad (liftTypeCheckM) -- | Rebuild a single module. @@ -188,25 +189,29 @@ makeIncremental ma@MakeActions{..} ms = do -- Read cache database for incremental build support cacheDb <- readCacheDb + -- Try to load cached module graph from previous build. + -- If valid (all input hashes match), we skip the expensive sortModules call. + let graphFile = getOutputDir "module-graph.json" + -- Load cached module graph if available and valid. + -- Disabled for now: needs further debugging for test compatibility. + -- cachedGraph <- liftIO $ Traces.readCachedGraph graphFile cacheDb (S.fromList $ M.keys moduleMap) + let cachedGraph = (Nothing :: Maybe Traces.CachedGraph) + -- IORefs for state accumulated during the rock build warningsRef <- liftIO $ newIORef mempty memoVar <- liftIO $ newIORef mempty diffsRef <- liftIO $ newIORef M.empty sharedEnvRef <- liftIO $ newIORef primEnv - -- New CacheDb entries accumulated lazily as modules are checked newCacheDbRef <- liftIO $ newIORef cacheDb - -- Output timestamps for dep freshness comparison timestampsRef <- liftIO $ newIORef M.empty + -- Captured graph data for persistence + graphRef <- liftIO $ newIORef (Nothing :: Maybe ([ModuleName], [(ModuleName, [ModuleName])])) - -- The per-module compilation function, partially applied with MakeActions let compileFn = rebuildModule' ma - -- Construct memoized rock rules. - -- Cache checks happen lazily inside CompileModule — only when a module - -- is actually demanded by rock, not eagerly for all 1200 modules. let rules :: Rock.Rules Query rules = Rock.memoise memoVar - $ makeRules moduleMap opts ma warningsRef compileFn cacheDb diffsRef sharedEnvRef newCacheDbRef timestampsRef + $ makeRules moduleMap opts ma warningsRef compileFn cacheDb diffsRef sharedEnvRef newCacheDbRef timestampsRef cachedGraph graphRef -- Run the rock task: sort modules, then compile all in parallel. -- Rock's memoise handles synchronization: if module B depends on A, @@ -237,6 +242,12 @@ makeIncremental ma@MakeActions{..} ms = do -- Write updated cache database newCacheDb <- liftIO $ readIORef newCacheDbRef writeCacheDb newCacheDb + -- Save module graph for next build + mbGraph <- liftIO $ readIORef graphRef + case mbGraph of + Just (sorted, graph) -> + liftIO $ Traces.writeCachedGraph graphFile sorted graph newCacheDb + Nothing -> pure () writePackageJson outputPrimDocs pure externs diff --git a/src/Language/PureScript/Make/Actions.hs b/src/Language/PureScript/Make/Actions.hs index 26e5fcccce..063930dacc 100644 --- a/src/Language/PureScript/Make/Actions.hs +++ b/src/Language/PureScript/Make/Actions.hs @@ -142,6 +142,8 @@ data MakeActions m = MakeActions -- load .js files as ES modules. , outputPrimDocs :: m () -- ^ If generating docs, output the documentation for the Prim modules + , getOutputDir :: FilePath + -- ^ The output directory path (for auxiliary cache files) } -- | Given the output directory, determines the location for the @@ -203,6 +205,7 @@ buildMakeActions outputDir filePathMap foreigns usePrefix = writeCacheDb writePackageJson outputPrimDocs + outputDir where getInputTimestampsAndHashes diff --git a/src/Language/PureScript/Make/Rules.hs b/src/Language/PureScript/Make/Rules.hs index 06e2db9ab9..57ce0abc3d 100644 --- a/src/Language/PureScript/Make/Rules.hs +++ b/src/Language/PureScript/Make/Rules.hs @@ -36,6 +36,7 @@ import Language.PureScript.Make.Query (Query(..)) import Language.PureScript.ModuleDependencies (DependencyDepth(..), moduleSignature, sortModules) import Language.PureScript.Names (ModuleName, runModuleName) import Language.PureScript.Options (Options) +import Language.PureScript.Make.Traces qualified as Traces import Language.PureScript.Sugar (Env, externsEnv) import Control.Monad.Writer.Strict (runWriterT) @@ -81,9 +82,12 @@ makeRules -> IORef Env -> IORef CacheDb -> IORef (M.Map ModuleName UTCTime) - -- ^ Output timestamps for modules checked so far (for dep freshness) + -> Maybe Traces.CachedGraph + -- ^ Cached module graph from previous build (if valid) + -> IORef (Maybe ([ModuleName], [(ModuleName, [ModuleName])])) + -- ^ Captures computed graph for persistence -> Rock.Rules Query -makeRules modules opts actions warningsRef compileFn cacheDb diffsRef sharedEnvRef newCacheDbRef timestampsRef = \case +makeRules modules opts actions warningsRef compileFn cacheDb diffsRef sharedEnvRef newCacheDbRef timestampsRef cachedGraph graphRef = \case InputModule mn -> case M.lookup mn modules of @@ -91,21 +95,33 @@ makeRules modules opts actions warningsRef compileFn cacheDb diffsRef sharedEnvR Nothing -> liftIO . throwIO . MakeError $ internalError ("makeRules: InputModule: module not found: " <> show (runModuleName mn)) - SortedModules -> do - let allNames = M.keys modules - _ <- traverse (\mn -> Rock.fetch (InputModule mn)) allNames - liftMake opts warningsRef $ do - let prs = M.elems modules - (sorted, _graph) <- sortModules Transitive (moduleSignature . CST.resPartial) prs - pure $ map (getModuleName . CST.resPartial) sorted - - ModuleGraph -> do - let allNames = M.keys modules - _ <- traverse (\mn -> Rock.fetch (InputModule mn)) allNames - liftMake opts warningsRef $ do - let prs = M.elems modules - (_sorted, graph) <- sortModules Transitive (moduleSignature . CST.resPartial) prs - pure $ M.fromList graph + SortedModules -> case cachedGraph of + Just cg | M.keysSet modules == S.fromList (Traces.cgSorted cg) -> pure (Traces.cgSorted cg) + _ -> do + let allNames = M.keys modules + _ <- traverse (\mn -> Rock.fetch (InputModule mn)) allNames + liftMake opts warningsRef $ do + let prs = M.elems modules + (sorted, graph) <- sortModules Transitive (moduleSignature . CST.resPartial) prs + let result = map (getModuleName . CST.resPartial) sorted + -- Capture for persistence + liftIO $ atomicModifyIORef' graphRef (\_ -> (Just (result, graph), ())) + pure result + + ModuleGraph -> case cachedGraph of + Just cg | M.keysSet modules == S.fromList (Traces.cgSorted cg) -> pure $ M.fromList (Traces.cgGraph cg) + _ -> do + let allNames = M.keys modules + _ <- traverse (\mn -> Rock.fetch (InputModule mn)) allNames + liftMake opts warningsRef $ do + let prs = M.elems modules + (sorted, graph) <- sortModules Transitive (moduleSignature . CST.resPartial) prs + let result = map (getModuleName . CST.resPartial) sorted + -- Capture for persistence (if not already done by SortedModules) + liftIO $ atomicModifyIORef' graphRef (\prev -> case prev of + Nothing -> (Just (result, graph), ()) + just -> (just, ())) + pure $ M.fromList graph ModuleSugarEnv _mn -> liftIO $ readIORef sharedEnvRef ModuleTypeEnv mn -> do diff --git a/src/Language/PureScript/Make/Traces.hs b/src/Language/PureScript/Make/Traces.hs new file mode 100644 index 0000000000..576754bd52 --- /dev/null +++ b/src/Language/PureScript/Make/Traces.hs @@ -0,0 +1,82 @@ +-- | Cached module graph for cross-build incrementality. +-- Persisted to disk alongside cache-db.json. Invalidated when +-- any input module's content hash changes. +module Language.PureScript.Make.Traces + ( CachedGraph(..) + , readCachedGraph + , writeCachedGraph + ) where + +import Prelude + +import Data.Aeson qualified as Aeson +import Data.Aeson ((.=), (.:)) +import Data.ByteString.Lazy qualified as LBS +import Data.Set qualified as S +import Data.Version (showVersion) +import Language.PureScript.Make.Cache (CacheDb) +import Language.PureScript.Names (ModuleName) +import Paths_purescript qualified as Paths +import System.Directory (doesFileExist) +import System.IO.Error (tryIOError) + +-- | Cached module graph: sorted module list and dependency graph. +-- Only valid when all input module hashes match the CacheDb. +data CachedGraph = CachedGraph + { cgVersion :: String + , cgSorted :: [ModuleName] + , cgGraph :: [(ModuleName, [ModuleName])] + , cgInputHashes :: CacheDb + -- ^ Snapshot of input hashes when graph was computed. + -- If current CacheDb matches, graph is still valid. + } deriving (Show) + +instance Aeson.ToJSON CachedGraph where + toJSON CachedGraph{..} = Aeson.object + [ "version" .= cgVersion + , "sorted" .= cgSorted + , "graph" .= cgGraph + , "hashes" .= cgInputHashes + ] + +instance Aeson.FromJSON CachedGraph where + parseJSON = Aeson.withObject "CachedGraph" $ \v -> + CachedGraph + <$> v .: "version" + <*> v .: "sorted" + <*> v .: "graph" + <*> v .: "hashes" + +-- | Try to read a cached graph. Returns Nothing if: +-- - File doesn't exist +-- - File can't be parsed +-- - Compiler version differs +-- - The set of module names differs from the current compilation +-- - Any input hash differs from the current CacheDb +readCachedGraph :: FilePath -> CacheDb -> S.Set ModuleName -> IO (Maybe CachedGraph) +readCachedGraph path currentCacheDb currentModules = do + exists <- doesFileExist path + if not exists then pure Nothing + else do + result <- tryIOError $ LBS.readFile path + case result of + Left _ -> pure Nothing + Right bs -> case Aeson.decode bs of + Nothing -> pure Nothing + Just cg + | cgVersion cg /= showVersion Paths.version -> pure Nothing + | S.fromList (cgSorted cg) /= currentModules -> pure Nothing + | cgInputHashes cg /= currentCacheDb -> pure Nothing + | otherwise -> pure (Just cg) + +-- | Write cached graph to disk. +writeCachedGraph :: FilePath -> [ModuleName] -> [(ModuleName, [ModuleName])] -> CacheDb -> IO () +writeCachedGraph path sorted graph cacheDb = do + let cg = CachedGraph + { cgVersion = showVersion Paths.version + , cgSorted = sorted + , cgGraph = graph + , cgInputHashes = cacheDb + } + _ <- tryIOError $ LBS.writeFile path (Aeson.encode cg) + pure () From 7a8b291db3a0db0a84bb4f63dc4ee1baf3950f25 Mon Sep 17 00:00:00 2001 From: Michal Kozakiewicz Date: Sat, 11 Apr 2026 14:19:29 +0000 Subject: [PATCH 09/11] Enable graph caching with fast-path no-change detection Fix graph caching test failures caused by empty CacheDb for RebuildAlways/RebuildNever modules allowing false cache hits. Validation now requires CacheDb entries for all current modules. Store direct (not transitive) deps in cached graph, reducing file size from 12MB to 689KB. Compute transitive closure on load. Store a hash of the CacheDb instead of the full CacheDb in the graph file for compact validation. Add fast path in make_: when cached graph is valid and all module timestamps match, skip the rock pipeline entirely. This brings no-change rebuild from ~3.5s to ~1.2s (1758 modules). Defer externs loading: checkModuleCache no longer reads .cbor files for cached modules. Externs are loaded only when actually needed for compilation or to return results. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Language/PureScript/Make.hs | 65 ++++++++++++--- src/Language/PureScript/Make/Rules.hs | 105 +++++++++++++++++-------- src/Language/PureScript/Make/Traces.hs | 29 ++++--- 3 files changed, 149 insertions(+), 50 deletions(-) diff --git a/src/Language/PureScript/Make.hs b/src/Language/PureScript/Make.hs index 1efe7bd7b7..bdedd27c5c 100644 --- a/src/Language/PureScript/Make.hs +++ b/src/Language/PureScript/Make.hs @@ -24,7 +24,7 @@ import Data.Function (on) import Data.Foldable (fold, for_) import Data.IORef (newIORef, readIORef) import Data.List (foldl', sortOn) -import Data.Maybe (mapMaybe) +import Data.Maybe (isJust, mapMaybe) import Data.List.NonEmpty qualified as NEL import Data.Map qualified as M import Data.Set qualified as S @@ -63,12 +63,13 @@ import Language.PureScript.Make.Monad as Monad getTimestamp, getCurrentTime, copyFile ) +import Language.PureScript.Options (Options) import Language.PureScript.Make.Query (Query(..)) import Language.PureScript.Make.Rules (makeRules, MakeError(..)) import Language.PureScript.Make.Traces qualified as Traces import Language.PureScript.CoreFn qualified as CF import Rock qualified -import System.Directory (doesFileExist) +import System.Directory (doesFileExist, getCurrentDirectory) import System.FilePath (replaceExtension, ()) import Language.PureScript.TypeChecker.Monad (liftTypeCheckM) @@ -163,10 +164,29 @@ make :: MakeActions Make make ma ms = makeIncremental ma ms -- | Like 'make' but discards the result. +-- Uses a fast path to skip the rock pipeline when all modules are cached. make_ :: MakeActions Make -> [CST.PartialResult Module] -> Make () -make_ ma ms = void $ makeIncremental ma ms +make_ ma@MakeActions{..} ms = do + -- Fast path: if all modules are cached, skip rock entirely. + -- This avoids the overhead of dependency resolution, memoization, + -- and reading externs from disk when nothing needs to compile. + opts <- ask + cacheDb <- readCacheDb + let moduleNames = map (getModuleName . CST.resPartial) ms + let currentModules = S.fromList moduleNames + let graphFile = getOutputDir "module-graph.json" + cachedGraph <- liftIO $ Traces.readCachedGraph graphFile cacheDb currentModules + case cachedGraph of + Just _ -> do + allCached <- liftIO $ allModulesCached opts ma cacheDb moduleNames + if allCached then do + writeCacheDb cacheDb + writePackageJson + outputPrimDocs + else void $ makeIncremental ma ms + Nothing -> void $ makeIncremental ma ms -- | Rock-based incremental compilation. -- Defines queries for each compilation phase and lets rock handle @@ -192,10 +212,8 @@ makeIncremental ma@MakeActions{..} ms = do -- Try to load cached module graph from previous build. -- If valid (all input hashes match), we skip the expensive sortModules call. let graphFile = getOutputDir "module-graph.json" - -- Load cached module graph if available and valid. - -- Disabled for now: needs further debugging for test compatibility. - -- cachedGraph <- liftIO $ Traces.readCachedGraph graphFile cacheDb (S.fromList $ M.keys moduleMap) - let cachedGraph = (Nothing :: Maybe Traces.CachedGraph) + let currentModules = S.fromList $ M.keys moduleMap + cachedGraph <- liftIO $ Traces.readCachedGraph graphFile cacheDb currentModules -- IORefs for state accumulated during the rock build warningsRef <- liftIO $ newIORef mempty @@ -219,8 +237,6 @@ makeIncremental ma@MakeActions{..} ms = do -- natural parallelism bounded by the dependency graph. let rockTask = Rock.runTask rules $ do sorted <- Rock.fetch SortedModules - -- Fork all module compilations concurrently within the same Task. - -- Each fork shares the same Fetch function (and thus memoization). liftIO $ forConcurrently sorted $ \mn -> Rock.runTask rules $ Rock.fetch (CompileModule mn) result <- liftIO (try rockTask) :: Make (Either SomeException [ExternsFile]) @@ -279,6 +295,37 @@ makeIncremental ma@MakeActions{..} ms = do [] -> Nothing xss -> Just xss +-- | Quick check: are all modules cached? Checks timestamps + hashes against +-- CacheDb and verifies output exists. No externs are read. +-- Runs all checks concurrently for speed. +allModulesCached + :: Options + -> MakeActions Make + -> Cache.CacheDb + -> [ModuleName] + -> IO Bool +allModulesCached opts MakeActions{..} cacheDb moduleNames = do + cwd <- getCurrentDirectory + results <- forConcurrently moduleNames $ \mn -> do + (result, _) <- runMake opts $ do + inputInfo <- getInputTimestampsAndHashes mn + case inputInfo of + Left RebuildAlways -> pure False + Left RebuildNever -> do + -- Assume RebuildNever modules are always up to date + outputTs <- getOutputTimestamp mn + pure (isJust outputTs) + Right timestamps -> do + (_, upToDate) <- Cache.checkChanged cacheDb mn cwd timestamps + if upToDate then do + outputTs <- getOutputTimestamp mn + pure (isJust outputTs) + else pure False + pure $ case result of + Right True -> True + _ -> False + pure (and results) + -- | Infer the module name for a module by looking for the same filename with -- a .js extension. inferForeignModules diff --git a/src/Language/PureScript/Make/Rules.hs b/src/Language/PureScript/Make/Rules.hs index 57ce0abc3d..a788c2554b 100644 --- a/src/Language/PureScript/Make/Rules.hs +++ b/src/Language/PureScript/Make/Rules.hs @@ -62,14 +62,19 @@ liftMake opts warningsRef action = liftIO $ do type CompileFn = Env -> [ExternsFile] -> Module -> Make ExternsFile -- | Per-module cache info, computed lazily on demand. --- (Just (externs, outputTimestamp)) = source unchanged, cached externs available --- Nothing = needs rebuild data CacheInfo = CacheInfo - { ciCachedExterns :: !(Maybe (ExternsFile, UTCTime)) - , ciOldExterns :: !(Maybe ExternsFile) + { ciCacheStatus :: !CacheStatus + , ciOldExterns :: !(Maybe ExternsFile) -- ^ Old externs for ExternsDiff (loaded even for changed modules) } +-- | Whether a module's build artifacts are up to date. +data CacheStatus + = CacheHit !UTCTime + -- ^ Source unchanged, output exists at this timestamp + | CacheMiss + -- ^ Needs rebuild (source changed or output missing) + -- | Define the rock rules for the incremental compilation pipeline. makeRules :: M.Map ModuleName (CST.PartialResult Module) @@ -102,26 +107,25 @@ makeRules modules opts actions warningsRef compileFn cacheDb diffsRef sharedEnvR _ <- traverse (\mn -> Rock.fetch (InputModule mn)) allNames liftMake opts warningsRef $ do let prs = M.elems modules - (sorted, graph) <- sortModules Transitive (moduleSignature . CST.resPartial) prs + -- Use Direct deps for sorting (cheaper than Transitive). + -- Transitive closure is computed on demand in ModuleGraph. + (sorted, directGraph) <- sortModules Direct (moduleSignature . CST.resPartial) prs let result = map (getModuleName . CST.resPartial) sorted - -- Capture for persistence - liftIO $ atomicModifyIORef' graphRef (\_ -> (Just (result, graph), ())) + -- Capture direct graph for persistence (compact on disk) + liftIO $ atomicModifyIORef' graphRef (\_ -> (Just (result, directGraph), ())) pure result ModuleGraph -> case cachedGraph of - Just cg | M.keysSet modules == S.fromList (Traces.cgSorted cg) -> pure $ M.fromList (Traces.cgGraph cg) + Just cg | M.keysSet modules == S.fromList (Traces.cgSorted cg) -> + pure $ transitiveClosure (M.fromList (Traces.cgGraph cg)) _ -> do - let allNames = M.keys modules - _ <- traverse (\mn -> Rock.fetch (InputModule mn)) allNames - liftMake opts warningsRef $ do - let prs = M.elems modules - (sorted, graph) <- sortModules Transitive (moduleSignature . CST.resPartial) prs - let result = map (getModuleName . CST.resPartial) sorted - -- Capture for persistence (if not already done by SortedModules) - liftIO $ atomicModifyIORef' graphRef (\prev -> case prev of - Nothing -> (Just (result, graph), ()) - just -> (just, ())) - pure $ M.fromList graph + -- Ensure SortedModules has run (which populates graphRef) + _ <- Rock.fetch SortedModules + directGraph <- liftIO $ readIORef graphRef + case directGraph of + Just (_sorted, graph) -> pure $ transitiveClosure (M.fromList graph) + Nothing -> liftIO . throwIO . MakeError $ internalError + "makeRules: ModuleGraph: graphRef not populated" ModuleSugarEnv _mn -> liftIO $ readIORef sharedEnvRef ModuleTypeEnv mn -> do @@ -142,8 +146,8 @@ makeRules modules opts actions warningsRef compileFn cacheDb diffsRef sharedEnvR -- Lazy cache check: only done when this module is actually demanded cache <- checkModuleCache mn - case ciCachedExterns cache of - Just (cached, myTimestamp) -> do + case ciCacheStatus cache of + CacheHit myTimestamp -> do -- Source unchanged. Check if any dep was rebuilt after us. timestamps <- liftIO $ readIORef timestampsRef let depsNewerThanMe = any (\dep -> @@ -165,8 +169,12 @@ makeRules modules opts actions warningsRef compileFn cacheDb diffsRef sharedEnvR needsRebuild = checkDiffs fullModule depDiffs if needsRebuild then do + -- Load cached externs for diff computation + mbCached <- loadExterns mn exts <- doCompile mn sortedDeps depExterns - let diff = diffExterns exts cached depDiffs + let diff = case mbCached of + Just old -> diffExterns exts old depDiffs + Nothing -> emptyDiff mn liftIO $ atomicModifyIORef' diffsRef (\d -> (M.insert mn diff d, ())) pure exts else do @@ -174,9 +182,18 @@ makeRules modules opts actions warningsRef compileFn cacheDb diffsRef sharedEnvR liftMake opts warningsRef $ progress actions $ SkippingModule mn Nothing liftIO $ atomicModifyIORef' diffsRef (\d -> (M.insert mn (emptyDiff mn) d, ())) - pure cached + -- Load externs only now (deferred from cache check) + mbCached <- loadExterns mn + case mbCached of + Just cached -> pure cached + Nothing -> do + -- Externs missing on disk even though cache says up to date. + -- Fall back to recompilation. + exts <- doCompile mn sortedDeps depExterns + recordDiff mn exts Nothing sortedDeps + pure exts - Nothing -> do + CacheMiss -> do exts <- doCompile mn sortedDeps depExterns recordDiff mn exts (ciOldExterns cache) sortedDeps pure exts @@ -190,16 +207,17 @@ makeRules modules opts actions warningsRef compileFn cacheDb diffsRef sharedEnvR -- Run the cache check directly in IO via runMake, avoiding -- the overhead of accumulating into warningsRef (cache checks -- don't produce warnings). + -- Note: does NOT read externs here — deferred to loadExterns + -- to avoid reading .cbor files for modules that don't need them. (result, _warnings) <- runMake opts $ do inputInfo <- getInputTimestampsAndHashes actions mn case inputInfo of Left RebuildAlways -> do (_, mbOld) <- readExterns actions mn - pure $ CacheInfo Nothing mbOld + pure $ CacheInfo CacheMiss mbOld Left RebuildNever -> do - (_, mbExterns) <- readExterns actions mn let epoch = UTCTime (toEnum 0) 0 - pure $ CacheInfo (fmap (\e -> (e, epoch)) mbExterns) mbExterns + pure $ CacheInfo (CacheHit epoch) Nothing Right timestamps -> do cwd <- liftIO getCurrentDirectory (newCacheInfo, upToDate) <- Cache.checkChanged cacheDb mn cwd timestamps @@ -207,18 +225,28 @@ makeRules modules opts actions warningsRef compileFn cacheDb diffsRef sharedEnvR if upToDate then do outputTs <- getOutputTimestamp actions mn case outputTs of - Nothing -> pure $ CacheInfo Nothing Nothing + Nothing -> pure $ CacheInfo CacheMiss Nothing Just ts -> do liftIO $ atomicModifyIORef' timestampsRef (\m -> (M.insert mn ts m, ())) - (_, mbExterns) <- readExterns actions mn - pure $ CacheInfo (fmap (\e -> (e, ts)) mbExterns) mbExterns + pure $ CacheInfo (CacheHit ts) Nothing else do (_, mbOld) <- readExterns actions mn - pure $ CacheInfo Nothing mbOld + pure $ CacheInfo CacheMiss mbOld case result of - Left _errs -> pure $ CacheInfo Nothing Nothing + Left _errs -> pure $ CacheInfo CacheMiss Nothing Right info -> pure info + -- | Load cached externs from disk. Only called when externs are + -- actually needed (for compilation or to return as a result). + loadExterns :: ModuleName -> Rock.Task Query (Maybe ExternsFile) + loadExterns mn = liftIO $ do + (result, _) <- runMake opts $ do + (_, mbExterns) <- readExterns actions mn + pure mbExterns + case result of + Right ext -> pure ext + Left _ -> pure Nothing + -- | Get a dep's output timestamp (recorded during cache check). getDepTimestamp :: M.Map ModuleName UTCTime -> ModuleName -> Maybe UTCTime getDepTimestamp timestamps dep = M.lookup dep timestamps @@ -262,3 +290,16 @@ makeRules modules opts actions warningsRef compileFn cacheDb diffsRef sharedEnvR Just old -> diffExterns exts old depDiffs Nothing -> emptyDiff mn liftIO $ atomicModifyIORef' diffsRef (\d -> (M.insert mn diff d, ())) + +-- | Compute transitive closure of a direct dependency graph. +-- For each module, find all modules reachable via dependencies. +transitiveClosure :: M.Map ModuleName [ModuleName] -> M.Map ModuleName [ModuleName] +transitiveClosure directGraph = M.mapWithKey (\mn _ -> S.toList (go S.empty (directDeps mn))) directGraph + where + directDeps :: ModuleName -> [ModuleName] + directDeps mn = fromMaybe [] (M.lookup mn directGraph) + go :: S.Set ModuleName -> [ModuleName] -> S.Set ModuleName + go visited [] = visited + go visited (dep:deps) + | S.member dep visited = go visited deps + | otherwise = go (S.insert dep visited) (directDeps dep ++ deps) diff --git a/src/Language/PureScript/Make/Traces.hs b/src/Language/PureScript/Make/Traces.hs index 576754bd52..02f242ed3a 100644 --- a/src/Language/PureScript/Make/Traces.hs +++ b/src/Language/PureScript/Make/Traces.hs @@ -12,9 +12,10 @@ import Prelude import Data.Aeson qualified as Aeson import Data.Aeson ((.=), (.:)) import Data.ByteString.Lazy qualified as LBS +import Data.Map qualified as M import Data.Set qualified as S import Data.Version (showVersion) -import Language.PureScript.Make.Cache (CacheDb) +import Language.PureScript.Make.Cache (CacheDb, ContentHash, hash) import Language.PureScript.Names (ModuleName) import Paths_purescript qualified as Paths import System.Directory (doesFileExist) @@ -26,9 +27,9 @@ data CachedGraph = CachedGraph { cgVersion :: String , cgSorted :: [ModuleName] , cgGraph :: [(ModuleName, [ModuleName])] - , cgInputHashes :: CacheDb - -- ^ Snapshot of input hashes when graph was computed. - -- If current CacheDb matches, graph is still valid. + , cgCacheDbHash :: ContentHash + -- ^ Hash of the serialized CacheDb when graph was computed. + -- If current CacheDb hashes the same, graph is still valid. } deriving (Show) instance Aeson.ToJSON CachedGraph where @@ -36,7 +37,7 @@ instance Aeson.ToJSON CachedGraph where [ "version" .= cgVersion , "sorted" .= cgSorted , "graph" .= cgGraph - , "hashes" .= cgInputHashes + , "cacheDbHash" .= cgCacheDbHash ] instance Aeson.FromJSON CachedGraph where @@ -45,14 +46,19 @@ instance Aeson.FromJSON CachedGraph where <$> v .: "version" <*> v .: "sorted" <*> v .: "graph" - <*> v .: "hashes" + <*> v .: "cacheDbHash" + +-- | Compute a hash of the CacheDb for comparison purposes. +hashCacheDb :: CacheDb -> ContentHash +hashCacheDb = hash . LBS.toStrict . Aeson.encode -- | Try to read a cached graph. Returns Nothing if: -- - File doesn't exist -- - File can't be parsed -- - Compiler version differs -- - The set of module names differs from the current compilation --- - Any input hash differs from the current CacheDb +-- - The CacheDb hash differs (some input changed) +-- - The CacheDb lacks entries for some current modules (can't verify content) readCachedGraph :: FilePath -> CacheDb -> S.Set ModuleName -> IO (Maybe CachedGraph) readCachedGraph path currentCacheDb currentModules = do exists <- doesFileExist path @@ -66,7 +72,12 @@ readCachedGraph path currentCacheDb currentModules = do Just cg | cgVersion cg /= showVersion Paths.version -> pure Nothing | S.fromList (cgSorted cg) /= currentModules -> pure Nothing - | cgInputHashes cg /= currentCacheDb -> pure Nothing + -- Require that the CacheDb has entries for all current modules. + -- Without content hashes for every module, we can't verify the + -- dependency graph is still valid (e.g. when modules use + -- RebuildAlways/RebuildNever, their hashes are not tracked). + | not (currentModules `S.isSubsetOf` M.keysSet currentCacheDb) -> pure Nothing + | cgCacheDbHash cg /= hashCacheDb currentCacheDb -> pure Nothing | otherwise -> pure (Just cg) -- | Write cached graph to disk. @@ -76,7 +87,7 @@ writeCachedGraph path sorted graph cacheDb = do { cgVersion = showVersion Paths.version , cgSorted = sorted , cgGraph = graph - , cgInputHashes = cacheDb + , cgCacheDbHash = hashCacheDb cacheDb } _ <- tryIOError $ LBS.writeFile path (Aeson.encode cg) pure () From 684849c921771e29d166ba693914fc4ee4aa1a7f Mon Sep 17 00:00:00 2001 From: Michal Kozakiewicz Date: Sat, 11 Apr 2026 15:17:12 +0000 Subject: [PATCH 10/11] Fix incremental rebuild: use ExternsDiff before timestamp fallback Previously, when a dependency had a newer output timestamp, the compiler would unconditionally recompile all dependents regardless of whether the dependency's interface actually changed. Now it checks ExternsDiff first, and only falls back to timestamp-based recompilation when a dep was rebuilt externally (in a separate build, not tracked by this build's ExternsDiff). Also removes updateSharedEnv from the skip path since doCompile already handles missing env entries when compilation is needed. Tracks which modules were compiled (vs skipped) in this build via compiledRef IORef to distinguish between empty diffs from this-build skips vs previous-build external rebuilds. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Language/PureScript/Make.hs | 3 +- src/Language/PureScript/Make/Rules.hs | 107 ++++++++++++-------------- 2 files changed, 52 insertions(+), 58 deletions(-) diff --git a/src/Language/PureScript/Make.hs b/src/Language/PureScript/Make.hs index bdedd27c5c..4da133f02c 100644 --- a/src/Language/PureScript/Make.hs +++ b/src/Language/PureScript/Make.hs @@ -222,6 +222,7 @@ makeIncremental ma@MakeActions{..} ms = do sharedEnvRef <- liftIO $ newIORef primEnv newCacheDbRef <- liftIO $ newIORef cacheDb timestampsRef <- liftIO $ newIORef M.empty + compiledRef <- liftIO $ newIORef S.empty -- Captured graph data for persistence graphRef <- liftIO $ newIORef (Nothing :: Maybe ([ModuleName], [(ModuleName, [ModuleName])])) @@ -229,7 +230,7 @@ makeIncremental ma@MakeActions{..} ms = do let rules :: Rock.Rules Query rules = Rock.memoise memoVar - $ makeRules moduleMap opts ma warningsRef compileFn cacheDb diffsRef sharedEnvRef newCacheDbRef timestampsRef cachedGraph graphRef + $ makeRules moduleMap opts ma warningsRef compileFn cacheDb diffsRef sharedEnvRef newCacheDbRef timestampsRef compiledRef cachedGraph graphRef -- Run the rock task: sort modules, then compile all in parallel. -- Rock's memoise handles synchronization: if module B depends on A, diff --git a/src/Language/PureScript/Make/Rules.hs b/src/Language/PureScript/Make/Rules.hs index a788c2554b..e7e6da0aa4 100644 --- a/src/Language/PureScript/Make/Rules.hs +++ b/src/Language/PureScript/Make/Rules.hs @@ -87,12 +87,14 @@ makeRules -> IORef Env -> IORef CacheDb -> IORef (M.Map ModuleName UTCTime) + -> IORef (S.Set ModuleName) + -- ^ Modules actually compiled (not skipped) in this build -> Maybe Traces.CachedGraph -- ^ Cached module graph from previous build (if valid) -> IORef (Maybe ([ModuleName], [(ModuleName, [ModuleName])])) -- ^ Captures computed graph for persistence -> Rock.Rules Query -makeRules modules opts actions warningsRef compileFn cacheDb diffsRef sharedEnvRef newCacheDbRef timestampsRef cachedGraph graphRef = \case +makeRules modules opts actions warningsRef compileFn cacheDb diffsRef sharedEnvRef newCacheDbRef timestampsRef compiledRef cachedGraph graphRef = \case InputModule mn -> case M.lookup mn modules of @@ -148,50 +150,51 @@ makeRules modules opts actions warningsRef compileFn cacheDb diffsRef sharedEnvR case ciCacheStatus cache of CacheHit myTimestamp -> do - -- Source unchanged. Check if any dep was rebuilt after us. + -- Source unchanged. Check if any dep was rebuilt externally (not + -- in this build) by comparing output timestamps. For deps rebuilt + -- in THIS build, ExternsDiff tells us if the interface changed. + diffs <- liftIO $ readIORef diffsRef timestamps <- liftIO $ readIORef timestampsRef - let depsNewerThanMe = any (\dep -> - maybe False (> myTimestamp) (getDepTimestamp timestamps dep)) sortedDeps + compiled <- liftIO $ readIORef compiledRef + let depDiffs = map (\dep -> fromMaybe (emptyDiff dep) (M.lookup dep diffs)) sortedDeps + pr = fromMaybe (internalError "makeRules: missing module") + (M.lookup mn modules) + fullModule = case snd (CST.resFull pr) of + Right m -> m + Left _ -> CST.resPartial pr + -- A dep rebuilt externally (in a previous build, not this one) + -- has newer output. We must recompile since ExternsDiff can't + -- tell us what changed in its externs across builds. + hasExternallyRebuiltDep = any (\dep -> + not (S.member dep compiled) && depHasNewerOutput timestamps dep myTimestamp) sortedDeps + needsRebuild = hasExternallyRebuiltDep || checkDiffs fullModule depDiffs - if depsNewerThanMe then do + if needsRebuild then do + -- Load cached externs for diff computation + mbCached <- loadExterns mn exts <- doCompile mn sortedDeps depExterns - recordDiff mn exts (ciOldExterns cache) sortedDeps + let diff = case mbCached of + Just old -> diffExterns exts old depDiffs + Nothing -> emptyDiff mn + liftIO $ atomicModifyIORef' diffsRef (\d -> (M.insert mn diff d, ())) pure exts else do - -- Check ExternsDiff - diffs <- liftIO $ readIORef diffsRef - let depDiffs = map (\dep -> fromMaybe (emptyDiff dep) (M.lookup dep diffs)) sortedDeps - pr = fromMaybe (internalError "makeRules: missing module") - (M.lookup mn modules) - fullModule = case snd (CST.resFull pr) of - Right m -> m - Left _ -> CST.resPartial pr - needsRebuild = checkDiffs fullModule depDiffs - - if needsRebuild then do - -- Load cached externs for diff computation - mbCached <- loadExterns mn - exts <- doCompile mn sortedDeps depExterns - let diff = case mbCached of - Just old -> diffExterns exts old depDiffs - Nothing -> emptyDiff mn - liftIO $ atomicModifyIORef' diffsRef (\d -> (M.insert mn diff d, ())) - pure exts - else do - updateSharedEnv sortedDeps depExterns - liftMake opts warningsRef $ - progress actions $ SkippingModule mn Nothing - liftIO $ atomicModifyIORef' diffsRef (\d -> (M.insert mn (emptyDiff mn) d, ())) - -- Load externs only now (deferred from cache check) - mbCached <- loadExterns mn - case mbCached of - Just cached -> pure cached - Nothing -> do - -- Externs missing on disk even though cache says up to date. - -- Fall back to recompilation. - exts <- doCompile mn sortedDeps depExterns - recordDiff mn exts Nothing sortedDeps - pure exts + -- Skip: deps' externs haven't meaningfully changed. + -- Don't call updateSharedEnv here — doCompile handles + -- missing env entries if a downstream module needs compilation. + liftMake opts warningsRef $ + progress actions $ SkippingModule mn Nothing + liftIO $ atomicModifyIORef' diffsRef (\d -> (M.insert mn (emptyDiff mn) d, ())) + -- Load externs only now (deferred from cache check) + mbCached <- loadExterns mn + case mbCached of + Just cached -> pure cached + Nothing -> do + -- Externs missing on disk even though cache says up to date. + -- Fall back to recompilation. + exts <- doCompile mn sortedDeps depExterns + recordDiff mn exts Nothing sortedDeps + pure exts CacheMiss -> do exts <- doCompile mn sortedDeps depExterns @@ -199,6 +202,12 @@ makeRules modules opts actions warningsRef compileFn cacheDb diffsRef sharedEnvR pure exts where + -- | Check if a dependency's output is newer than a given timestamp. + -- Used to detect deps rebuilt in a previous build (not in this one). + depHasNewerOutput :: M.Map ModuleName UTCTime -> ModuleName -> UTCTime -> Bool + depHasNewerOutput timestamps dep myTimestamp = + maybe False (> myTimestamp) (M.lookup dep timestamps) + -- | Lazily check a single module's cache status. -- This is the key difference from the eager approach: only called -- when rock actually demands this module. @@ -247,12 +256,9 @@ makeRules modules opts actions warningsRef compileFn cacheDb diffsRef sharedEnvR Right ext -> pure ext Left _ -> pure Nothing - -- | Get a dep's output timestamp (recorded during cache check). - getDepTimestamp :: M.Map ModuleName UTCTime -> ModuleName -> Maybe UTCTime - getDepTimestamp timestamps dep = M.lookup dep timestamps - doCompile :: ModuleName -> [ModuleName] -> [ExternsFile] -> Rock.Task Query ExternsFile doCompile mn sortedDeps depExterns = do + liftIO $ atomicModifyIORef' compiledRef (\s -> (S.insert mn s, ())) currentEnv <- liftIO $ readIORef sharedEnvRef let pr = fromMaybe (internalError $ "makeRules: CompileModule: module not found: " <> show (runModuleName mn)) (M.lookup mn modules) @@ -269,19 +275,6 @@ makeRules modules opts actions warningsRef compileFn cacheDb diffsRef sharedEnvR m <- CST.unwrapParserError fp mres compileFn sugarEnv depExterns m - updateSharedEnv :: [ModuleName] -> [ExternsFile] -> Rock.Task Query () - updateSharedEnv sortedDeps depExterns = do - currentEnv <- liftIO $ readIORef sharedEnvRef - let missingExterns = [ exts - | (dep, exts) <- zip sortedDeps depExterns - , not (M.member dep currentEnv) - ] - if null missingExterns then pure () - else do - newEnv <- liftMake opts warningsRef $ - fmap fst . runWriterT $ foldM externsEnv currentEnv missingExterns - liftIO $ atomicModifyIORef' sharedEnvRef (\_ -> (newEnv, ())) - recordDiff :: ModuleName -> ExternsFile -> Maybe ExternsFile -> [ModuleName] -> Rock.Task Query () recordDiff mn exts mbOldExterns sortedDeps = do diffs <- liftIO $ readIORef diffsRef From 29c88eb6b12ce92f673a26dd74fe462d5ef06688 Mon Sep 17 00:00:00 2001 From: Michal Kozakiewicz Date: Sun, 12 Apr 2026 14:38:09 +0000 Subject: [PATCH 11/11] Fix CI: use git dependency for rock, fix hlint warnings - Replace local ../rock path with github: ollef/rock git reference so CI can resolve the dependency - Fix hlint warnings: redundant brackets in Query.hs, use traverse_ and const in Rules.hs - Remove unused liftMake export from Rules module Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Language/PureScript/Make/Query.hs | 4 ++-- src/Language/PureScript/Make/Rules.hs | 8 ++++---- stack.yaml | 3 ++- stack.yaml.lock | 11 +++++++++++ 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/Language/PureScript/Make/Query.hs b/src/Language/PureScript/Make/Query.hs index 8100602550..6845824efe 100644 --- a/src/Language/PureScript/Make/Query.hs +++ b/src/Language/PureScript/Make/Query.hs @@ -68,8 +68,8 @@ instance GShow Query where instance GEq Query where geq (InputModule a) (InputModule b) | a == b = Just Refl - geq (ModuleGraph) (ModuleGraph) = Just Refl - geq (SortedModules) (SortedModules) = Just Refl + geq ModuleGraph ModuleGraph = Just Refl + geq SortedModules SortedModules = Just Refl geq (ModuleSugarEnv a) (ModuleSugarEnv b) | a == b = Just Refl geq (ModuleTypeEnv a) (ModuleTypeEnv b) diff --git a/src/Language/PureScript/Make/Rules.hs b/src/Language/PureScript/Make/Rules.hs index e7e6da0aa4..0b46aaa1bc 100644 --- a/src/Language/PureScript/Make/Rules.hs +++ b/src/Language/PureScript/Make/Rules.hs @@ -3,13 +3,13 @@ module Language.PureScript.Make.Rules ( makeRules , MakeError(..) - , liftMake ) where import Prelude import Control.Exception (Exception, throwIO) import Control.Monad (foldM) +import Data.Foldable (traverse_) import Control.Monad.IO.Class (liftIO) import Control.Monad.Writer.Class (tell) import Data.IORef (IORef, atomicModifyIORef', readIORef) @@ -106,7 +106,7 @@ makeRules modules opts actions warningsRef compileFn cacheDb diffsRef sharedEnvR Just cg | M.keysSet modules == S.fromList (Traces.cgSorted cg) -> pure (Traces.cgSorted cg) _ -> do let allNames = M.keys modules - _ <- traverse (\mn -> Rock.fetch (InputModule mn)) allNames + traverse_ (\mn -> Rock.fetch (InputModule mn)) allNames liftMake opts warningsRef $ do let prs = M.elems modules -- Use Direct deps for sorting (cheaper than Transitive). @@ -114,7 +114,7 @@ makeRules modules opts actions warningsRef compileFn cacheDb diffsRef sharedEnvR (sorted, directGraph) <- sortModules Direct (moduleSignature . CST.resPartial) prs let result = map (getModuleName . CST.resPartial) sorted -- Capture direct graph for persistence (compact on disk) - liftIO $ atomicModifyIORef' graphRef (\_ -> (Just (result, directGraph), ())) + liftIO $ atomicModifyIORef' graphRef (const (Just (result, directGraph), ())) pure result ModuleGraph -> case cachedGraph of @@ -270,7 +270,7 @@ makeRules modules opts actions warningsRef compileFn cacheDb diffsRef sharedEnvR ] liftMake opts warningsRef $ do sugarEnv <- fmap fst . runWriterT $ foldM externsEnv currentEnv missingExterns - liftIO $ atomicModifyIORef' sharedEnvRef (\_ -> (sugarEnv, ())) + liftIO $ atomicModifyIORef' sharedEnvRef (const (sugarEnv, ())) tell $ CST.toMultipleWarnings fp pwarnings m <- CST.unwrapParserError fp mres compileFn sugarEnv depExterns m diff --git a/stack.yaml b/stack.yaml index e5e46856f7..ef266dd998 100644 --- a/stack.yaml +++ b/stack.yaml @@ -5,7 +5,6 @@ pvp-bounds: both system-ghc: true packages: - '.' -- '../rock' ghc-options: # Build with advanced optimizations enabled by default "$locals": -O2 -Werror @@ -21,6 +20,8 @@ extra-deps: - aeson-better-errors-0.9.1.3 # Rock incremental computation dependencies - dependent-hashmap-0.1.0.1 +- github: ollef/rock + commit: 2b007b75f2866b0c9dae82049a0a06582d883b0f - github: purescript/cheapskate commit: 8bfaf4beeb108e97a274ed51303f278905979e87 diff --git a/stack.yaml.lock b/stack.yaml.lock index 50a3504824..389a3858a7 100644 --- a/stack.yaml.lock +++ b/stack.yaml.lock @@ -46,6 +46,17 @@ packages: size: 334 original: hackage: dependent-hashmap-0.1.0.1 +- completed: + name: rock + pantry-tree: + sha256: cef9733314b1f4778ccecaf5fb6f80ef1004251ea76804073a498ddf02c00ed5 + size: 751 + sha256: 3b2ec18d31734ad105b5ff7cd795d06d4d0e9be925af5bcdb9a7ea20d414cbc7 + size: 12242 + url: https://github.com/ollef/rock/archive/2b007b75f2866b0c9dae82049a0a06582d883b0f.tar.gz + version: 0.3.1.2 + original: + url: https://github.com/ollef/rock/archive/2b007b75f2866b0c9dae82049a0a06582d883b0f.tar.gz - completed: name: cheapskate pantry-tree: