Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/WINDOWS_INTEL_MONITOR.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Intel GPU monitoring on Windows via IGCL

The SYCL server can read Intel GPU telemetry from an optional native helper.
Build and install the helper explicitly; inference does not require it.
The helper loads the driver's ControlLib.dll from the Windows system directory.

Obtain `igcl_api.h` from Intel's
[GPU Control Library](https://github.com/intel/drivers.gpu.control-library).
The tested header revision is `b6c462933502e13d1537dd5024949a51be30e63d`.
From an x64 Visual Studio developer PowerShell:

```powershell
./sycl/tools/build-windows-telemetry.ps1 -IgclIncludeDir C:/SDK/igcl/include
./build-sycl-windows/strata-intel-telemetry.exe --samples 3
```

The SYCL server discovers the helper in `build-sycl-windows`, or at the path
specified by `STRATA_INTEL_TELEMETRY_EXE`. `STRATA_INTEL_PCI_BDF` selects an
adapter by decimal bus:device.function, for example `3:0.0`.

Readings include GPU activity, device memory allocation, GPU and memory
temperature, GPU chip power, and PCIe generation/width where supported.
Chip power is derived from energy deltas and is not total board power.
PCIe link information is not traffic throughput. Unsupported fields stay empty.
Missing, exited or stale helpers do not block inference. Restart the server
after resolving a failed helper; automatic helper restart is not implemented.

Validated on Windows 10 with one Intel Arc Pro B70: real API readings under
inference load, collector unit tests, monitor tests and JavaScript label tests.
Observed peak readings included 100% activity, 119 W chip power and 64 C GPU
temperature. Helper cleanup after parent exit was also checked.
Windows 11 and multi-GPU mapping remain untested. Linux behavior has not been
revalidated by execution. The shared CUDA/HIP server is not modified.
137 changes: 137 additions & 0 deletions sycl/probe/windows_telemetry.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Read-only IGCL sampler. Driver calls live outside the API server process.
// Build with Intel's igcl_api.h; ControlLib.dll comes from the graphics driver.
#include <windows.h>
#define CTL_APIEXPORT
#include "igcl_api.h"
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <iomanip>
#include <iostream>
#include <sstream>
#include <string>
#include <vector>

static std::string quote(const char* text) {
std::ostringstream out;
out << '"';
for (const unsigned char* p = reinterpret_cast<const unsigned char*>(text); *p; ++p) {
if (*p == '"' || *p == '\\') out << '\\' << *p;
else if (*p < 32) out << "\\u" << std::hex << std::setw(4) << std::setfill('0') << unsigned(*p);
else out << *p;
}
return out.str() + '"';
}

static void sensor(std::ostream& out, const char* key, const ctl_oc_telemetry_item_t& item, ctl_units_t units) {
if (!item.bSupported || item.units != units) return;
double v;
switch (item.type) {
case CTL_DATA_TYPE_DOUBLE: v = item.value.datadouble; break;
case CTL_DATA_TYPE_FLOAT: v = item.value.datafloat; break;
case CTL_DATA_TYPE_UINT64: v = static_cast<double>(item.value.datau64); break;
case CTL_DATA_TYPE_INT64: v = static_cast<double>(item.value.data64); break;
case CTL_DATA_TYPE_UINT32: v = item.value.datau32; break;
case CTL_DATA_TYPE_INT32: v = item.value.data32; break;
default: return;
}
if (std::isfinite(v) && v >= 0) out << ',' << quote(key) << ':' << std::setprecision(16) << v;
}

int main(int argc, char** argv) {
unsigned index = 0, samples = 0;
DWORD parent_pid = 0;
std::string bdf;
for (int i = 1; i < argc; ++i) {
if (i + 1 >= argc) return 2;
std::string arg = argv[i++];
if (arg == "--index") index = std::stoul(argv[i]);
else if (arg == "--parent") parent_pid = std::stoul(argv[i]);
else if (arg == "--samples") samples = std::stoul(argv[i]);
else if (arg == "--pci-bdf") bdf = argv[i];
else return 2;
}
HANDLE parent = parent_pid ? OpenProcess(SYNCHRONIZE, FALSE, parent_pid) : nullptr;
if (parent_pid && !parent) return 3;
auto lib = LoadLibraryExW(L"ControlLib.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32);
if (!lib) { std::cerr << "Intel ControlLib.dll unavailable\n"; return 4; }
#define FN(name) auto name = reinterpret_cast<decltype(&::name)>(GetProcAddress(lib, #name))
FN(ctlInit); FN(ctlEnumerateDevices); FN(ctlGetDeviceProperties); FN(ctlClose);
FN(ctlPowerTelemetryGet); FN(ctlEnumMemoryModules); FN(ctlMemoryGetProperties); FN(ctlMemoryGetState);
FN(ctlPciGetProperties); FN(ctlPciGetState);
#undef FN
if (!ctlInit || !ctlEnumerateDevices || !ctlGetDeviceProperties) return 5;
ctl_init_args_t args{};
args.Size = sizeof(args); args.AppVersion = CTL_IMPL_VERSION; args.flags = CTL_INIT_FLAG_USE_LEVEL_ZERO;
ctl_api_handle_t api{};
if (ctlInit(&args, &api) != CTL_RESULT_SUCCESS) return 6;
uint32_t count = 0;
if (ctlEnumerateDevices(api, &count, nullptr) || !count) return 7;
std::vector<ctl_device_adapter_handle_t> devices(count);
if (ctlEnumerateDevices(api, &count, devices.data())) return 7;
ctl_device_adapter_handle_t device = nullptr;
std::string name, address;
unsigned matched = 0;
for (auto candidate : devices) {
ctl_device_adapter_properties_t p{};
p.Size = sizeof(p); p.Version = 2;
LUID luid{}; p.pDeviceID = &luid; p.device_id_size = sizeof(luid);
if (ctlGetDeviceProperties(candidate, &p) || p.pci_vendor_id != 0x8086 || p.device_type != CTL_DEVICE_TYPE_GRAPHICS) continue;
std::string candidate_bdf = std::to_string(p.adapter_bdf.bus) + ":" + std::to_string(p.adapter_bdf.device) + "." + std::to_string(p.adapter_bdf.function);
if ((!bdf.empty() && bdf == candidate_bdf) || (bdf.empty() && matched == index)) {
device = candidate; p.name[sizeof(p.name) - 1] = '\0'; name = p.name; address = candidate_bdf; break;
}
++matched;
}
if (!device) { std::cerr << "Requested Intel adapter unavailable\n"; return 8; }
std::vector<ctl_mem_handle_t> memories;
if (ctlEnumMemoryModules && ctlMemoryGetProperties && ctlMemoryGetState) {
uint32_t n = 0;
if (!ctlEnumMemoryModules(device, &n, nullptr) && n) {
std::vector<ctl_mem_handle_t> all(n);
if (!ctlEnumMemoryModules(device, &n, all.data())) for (auto mem : all) {
ctl_mem_properties_t p{}; p.Size = sizeof(p);
if (!ctlMemoryGetProperties(mem, &p) && p.location == CTL_MEM_LOC_DEVICE) memories.push_back(mem);
}
}
}
unsigned emitted = 0;
do {
if (parent && WaitForSingleObject(parent, 0) != WAIT_TIMEOUT) break;
std::ostringstream out;
out << "{\"name\":" << quote(name.c_str()) << ",\"pci_bdf\":" << quote(address.c_str());
ctl_power_telemetry_t t{}; t.Size = sizeof(t);
if (ctlPowerTelemetryGet && !ctlPowerTelemetryGet(device, &t)) {
sensor(out, "timestamp", t.timeStamp, CTL_UNITS_TIME_SECONDS);
sensor(out, "energy", t.gpuEnergyCounter, CTL_UNITS_ENERGY_JOULES);
sensor(out, "activity", t.globalActivityCounter, CTL_UNITS_TIME_SECONDS);
sensor(out, "temp", t.gpuCurrentTemperature, CTL_UNITS_TEMPERATURE_CELSIUS);
sensor(out, "vram_temp", t.vramCurrentTemperature, CTL_UNITS_TEMPERATURE_CELSIUS);
}
uint64_t total = 0, free = 0;
bool valid = !memories.empty();
for (auto mem : memories) {
ctl_mem_state_t s{}; s.Size = sizeof(s);
if (ctlMemoryGetState(mem, &s) || !s.size || s.free > s.size) { valid = false; break; }
total += s.size; free += s.free;
}
if (valid) out << ",\"mem_total\":" << total << ",\"mem_used\":" << total - free;
ctl_pci_properties_t p{}; p.Size = sizeof(p);
p.address.Size = sizeof(p.address); p.maxSpeed.Size = sizeof(p.maxSpeed);
if (ctlPciGetProperties && !ctlPciGetProperties(device, &p) && p.maxSpeed.gen > 0)
out << ",\"pcie_gen_max\":" << p.maxSpeed.gen;
ctl_pci_state_t state{}; state.Size = sizeof(state); state.speed.Size = sizeof(state.speed);
if (ctlPciGetState && !ctlPciGetState(device, &state)) {
if (state.speed.gen > 0) out << ",\"pcie_gen\":" << state.speed.gen;
if (state.speed.width > 0) out << ",\"pcie_width\":" << state.speed.width;
}
std::cout << out.str() << "}\n" << std::flush;
if (!std::cout || (samples && ++emitted >= samples)) break;
if (parent) { if (WaitForSingleObject(parent, 1000) != WAIT_TIMEOUT) break; }
else Sleep(1000);
} while (true);
if (ctlClose) ctlClose(api);
if (parent) CloseHandle(parent);
FreeLibrary(lib);
return 0;
}
44 changes: 40 additions & 4 deletions sycl/serve/server_intel.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
Every argument is serve/server.py's. Nothing in serve/ is edited (the SYCL port keeps out of the shared files so
upstream merges stay clean); this wrapper adds, at run time:

- the Monitor tab's GPU readings on an Intel Arc (sycl/serve/xe_telemetry.py) (also on a PC with an NVIDIA card: the engine runs on the Intel one);
- Intel GPU readings: IGCL on Windows, xe on Linux, preferring the Intel card even with NVIDIA present;
- a model menu in the web app's header, for a host that swaps models on one GPU: the config names the RPC that
does the swap as "model_switcher" (it takes {"mode": m} and answers {"mode", "up", "starting", "choices",
"urls"}); the menu offers the models served on this server's port. Without that key nothing changes;
Expand Down Expand Up @@ -35,17 +35,52 @@


def install_xe_reader():
"""telemetry.gpu_reader -> the xe reader whenever an Intel Arc is there. #2001: this server runs the SYCL engine,
so the cards it reports are the Intel ones; before, an NVIDIA card in the same PC answered first and the Monitor
(and the benchmark report, which takes the card's name from it) showed the GeForce the engine never used."""
"""Report the Intel card used by SYCL, with native Windows IGCL or Linux xe."""
orig = T.gpu_reader

def gpu_reader(index=0, amd=False):
if sys.platform == "win32" and not amd:
from windows_telemetry import _WindowsIntelGpu
return _WindowsIntelGpu(index)
xe = _XeGpu(index)
return xe if xe.ok() else orig(index, amd)

T.gpu_reader = gpu_reader

class IntelTelemetry(T.Telemetry):
def close(self):
super().close()
for _, reader in self.gpus:
close = getattr(reader, "close", None)
if close:
close()

T.Telemetry = IntelTelemetry


def install_windows_monitor():
"""Explain IGCL power scope and current PCIe link in the Windows Monitor."""
if sys.platform != "win32":
return
original = S.make_handler

def make(svc):
class Handler(original(svc)):
def do_GET(self):
if self.path.split("?")[0] == "/web/app.js":
body = (ROOT / "serve/web/app.js").read_bytes() + b"\n" + (HERE / "web/windows-monitor.js").read_bytes()
self.send_response(200)
self.send_header("Content-Type", "text/javascript; charset=utf-8")
self.send_header("Cache-Control", "no-cache")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
super().do_GET()
return Handler

S.make_handler = make


def model_switcher(url: str, port, model, mode: str = "") -> dict:
"""The host's model swapper: the models it serves on THIS port, which one holds the card, whether one is loading.
Expand Down Expand Up @@ -308,6 +343,7 @@ def top_logprobs(req: dict):

if __name__ == "__main__":
install_xe_reader()
install_windows_monitor()
install_narrator()
install_switcher(sys.argv[1:])
install_logprobs()
Expand Down
107 changes: 107 additions & 0 deletions sycl/serve/test_windows_telemetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import io
import json
from pathlib import Path
import sys
import time
import unittest
from unittest.mock import Mock, patch

sys.path.insert(0, str(Path(__file__).resolve().parent))
from windows_telemetry import _Rates, _WindowsIntelGpu


class RatesTest(unittest.TestCase):
def test_rates_and_idle(self):
rates = _Rates()
self.assertNotIn("power", rates.convert(dict(timestamp=10, energy=100, activity=2)))
sample = rates.convert(dict(timestamp=12, energy=180, activity=3))
self.assertEqual(sample["power"], 40)
self.assertEqual(sample["util"], 50)
sample = rates.convert(dict(timestamp=13, energy=180, activity=3))
self.assertEqual(sample["power"], 0)
self.assertEqual(sample["util"], 0)

def test_reset_missing_stale_and_nonfinite(self):
rates = _Rates()
for sample in [dict(timestamp=10, energy=100), dict(timestamp=11, energy=10),
dict(timestamp=11, energy=20), dict(timestamp=20, energy=30),
dict(timestamp=21, energy=float("nan")), dict(timestamp=22, energy=40),
dict(timestamp=23), dict(timestamp=24, energy=60)]:
self.assertNotIn("power", rates.convert(sample))

def test_bad_memory_and_sensors(self):
rates = _Rates()
sample = rates.convert(dict(mem_used=11, mem_total=10, temp=float("inf"), vram_temp=-1))
for key in ("mem_used", "mem_total", "temp", "vram_temp"):
self.assertNotIn(key, sample)
sample = rates.convert(dict(mem_used=0, mem_total=10, temp=0))
self.assertEqual(sample["mem_used"], 0)
self.assertEqual(sample["temp"], 0)


class ReaderTest(unittest.TestCase):
def reader(self):
with patch("windows_telemetry.sys.platform", "linux"):
reader = _WindowsIntelGpu()
self.addCleanup(reader.close)
return reader

def test_stale_dead_and_snapshot_copy(self):
reader = self.reader()
process = Mock()
process.poll.return_value = None
reader._process = process
reader._latest = {"temp": 55}
reader._received = time.monotonic()
self.assertEqual(reader.read(), {"temp": 55})
reader.read()["temp"] = 0
self.assertEqual(reader.read()["temp"], 55)
reader._received -= 6
self.assertEqual(reader.read(), {})
reader._received = time.monotonic()
process.poll.return_value = 1
self.assertEqual(reader.read(), {})

def test_consumer_ignores_bad_lines(self):
reader = self.reader()
process = Mock()
process.stdout = io.StringIO('broken\n[]\n' + json.dumps(dict(name="Arc", temp=50)) + '\n')
reader._process = process
reader._consume()
self.assertTrue(reader.ok())
self.assertEqual(reader.name(), "Arc")
self.assertEqual(reader._latest["temp"], 50)

def test_missing_helper(self):
with patch("windows_telemetry.sys.platform", "win32"), patch("windows_telemetry.subprocess.Popen", side_effect=OSError), patch("windows_telemetry.subprocess.CREATE_NO_WINDOW", 0, create=True):
reader = _WindowsIntelGpu()
self.assertFalse(reader.ok())
self.assertEqual(reader.read(), {})

def test_failed_startup_is_cleaned_up_and_bdf_forwarded(self):
process = Mock()
process.stdout = io.StringIO('')
process.poll.return_value = None
with patch("windows_telemetry.sys.platform", "win32"), patch("windows_telemetry.subprocess.Popen", return_value=process) as popen, patch("windows_telemetry.subprocess.CREATE_NO_WINDOW", 0, create=True), patch.dict("os.environ", {"STRATA_INTEL_PCI_BDF": "3:0.0"}):
reader = _WindowsIntelGpu(2)
self.assertFalse(reader.ok())
self.assertIsNone(reader._process)
process.terminate.assert_called_once()
command = popen.call_args.args[0]
self.assertEqual(command[command.index("--index") + 1], "2")
self.assertEqual(command[command.index("--pci-bdf") + 1], "3:0.0")

def test_close_terminates_child_and_is_repeatable(self):
reader = self.reader()
process = Mock()
process.poll.side_effect = [None, 0]
reader._process = process
reader.close()
process.terminate.assert_called_once()
process.wait.assert_called_once_with(timeout=2)
reader.close()
self.assertIsNone(reader._process)


if __name__ == "__main__":
unittest.main()
26 changes: 26 additions & 0 deletions sycl/serve/web/test-windows-monitor.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Run with node sycl/serve/web/test-windows-monitor.cjs.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const cards = {};
let calls = 0;
const scope = {renderMonitor: () => calls++, fmt: x => String(x),
setMetric: (key, value, unit, sub) => { cards[key] = {value, unit, sub}; }};
vm.createContext(scope);
vm.runInContext(fs.readFileSync(__dirname + '/windows-monitor.js', 'utf8'), scope);
scope.renderMonitor({}, {gpu_backend: 'igcl', gpu_power: 0, gpu_temp: 56, gpu_vram_temp: 58,
gpu_pcie_gen: 3, gpu_pcie_gen_max: 5, gpu_pcie_width: 16});
assert.equal(cards.power.value, '0');
assert.equal(cards.power.sub, 'GPU chip power (Intel)');
assert.equal(cards.temp.sub, 'VRAM 58 °C');
assert.equal(cards.pcie.value, 'Gen3');
assert.equal(cards.pcie.unit, 'x16');
assert.match(cards.pcie.sub, /Gen5.*Traffic unavailable/);
scope.renderMonitor({}, {gpu_backend: 'igcl'});
assert.equal(cards.power.value, null);
assert.equal(cards.pcie.value, null);
const before = JSON.stringify(cards);
scope.renderMonitor({}, {gpu_power: 120});
assert.equal(JSON.stringify(cards), before);
assert.equal(calls, 3);
console.log('Windows Monitor labels: passed');
12 changes: 12 additions & 0 deletions sycl/serve/web/windows-monitor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Loaded only by the Windows Intel wrapper; the shared web app is unchanged.
const renderIntelMonitorBase = renderMonitor;
renderMonitor = function(live, hw, st, eng, h, last, requests, totals, kept) {
renderIntelMonitorBase(live, hw, st, eng, h, last, requests, totals, kept);
if (hw.gpu_backend !== "igcl") return;
setMetric("power", hw.gpu_power == null ? null : fmt(hw.gpu_power), "W", "GPU chip power (Intel)");
setMetric("temp", hw.gpu_temp == null ? null : fmt(hw.gpu_temp), "°C",
hw.gpu_vram_temp == null ? "" : `VRAM ${fmt(hw.gpu_vram_temp)} °C`);
setMetric("pcie", hw.gpu_pcie_gen ? `Gen${hw.gpu_pcie_gen}` : null,
hw.gpu_pcie_width ? `x${hw.gpu_pcie_width}` : "",
(hw.gpu_pcie_gen_max ? `GPU supports Gen${hw.gpu_pcie_gen_max} · ` : "") + "Traffic unavailable");
};
Loading