mem-info-0.3.0.1: src/System/MemInfo/Proc.hs
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{- |
Module : System.MemInfo.Proc
Copyright : (c) 2022 Tim Emiola
Maintainer : Tim Emiola <adetokunbo@emio.la>
SPDX-License-Identifier: BSD3
This module provides types that model data from files in the @Linux proc
filesystem@ that track memory usage, combinators for parsing the files contents,
and for grouping the results.
-}
module System.MemInfo.Proc (
-- * Combine process memory metrics
MemUsage (..),
amass,
-- * Parse process memory metrics
ProcUsage (..),
parseFromSmap,
parseFromStatm,
-- * Parse \/proc\/\<pid\>\/exe
ExeInfo (..),
parseExeInfo,
-- * Parse \/proc\/<pid\>\/status
parseStatusInfo,
StatusInfo (..),
BadStatus (..),
) where
import qualified Data.Map as Map
import qualified Data.Set as Set
import qualified Data.Text as Text
import Data.Validity (Validity (..), check, delve)
import Data.Validity.Text ()
import GHC.Generics (Generic)
import System.MemInfo.Prelude
import System.MemInfo.SysInfo (KernelVersion, fickleSharing)
-- | Represents the information about a process obtained from /proc/<pid>/status
data StatusInfo = StatusInfo
{ siName :: !Text
, siParent :: !ProcessID
}
deriving (Eq, Show, Generic)
instance Validity StatusInfo where
validate StatusInfo {siName, siParent} =
let name' = Text.strip siName
noNewlines = not $ Text.any (== '\n') siName
nameOk = noNewlines && Text.length name' > 0 && siName == name'
in mconcat
[ check nameOk "the process name"
, delve "the process ID" $ toInteger siParent
]
-- | Indicates why @'parseStatusInfo'@ failed
data BadStatus
= NoCmd
| NoParent
deriving (Eq, Show)
-- | Parses the content of \/proc\/\<pid\>\/status into a @'StatusInfo'@
parseStatusInfo :: Text -> Either BadStatus StatusInfo
parseStatusInfo content =
let
statusLines = Text.lines content
parseLine key l = Text.strip <$> Text.stripPrefix (key <> ":") l
mkStep prefix acc l = case acc of
Nothing -> parseLine prefix l
found -> found
name = maybe (Left NoCmd) Right name'
name' = foldl' (mkStep "Name") Nothing statusLines
ppidTxt = foldl' (mkStep "PPid") Nothing statusLines
parsePpid = readMaybe . Text.unpack
ppId = maybe (Left NoParent) Right (ppidTxt >>= parsePpid)
in
StatusInfo <$> name <*> ppId
-- | Parses the target of \/proc\/\<pid\>\/exe into a @'ExeInfo'@
parseExeInfo :: Text -> ExeInfo
parseExeInfo x =
let eiTarget = takeTillNull x
eiDeleted = delEnd `Text.isSuffixOf` eiTarget
withoutDeleted = Text.replace delEnd "" eiTarget
eiOriginal = if eiDeleted then withoutDeleted else eiTarget
takeTillNull = Text.takeWhile (not . isNull)
in ExeInfo {eiDeleted, eiOriginal, eiTarget}
delEnd :: Text
delEnd = " (deleted)"
-- | Represents the information about a process obtained from \/proc\/\<pid\>\/exe
data ExeInfo = ExeInfo
{ eiTarget :: !Text
-- ^ the path that the link \/proc\/\<pid\>\/exe resolves to
, eiOriginal :: !Text
-- ^ a sanitized form of eiTarget; it removes the / (deleted)/ suffix
, eiDeleted :: !Bool
-- ^ does eiTarget end with /(deleted)/?
}
deriving (Eq, Show, Generic)
instance Validity ExeInfo where
validate ei | eiDeleted ei = check (eiOriginal ei <> delEnd == eiTarget ei) "target is actually deleted"
validate ei = check (eiOriginal ei == eiTarget ei) "target is not deleted"
-- | Combine @'ProcUsage'@, grouping them by the effective program name
amass ::
(Ord a) =>
Bool ->
[(a, ProcUsage)] ->
Map a MemUsage
amass hasPss = Map.map (fromSubTotal hasPss) . foldl' (incrSubTotals hasPss) mempty
fromSubTotal :: Bool -> SubTotal -> MemUsage
fromSubTotal hasPss st =
let reducedPrivate = stPrivate st `div` stCount st
areThreads = threadsNotProcs st
reducedShared = stShared st `div` stCount st
newPrivate = if areThreads then reducedPrivate else stPrivate st
newShared = if areThreads && hasPss then reducedShared else stShared st
in MemUsage
{ muShared = newShared + stSharedHuge st
, muPrivate = newPrivate + newShared + stSharedHuge st
, muSwap = stSwap st
, muCount = stCount st
}
-- | Represents the measured memory usage of a program
data MemUsage = MemUsage
{ muShared :: !Int
-- ^ the total shared memory in use
, muPrivate :: !Int
-- ^ the total private memory in use
, muCount :: !Int
-- ^ the number of processes running as the program
, muSwap :: !Int
-- ^ the total swap memory in use
}
deriving (Eq, Show)
incrSubTotals ::
(Ord a) =>
Bool ->
Map a SubTotal ->
(a, ProcUsage) ->
Map a SubTotal
incrSubTotals hasPss acc (cmd, mem) =
let combinePrivate next prev | hasPss = next + prev
combinePrivate next prev = max next prev
nextSt =
SubTotal
{ stShared = puShared mem
, stSharedHuge = puSharedHuge mem
, stCount = 1
, stPrivate = puPrivate mem
, stSwap = puSwap mem
, stMemIds = Set.singleton $ puMemId mem
}
update' next prev =
prev
{ stShared = stShared next + stShared prev
, stSharedHuge = max (stSharedHuge next) (stSharedHuge prev)
, stPrivate = combinePrivate (stPrivate next) (stPrivate prev)
, stCount = stCount next + stCount prev
, stSwap = stSwap next + stSwap prev
, stMemIds = Set.union (stMemIds next) (stMemIds prev)
}
in Map.insertWith update' cmd nextSt acc
data SubTotal = SubTotal
{ stShared :: !Int
, stSharedHuge :: !Int
, stPrivate :: !Int
, stCount :: !Int
, stSwap :: !Int
, stMemIds :: !(Set Int)
}
deriving (Eq, Show)
-- If a process is invoked with clone using flags CLONE_VM and not CLONE_THREAD
-- it will share the same memory space as it's parent; this needs to accounted
-- for
--
-- This is detected by computing the memId has the hash of lines for the proc
-- read from its smaps file.
threadsNotProcs :: SubTotal -> Bool
threadsNotProcs cs = Set.size (stMemIds cs) == 1 && stCount cs > 1
-- | Represents the memory metrics for a single process
data ProcUsage = ProcUsage
{ puPrivate :: !Int
, puShared :: !Int
, puSharedHuge :: !Int
, puSwap :: !Int
, puMemId :: !Int
}
deriving (Eq, Show)
-- value used as page size when @MemStat@ is calcuated from statm
pageSizeKiB :: Int
pageSizeKiB = 4
-- | Parse @'ProcUsage'@ from the contents of \/proc\/\<pid\>\/statm
parseFromStatm :: KernelVersion -> Text -> Maybe ProcUsage
parseFromStatm version content =
let
parseWord w (Just acc) = (\x -> Just (x : acc)) =<< readMaybe (Text.unpack w)
parseWord _ Nothing = Nothing
parseMetrics = foldr parseWord (Just mempty)
withMemId = ppZero {puMemId = hash content}
fromRss rss _shared
| fickleSharing version = withMemId {puPrivate = rss * pageSizeKiB}
fromRss rss shared =
withMemId
{ puShared = shared * pageSizeKiB
, puPrivate = (rss - shared) * pageSizeKiB
}
fromRss' (_size : rss : shared : _xs) = Just $ fromRss rss shared
fromRss' _ = Nothing
in
parseMetrics (Text.words content) >>= fromRss'
ppZero :: ProcUsage
ppZero =
ProcUsage
{ puPrivate = 0
, puShared = 0
, puSharedHuge = 0
, puSwap = 0
, puMemId = 0
}
-- | Parse @'ProcUsage'@ from the contents of \/proc\/\<pid\>\/smap
parseFromSmap :: Text -> ProcUsage
parseFromSmap = fromSmap . parseSmapStats
parseSmapStats :: Text -> SmapStats
parseSmapStats content =
let noMemId = foldl' incrSmapStats ssZero $ Text.lines content
in noMemId {ssMemId = hash content}
fromSmap :: SmapStats -> ProcUsage
fromSmap ss =
let pssTweak = ssPssCount ss `div` 2 -- add ~0.5 per line to counter truncation
pssShared = ssPss ss + pssTweak - ssPrivate ss
in ProcUsage
{ puSwap = if ssHasSwapPss ss then ssSwapPss ss else ssSwap ss
, puShared = if ssHasPss ss then pssShared else ssShared ss
, puSharedHuge = ssSharedHuge ss
, puPrivate = ssPrivate ss + ssPrivateHuge ss
, puMemId = ssMemId ss
}
-- | Represents per-process data read from \/proc\/\<pid\>\/smap
data SmapStats = SmapStats
{ ssPss :: !Int
, ssPssCount :: !Int
, ssSwap :: !Int
, ssSwapPss :: !Int
, ssPrivate :: !Int
, ssPrivateHuge :: !Int
, ssSharedHuge :: !Int
, ssShared :: !Int
, ssMemId :: !Int
, ssHasPss :: !Bool
, ssHasSwapPss :: !Bool
}
deriving (Eq, Show)
ssZero :: SmapStats
ssZero =
SmapStats
{ ssPss = 0
, ssPssCount = 0
, ssSwap = 0
, ssSwapPss = 0
, ssPrivate = 0
, ssPrivateHuge = 0
, ssSharedHuge = 0
, ssShared = 0
, ssHasSwapPss = False
, ssHasPss = False
, ssMemId = 0
}
-- Q: is it worth the dependency to replace this with lens from a lens package ?
incrPss
, incrSwap
, incrSwapPss
, incrPrivate
, incrPrivateHuge
, incrShared
, incrSharedHuge ::
SmapStats -> Maybe Int -> SmapStats
incrPss ms = maybe ms $ \n -> ms {ssPss = n + ssPss ms}
incrSwap ms = maybe ms $ \n -> ms {ssSwap = n + ssSwap ms}
incrSwapPss ms = maybe ms $ \n -> ms {ssSwapPss = n + ssSwapPss ms}
incrPrivate ms = maybe ms $ \n -> ms {ssPrivate = n + ssPrivate ms}
incrShared ms = maybe ms $ \n -> ms {ssShared = n + ssShared ms}
incrPrivateHuge ms = maybe ms $ \n -> ms {ssPrivateHuge = n + ssPrivateHuge ms}
incrSharedHuge ms = maybe ms $ \n -> ms {ssSharedHuge = n + ssSharedHuge ms}
incrSmapStats :: SmapStats -> Text -> SmapStats
incrSmapStats acc l =
if
| Text.isPrefixOf "Private_Hugetlb:" l -> incrPrivateHuge acc $ smapValMb l
| Text.isPrefixOf "Shared_Hugetlb:" l -> incrSharedHuge acc $ smapValMb l
| Text.isPrefixOf "Shared" l -> incrShared acc $ smapValMb l
| Text.isPrefixOf "Private" l -> incrPrivate acc $ smapValMb l
| Text.isPrefixOf "Pss:" l ->
let acc' = acc {ssHasPss = True, ssPssCount = 1 + ssPssCount acc}
in incrPss acc' $ smapValMb l
| Text.isPrefixOf "Swap:" l -> incrSwap acc $ smapValMb l
| Text.isPrefixOf "SwapPss:" l -> incrSwapPss (acc {ssHasSwapPss = True}) $ smapValMb l
| otherwise -> acc
smapValMb :: (Read a) => Text -> Maybe a
smapValMb l =
let memWords = Text.words l
readVal (_ : x : _) = readMaybe $ Text.unpack x
readVal _ = Nothing
in readVal memWords