packages feed

taffybar-7.2.7: src/System/Taffybar/Information/Nvidia.hs

{-# LANGUAGE OverloadedStrings #-}

-- |
-- Module      : System.Taffybar.Information.Nvidia
-- Copyright   : (c) Ivan Malison
-- License     : BSD3-style (see LICENSE)
--
-- Maintainer  : Ivan Malison <IvanMalison@gmail.com>
-- Stability   : unstable
-- Portability : unportable
--
-- NVIDIA GPU information obtained from @nvidia-smi@.
module System.Taffybar.Information.Nvidia
  ( NvidiaGpuInfo (..),
    parseNvidiaGpuInfo,
    readNvidiaGpuInfo,
    readNvidiaGpuInfoWith,
    shouldQueryNvidiaForRuntimeStatuses,
    getNvidiaGpuInfoChan,
    getNvidiaGpuInfoChanWith,
    getNvidiaGpuInfoState,
    getNvidiaGpuInfoStateWith,
    NvidiaGpuTemperature (..),
    parseNvidiaGpuTemperatures,
    readNvidiaGpuTemperatures,
    readNvidiaGpuTemperaturesWith,
  )
where

import Control.Concurrent (forkIO)
import Control.Concurrent.MVar
import Control.Concurrent.STM.TChan
import Control.Exception (IOException, try)
import Control.Monad (forever, void, when)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.STM (atomically)
import Data.Char (isHexDigit)
import Data.Foldable (for_)
import Data.List (sortOn)
import Data.Maybe (fromMaybe, mapMaybe)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import System.Directory (listDirectory)
import System.Exit (ExitCode (ExitSuccess))
import System.FilePath ((</>))
import System.Process (readProcessWithExitCode)
import System.Taffybar.Context (TaffyIO, getStateDefault)
import System.Taffybar.Information.Wakeup (getWakeupChannelForDelay)
import Text.Read (readMaybe)
import Text.XML.Light

-- | A complete snapshot for one NVIDIA GPU.
--
-- Fields that @nvidia-smi@ reports as unavailable are represented by
-- 'Nothing'. Temperatures are in Celsius, power values are in watts, and
-- utilization and fan readings are percentages.
data NvidiaGpuInfo = NvidiaGpuInfo
  { nvidiaInfoIndex :: !Int,
    nvidiaInfoName :: !T.Text,
    nvidiaInfoTemperatureCelsius :: !(Maybe Double),
    nvidiaInfoMemoryTemperatureCelsius :: !(Maybe Double),
    nvidiaInfoTargetTemperatureCelsius :: !(Maybe Double),
    -- | Remaining temperature headroom before the target temperature.
    nvidiaInfoThermalHeadroomCelsius :: !(Maybe Double),
    nvidiaInfoFanSpeedPercent :: !(Maybe Double),
    nvidiaInfoGpuUtilizationPercent :: !(Maybe Double),
    nvidiaInfoMemoryUtilizationPercent :: !(Maybe Double),
    nvidiaInfoMemoryUsedMiB :: !(Maybe Double),
    nvidiaInfoMemoryTotalMiB :: !(Maybe Double),
    nvidiaInfoPowerDrawWatts :: !(Maybe Double),
    nvidiaInfoPowerLimitWatts :: !(Maybe Double),
    nvidiaInfoPerformanceState :: !(Maybe T.Text)
  }
  deriving (Eq, Show)

-- | Parse the XML produced by @nvidia-smi -q -x@.
parseNvidiaGpuInfo :: T.Text -> [NvidiaGpuInfo]
parseNvidiaGpuInfo contents =
  maybe [] (sortOn nvidiaInfoIndex . mapMaybe parseGpu . findElements (unqual "gpu")) $
    parseXMLDoc $
      T.unpack contents
  where
    parseGpu gpu = do
      index <- readElement ["minor_number"] gpu
      name <- elementText ["product_name"] gpu
      pure
        NvidiaGpuInfo
          { nvidiaInfoIndex = index,
            nvidiaInfoName = name,
            nvidiaInfoTemperatureCelsius = readElement ["temperature", "gpu_temp"] gpu,
            nvidiaInfoMemoryTemperatureCelsius = readElement ["temperature", "memory_temp"] gpu,
            nvidiaInfoTargetTemperatureCelsius = readElement ["temperature", "gpu_target_temperature"] gpu,
            nvidiaInfoThermalHeadroomCelsius = readElement ["temperature", "gpu_temp_tlimit"] gpu,
            nvidiaInfoFanSpeedPercent = readElement ["fan_speed"] gpu,
            nvidiaInfoGpuUtilizationPercent = readElement ["utilization", "gpu_util"] gpu,
            nvidiaInfoMemoryUtilizationPercent = readElement ["utilization", "memory_util"] gpu,
            nvidiaInfoMemoryUsedMiB = readElement ["fb_memory_usage", "used"] gpu,
            nvidiaInfoMemoryTotalMiB = readElement ["fb_memory_usage", "total"] gpu,
            nvidiaInfoPowerDrawWatts =
              firstElement
                [ ["gpu_power_readings", "average_power_draw"],
                  ["gpu_power_readings", "instant_power_draw"],
                  ["power_readings", "power_draw"]
                ]
                gpu,
            nvidiaInfoPowerLimitWatts =
              firstElement
                [ ["gpu_power_readings", "current_power_limit"],
                  ["gpu_power_readings", "requested_power_limit"],
                  ["power_readings", "power_limit"]
                ]
                gpu,
            nvidiaInfoPerformanceState = availableElementText ["performance_state"] gpu
          }

-- | Read a rich snapshot using @nvidia-smi@ from @PATH@.
readNvidiaGpuInfo :: IO [NvidiaGpuInfo]
readNvidiaGpuInfo = readNvidiaGpuInfoWith "nvidia-smi"

-- | Read a rich snapshot using the supplied @nvidia-smi@ executable.
-- Returns an empty list when the command is missing, exits unsuccessfully, or
-- is skipped because every detected NVIDIA PCI device is runtime-suspended.
readNvidiaGpuInfoWith :: FilePath -> IO [NvidiaGpuInfo]
readNvidiaGpuInfoWith command =
  fromMaybe [] <$> readNvidiaGpuInfoUpdateWith command

-- | Decide whether querying NVIDIA is safe from the runtime power states of
-- the detected NVIDIA PCI devices. A query is skipped only when at least one
-- device was detected and every device explicitly reports that it is suspended
-- or suspending. Missing and unknown states preserve the historical behavior
-- of running @nvidia-smi@.
shouldQueryNvidiaForRuntimeStatuses :: [Maybe T.Text] -> Bool
shouldQueryNvidiaForRuntimeStatuses [] = True
shouldQueryNvidiaForRuntimeStatuses statuses =
  any (maybe True ((`notElem` lowPowerStatuses) . T.strip)) statuses
  where
    lowPowerStatuses = ["suspended", "suspending"]

newtype NvidiaGpuInfoChanVar
  = NvidiaGpuInfoChanVar (TChan [NvidiaGpuInfo], MVar [NvidiaGpuInfo])

-- | Get a shared broadcast channel of rich NVIDIA snapshots.
--
-- The first call starts one polling producer for the process; subsequent calls
-- reuse it. Consequently, the command and interval from the first call win.
getNvidiaGpuInfoChan :: Double -> TaffyIO (TChan [NvidiaGpuInfo])
getNvidiaGpuInfoChan = getNvidiaGpuInfoChanWith "nvidia-smi"

-- | Like 'getNvidiaGpuInfoChan', using a supplied @nvidia-smi@ executable.
getNvidiaGpuInfoChanWith :: FilePath -> Double -> TaffyIO (TChan [NvidiaGpuInfo])
getNvidiaGpuInfoChanWith command interval = do
  NvidiaGpuInfoChanVar (chan, _) <- setupNvidiaGpuInfoChanVar command interval
  pure chan

-- | Read the latest snapshot cached by 'getNvidiaGpuInfoChan'.
getNvidiaGpuInfoState :: Double -> TaffyIO [NvidiaGpuInfo]
getNvidiaGpuInfoState = getNvidiaGpuInfoStateWith "nvidia-smi"

-- | Like 'getNvidiaGpuInfoState', using a supplied @nvidia-smi@ executable.
getNvidiaGpuInfoStateWith :: FilePath -> Double -> TaffyIO [NvidiaGpuInfo]
getNvidiaGpuInfoStateWith command interval = do
  NvidiaGpuInfoChanVar (_, var) <- setupNvidiaGpuInfoChanVar command interval
  liftIO $ readMVar var

setupNvidiaGpuInfoChanVar :: FilePath -> Double -> TaffyIO NvidiaGpuInfoChanVar
setupNvidiaGpuInfoChanVar command interval = do
  wakeupChan <- getWakeupChannelForDelay $ max 0.000001 interval
  ourWakeupChan <- liftIO $ atomically $ dupTChan wakeupChan
  getStateDefault $ liftIO $ do
    initialInfo <- fromMaybe [] <$> readNvidiaGpuInfoUpdateWith command
    chan <- newBroadcastTChanIO
    var <- newMVar initialInfo
    void $ forkIO $ forever $ do
      void $ atomically $ readTChan ourWakeupChan
      maybeInfo <- readNvidiaGpuInfoUpdateWith command
      for_ maybeInfo $ \info -> do
        old <- swapMVar var info
        when (info /= old) $ atomically $ writeTChan chan info
    pure $ NvidiaGpuInfoChanVar (chan, var)

-- | A temperature reported for one NVIDIA GPU.
--
-- This small compatibility type is retained for callers that only need the
-- current core temperature. New code should prefer 'NvidiaGpuInfo'.
data NvidiaGpuTemperature = NvidiaGpuTemperature
  { nvidiaGpuIndex :: Int,
    nvidiaGpuTemperatureCelsius :: Double
  }
  deriving (Eq, Show)

-- | Parse @index, temperature.gpu@ rows emitted by @nvidia-smi@.
-- Invalid rows, including unavailable temperature values, are ignored.
parseNvidiaGpuTemperatures :: T.Text -> [NvidiaGpuTemperature]
parseNvidiaGpuTemperatures =
  sortOn nvidiaGpuIndex . mapMaybe parseLine . T.lines
  where
    parseLine line =
      case map T.strip $ T.splitOn "," line of
        [indexText, temperatureText] ->
          NvidiaGpuTemperature
            <$> readMaybe (T.unpack indexText)
            <*> readMaybe (T.unpack temperatureText)
        _ -> Nothing

-- | Read core temperatures using @nvidia-smi@ from @PATH@.
readNvidiaGpuTemperatures :: IO [NvidiaGpuTemperature]
readNvidiaGpuTemperatures = readNvidiaGpuTemperaturesWith "nvidia-smi"

-- | Read core temperatures using the supplied @nvidia-smi@ executable.
readNvidiaGpuTemperaturesWith :: FilePath -> IO [NvidiaGpuTemperature]
readNvidiaGpuTemperaturesWith command = do
  result <-
    runNvidiaSmi
      command
      [ "--query-gpu=index,temperature.gpu",
        "--format=csv,noheader,nounits"
      ]
  pure $ case result of
    NvidiaSmiOutput output -> parseNvidiaGpuTemperatures $ T.pack output
    NvidiaSmiSkipped -> []
    NvidiaSmiFailed -> []

data NvidiaSmiResult
  = NvidiaSmiSkipped
  | NvidiaSmiFailed
  | NvidiaSmiOutput String

readNvidiaGpuInfoUpdateWith :: FilePath -> IO (Maybe [NvidiaGpuInfo])
readNvidiaGpuInfoUpdateWith command = do
  result <- runNvidiaSmi command ["-q", "-x"]
  pure $ case result of
    NvidiaSmiSkipped -> Nothing
    NvidiaSmiFailed -> Just []
    NvidiaSmiOutput output -> Just $ parseNvidiaGpuInfo $ T.pack output

runNvidiaSmi :: FilePath -> [String] -> IO NvidiaSmiResult
runNvidiaSmi command arguments = do
  statuses <- nvidiaPciRuntimeStatuses
  if shouldQueryNvidiaForRuntimeStatuses statuses
    then do
      result <-
        try (readProcessWithExitCode command arguments "") ::
          IO (Either IOException (ExitCode, String, String))
      pure $ case result of
        Right (ExitSuccess, output, _) -> NvidiaSmiOutput output
        _ -> NvidiaSmiFailed
    else pure NvidiaSmiSkipped

nvidiaPciRuntimeStatuses :: IO [Maybe T.Text]
nvidiaPciRuntimeStatuses = do
  let driverPath = "/sys/bus/pci/drivers/nvidia"
  entriesResult <- try (listDirectory driverPath) :: IO (Either IOException [FilePath])
  case entriesResult of
    Left _ -> pure []
    Right entries ->
      traverse (readRuntimeStatus . (driverPath </>)) $
        filter isPciAddress entries

readRuntimeStatus :: FilePath -> IO (Maybe T.Text)
readRuntimeStatus devicePath = do
  result <-
    try (T.strip <$> TIO.readFile (devicePath </> "power/runtime_status")) ::
      IO (Either IOException T.Text)
  pure $ either (const Nothing) Just result

isPciAddress :: FilePath -> Bool
isPciAddress entry =
  case T.split (\character -> character == ':' || character == '.') $ T.pack entry of
    [domain, bus, device, function] ->
      and
        [ T.length domain == 4,
          T.length bus == 2,
          T.length device == 2,
          T.length function == 1,
          all (all isHexDigit . T.unpack) [domain, bus, device, function]
        ]
    _ -> False

elementAt :: [String] -> Element -> Maybe Element
elementAt [] element = Just element
elementAt (name : rest) element =
  findChild (unqual name) element >>= elementAt rest

elementText :: [String] -> Element -> Maybe T.Text
elementText path element = T.strip . T.pack . strContent <$> elementAt path element

availableElementText :: [String] -> Element -> Maybe T.Text
availableElementText path element = do
  value <- elementText path element
  if value `elem` ["", "N/A", "[N/A]"] then Nothing else Just value

readElement :: (Read a) => [String] -> Element -> Maybe a
readElement path element = do
  value <- availableElementText path element
  case reads $ T.unpack value of
    [(number, _)] -> Just number
    _ -> Nothing

firstElement :: (Read a) => [[String]] -> Element -> Maybe a
firstElement paths element =
  case mapMaybe (`readElement` element) paths of
    value : _ -> Just value
    [] -> Nothing