diff --git a/WebUI/electron/main.ts b/WebUI/electron/main.ts index 5d60b638..1294d0ba 100644 --- a/WebUI/electron/main.ts +++ b/WebUI/electron/main.ts @@ -28,6 +28,8 @@ import { updateIntelWorkflows } from './subprocesses/updateIntelWorkflows.ts' import getPort, { portNumbers } from 'get-port' import { getMediaDir } from './util.ts' +import { runGpuid, runPowerShellScript, setWindow } from './subprocesses/collectorService' + // } // The built directory structure // @@ -172,6 +174,12 @@ async function createWindow() { if (url.startsWith('https:')) shell.openExternal(url) return { action: 'deny' } }) + + // Start metrics collection service + setWindow(win) + runGpuid() + runPowerShellScript() + return win } diff --git a/WebUI/electron/preload.ts b/WebUI/electron/preload.ts index 9200275d..09cf3024 100644 --- a/WebUI/electron/preload.ts +++ b/WebUI/electron/preload.ts @@ -72,4 +72,9 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('serviceSetUpProgress', (_event, value) => callback(value)), onServiceInfoUpdate: (callback: (service: ApiServiceInformation) => void) => ipcRenderer.on('serviceInfoUpdate', (_event, value) => callback(value)), + onMetrics: ( + callback: ( + metrics: { [key: string]: number }, + gpuInfo: { adapter: string, luid: string, sharedMemory: string, dedicatedMemory: string }[] + ) => void) => ipcRenderer.on('metrics', (_event, metrics, gpuInfo) => callback(metrics, gpuInfo)), }) diff --git a/WebUI/electron/subprocesses/collectorService.ts b/WebUI/electron/subprocesses/collectorService.ts new file mode 100644 index 00000000..5e12dd1b --- /dev/null +++ b/WebUI/electron/subprocesses/collectorService.ts @@ -0,0 +1,135 @@ +import { spawn } from 'child_process' +import path from 'path' + +import { fileURLToPath } from 'url' +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const collectorPath = path.join(__dirname, '../../public/tools/collector.ps1') +const gpuidPath = path.join(__dirname, '../../public/tools/gpuid.exe') + +let gpuInfo: { adapter: string; luid: string; sharedMemory: string; dedicatedMemory: string }[] = [] + +export function runGpuid() { + console.log('Running gpuid...') + const ps = spawn(gpuidPath) + + ps.stdout.on('data', (data) => { + const output = data.toString() + processGPUIdOutput(output) + }) + + ps.stderr.on('data', (data) => { + console.error('Error:', data.toString()) + }) + + ps.on('close', (code) => { + console.log(`GPUId tool exited with code ${code}`) + }) +} + +export function runPowerShellScript() { + console.log('Running PowerShell script...') + const ps = spawn('powershell.exe', [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + collectorPath, + ]) + + ps.stdout.on('data', (data) => { + const output = data.toString() + processOutput(output) + }) + + ps.stderr.on('data', (data) => { + console.error('Error:', data.toString()) + }) + + ps.on('close', (code) => { + console.log(`PowerShell script exited with code ${code}`) + runPowerShellScript() // Restart the process + }) +} + +export function processGPUIdOutput(output: string) { + const lines = output.split('\n') + gpuInfo = [] + let currentAdapter: { + adapter: string + luid: string + sharedMemory: string + dedicatedMemory: string + } | null = null + + lines.forEach((line) => { + if (line.startsWith('Found adapter:')) { + if (currentAdapter) { + gpuInfo.push(currentAdapter) + } + currentAdapter = { + adapter: line.replace('Found adapter: ', '').trim(), + luid: '', + sharedMemory: '', + dedicatedMemory: '', + } + } else if (currentAdapter) { + const [key, value] = line.split(':') + switch (key.trim()) { + case 'Adapter LUID': + currentAdapter.luid = value.trim().split(' ')[0] + break + case 'Adapter Shared Memory': + currentAdapter.sharedMemory = value.trim().split(' ')[0] + break + case 'Adapter Dedicated Memory': + currentAdapter.dedicatedMemory = value.trim().split(' ')[0] + break + } + } + }) + + if (currentAdapter) { + gpuInfo.push(currentAdapter) + } + + // Log the GPU info + console.log('GPU Info:', gpuInfo) +} + +import { BrowserWindow } from 'electron' +let win: BrowserWindow | null = null + +export function setWindow(window: BrowserWindow) { + win = window +} + +function processOutput(output: string) { + // Split output by new line + const lines = output.split('\n') + + // Process each line + lines.forEach((line) => { + // Initialize metrics object + let metrics: { [key: string]: number } = {} + + // Split line by space + const segments = line.split(' ') + + // Check that the first segment is the "metrics" keyword + if (segments[0] !== 'metrics') { + return + } + + // Process each metric + segments[1].split(',').forEach((metric) => { + const [key, value] = metric.split('=') + metrics[key] = parseFloat(value) + }) + + // Add the timestamp + metrics['epoch'] = parseInt(segments[2], 10) + + // Send the metrics and GPU info to the renderer + win?.webContents.send('metrics', { metrics, gpuInfo }) + }) +} diff --git a/WebUI/package-lock.json b/WebUI/package-lock.json index 24050363..0335f9b7 100644 --- a/WebUI/package-lock.json +++ b/WebUI/package-lock.json @@ -32,6 +32,7 @@ "unplugin-auto-import": "^0.18.6", "uuid": "^11.0.5", "vue": "^3.5.12", + "vue-chartjs": "^5.3.2", "zod": "^3.24.1" }, "devDependencies": { @@ -1550,6 +1551,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT", + "peer": true + }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -4169,6 +4177,19 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.8.tgz", + "integrity": "sha512-IkGZlVpXP+83QpMm4uxEiGqSI7jFizwVtF3+n5Pc3k7sMO+tkd0qxh2OzLhenM0K80xtmAONWGBn082EiBQSDA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", @@ -11560,6 +11581,16 @@ } } }, + "node_modules/vue-chartjs": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz", + "integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "vue": "^3.0.0-0 || ^2.7.0" + } + }, "node_modules/vue-demi": { "version": "0.14.10", "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", diff --git a/WebUI/package.json b/WebUI/package.json index 80bb3dfd..57fc9b5d 100644 --- a/WebUI/package.json +++ b/WebUI/package.json @@ -42,6 +42,7 @@ "unplugin-auto-import": "^0.18.6", "uuid": "^11.0.5", "vue": "^3.5.12", + "vue-chartjs": "^5.3.2", "zod": "^3.24.1" }, "devDependencies": { diff --git a/WebUI/public/tools/collector.ps1 b/WebUI/public/tools/collector.ps1 new file mode 100644 index 00000000..bb8cfe68 --- /dev/null +++ b/WebUI/public/tools/collector.ps1 @@ -0,0 +1,159 @@ +# Constants + +# Sample Interval is the time in seconds between each sample +$SampleInterval = 1 + +# Max Number of Samples is the maximum number of samples to collect +$MaxNumberOfSamples = 60 + +# Dictionary of GPUs +$gpus = @{} + +# Get GPU Adapter Memory and GPU Engine Counter Sets +$gpuAdapterMemory = Get-counter -ListSet "GPU Adapter Memory" +$gpuEngines = Get-counter -ListSet "GPU Engine" + +# Populate GPUs Dictionary +$Pattern = "\((.*?)\)" +foreach ($Match in [regex]::Matches($gpuAdapterMemory.PathsWithInstances, $Pattern)) { + + # Extract GPU Id + $id = $Match.Groups[1].Value + + # Initialize GPU Memory and Engine Types + $gpus[$id] = @{ + "MemoryTypes" = [System.Collections.Generic.List[string]]::new() + "EngineTypes" = [System.Collections.Generic.List[string]]::new() + } + + # Set Memory Types + $gpus[$id]["MemoryTypes"].Add("Shared Usage") | Out-Null + $gpus[$id]["MemoryTypes"].Add("Dedicated Usage") | Out-Null + $gpus[$id]["MemoryTypes"].Add("Total Committed") | Out-Null + + # Set Engine Types + $_gpuEngines = $gpuEngines.PathsWithInstances | Where-Object {$_ -match $id} + $Pattern = "_engtype_(.*?)\)" + foreach ($Match in [regex]::Matches($_gpuEngines, $Pattern)) { + if ($Match.Groups[1].Value -ne "") { + if (-not $gpus[$id]["EngineTypes"].Contains($Match.Groups[1].Value)) { + $gpus[$id]["EngineTypes"].Add($Match.Groups[1].Value) | Out-Null + } + } + } +} + +# Initialize Counter Set +$CounterSet = [System.Collections.Generic.List[string]]::new() + +# Add Processor Utilization Counters +$CounterSet.Add("\Processor Information(_Total)\% Processor Time") + +# Add Memory Utilization Counters +$CounterSet.Add("\Memory\% Committed Bytes In Use") + +# Add GPU Adapter Memory and GPU Engine Counters +foreach ($id in $gpus.Keys) { + + # Add GPU Adapter Memory Counters + foreach ($memoryType in $gpus[$id]["MemoryTypes"]) { + $CounterSet.Add("\GPU Adapter Memory(${id})\$memoryType") + } + + # Add GPU Engine Counters + foreach ($engineType in $gpus[$id]["EngineTypes"]) { + $CounterSet.Add("\GPU Engine(*_${id}_*_engtype_${engineType})\Utilization Percentage") + } +} + +# Debug Counter Set +foreach ($counter in $CounterSet) { + Write-Debug $counter +} + +# Query Counters +Get-Counter ` + -Counter $CounterSet ` + -MaxSamples $MaxNumberOfSamples ` + -SampleInterval $SampleInterval ` + -ErrorAction Stop | Foreach-Object { + + # Initialize Metrics Dictionary + $metrics = @{} + + # Set Timestamp + $metrics["timestamp"] = [math]::Round( + (Get-Date $_.Timestamp).ToUniversalTime().Subtract( + (Get-Date "1970-01-01T00:00:00Z") + ).TotalSeconds + ) + + # Set Processor Utilization + $metrics["cpu-utilization"] = [math]::Round( + ($_.CounterSamples | + Where-Object -Property Path -Match ".*processor information.*" + ).CookedValue, + 2 + ) + + # Set Memory Utilization + $metrics["memory-utilization"] = [math]::Round( + ($_.CounterSamples | + Where-Object -Property Path -Match ".*committed bytes in use.*" + ).CookedValue, + 2 + ) + + # Set GPU Adapter Memory and GPU Engine Utilization + foreach ($id in $gpus.Keys) { + + # Filter Counter Samples + $counterSamples = $_.CounterSamples | Where-Object -Property Path -Match ".*${id}.*" + + # Set Memory Utilization + foreach ($memoryType in $gpus[$id]["MemoryTypes"]) { + + # Gather value + $cookedValue = ($counterSamples | + Where-Object -Property Path -Match ".*$memoryType.*" + ).CookedValue + + # Normalize name + $normalizedMemoryType = ($memoryType -replace " ", "-").ToLower() + + # Save value + $metrics["gpu-${id}-memory-${normalizedMemoryType}"] = [math]::Round($cookedValue / 1GB, 2) + } + + # Set Engine Utilization + foreach ($engineType in $gpus[$id]["EngineTypes"]) { + + # Gather values + $cookedValue = ($counterSamples | + Where-Object -Property Path -Match ".*engtype_${engineType}" + ).CookedValue + + # Sum values + $sum = ($cookedValue | Measure-Object -Sum).Sum + + # Normalize name + $normalizedEngineType = ($engineType -replace " ", "-").ToLower() + + # Save sum + $metrics["gpu-${id}-engine-${normalizedEngineType}"] = [math]::Round($sum, 2) + } + } + + # Prepare Reporting Data + $output = "metrics " + $metrics.GetEnumerator() | Sort-Object Key | ForEach-Object { + if ($_.Key -ne "timestamp") { + $output += "$($_.Key)=$($_.Value)," + } + } + $output = $output.TrimEnd(",") + $output += " $($metrics["timestamp"])" + + # Report Data + Write-Output $output +} \ No newline at end of file diff --git a/WebUI/public/tools/gpuid.exe b/WebUI/public/tools/gpuid.exe new file mode 100644 index 00000000..bd5e150c Binary files /dev/null and b/WebUI/public/tools/gpuid.exe differ diff --git a/WebUI/src/App.vue b/WebUI/src/App.vue index 82f669f4..80f6b7a5 100644 --- a/WebUI/src/App.vue +++ b/WebUI/src/App.vue @@ -25,6 +25,11 @@
+
() const isOpen = ref(false) const activeTabIdx = ref(0) const showSetting = ref(false) +const showMetrics = ref(false) const showDowloadDlg = ref(false) const showModelRequestDialog = ref(false) const showWarningDialog = ref(false) @@ -330,6 +341,18 @@ function hideAppSettings() { showSetting.value = false } +function showAppMetrics() { + if (showMetrics.value === false) { + showMetrics.value = true + } else { + showMetrics.value = false + } +} + +function hideAppMetrics() { + showMetrics.value = false +} + function autoHideAppSettings(e: MouseEvent) { if ( showSetting.value && diff --git a/WebUI/src/env.d.ts b/WebUI/src/env.d.ts index ac548911..98b4cbc5 100644 --- a/WebUI/src/env.d.ts +++ b/WebUI/src/env.d.ts @@ -75,6 +75,12 @@ type electronAPI = { sendSetUpSignal(serviceName: string): void onServiceSetUpProgress(callback: (data: SetupProgress) => void): void onServiceInfoUpdate(callback: (service: ApiServiceInformation) => void): void + onMetrics( + callback: ( + metrics: { [key: string]: number }, + gpuInfo: { adapter: string, luid: string, sharedMemory: string, dedicatedMemory: string }[] + ) => void + ): void } type SetupProgress = { diff --git a/WebUI/src/views/AppMetrics.vue b/WebUI/src/views/AppMetrics.vue new file mode 100644 index 00000000..b5a3854d --- /dev/null +++ b/WebUI/src/views/AppMetrics.vue @@ -0,0 +1,261 @@ + + + diff --git a/gpuid/gpuid.cpp b/gpuid/gpuid.cpp new file mode 100644 index 00000000..b7bbd4ad --- /dev/null +++ b/gpuid/gpuid.cpp @@ -0,0 +1,61 @@ +// gpuid.cpp : This file contains the 'main' function. Program execution begins and ends there. +// +// This code is a simple example of how to get the GPU information using the DXGI API. It is based on the code in the following link: +// https://asawicki.info//news_1695_there_is_a_way_to_query_gpu_memory_usage_in_vulkan_-_use_dxgi +// +// Related to this implementation, NPU could be inspected in a similar fashion: +// https://learn.microsoft.com/en-us/answers/questions/1700210/how-to-read-and-output-the-npu-utilization => Something related to the NPU +// +// To compile, link against dxgi.lib and use static linking + +#include +#include +#include +#include +#include +#include + +int main() +{ + IDXGIFactory4* dxgiFactory = nullptr; + CreateDXGIFactory1(IID_PPV_ARGS(&dxgiFactory)); + + IDXGIAdapter1* tmpDxgiAdapter = nullptr; + + std::wstring_convert> converter; + + UINT adapterIndex = 0; + while (dxgiFactory->EnumAdapters1(adapterIndex, &tmpDxgiAdapter) != DXGI_ERROR_NOT_FOUND) + { + DXGI_ADAPTER_DESC1 desc; + tmpDxgiAdapter->GetDesc1(&desc); + if (desc.Flags == 0) + { + std::cout << "Found adapter: " << converter.to_bytes(desc.Description) << std::endl; + std::cout << "Adapter LUID: luid_" + << "0x" << std::hex << std::setw(8) << std::setfill('0') << desc.AdapterLuid.HighPart << std::dec + << "_" + << "0x" << std::hex << std::uppercase << std::setw(8) << std::setfill('0') << desc.AdapterLuid.LowPart << std::dec + << "_phys_0" + << std::endl; + std::cout << "Adapter Shared Memory: " << static_cast(desc.SharedSystemMemory) / (1024 * 1024 * 1024) << " GB" << std::endl; + std::cout << "Adapter Dedicated Memory: " << static_cast(desc.DedicatedVideoMemory) / (1024 * 1024 * 1024) << " GB" << std::endl; + std::cout << std::endl; + } + tmpDxgiAdapter->Release(); + ++adapterIndex; + } + + dxgiFactory->Release(); +} + +// Run program: Ctrl + F5 or Debug > Start Without Debugging menu +// Debug program: F5 or Debug > Start Debugging menu + +// Tips for Getting Started: +// 1. Use the Solution Explorer window to add/manage files +// 2. Use the Team Explorer window to connect to source control +// 3. Use the Output window to see build output and other messages +// 4. Use the Error List window to view errors +// 5. Go to Project > Add New Item to create new code files, or Project > Add Existing Item to add existing code files to the project +// 6. In the future, to open this project again, go to File > Open > Project and select the .sln file