diff --git a/src/bonsaiviewer/MainWindow.cpp b/src/bonsaiviewer/MainWindow.cpp index e7d00480b9..7bcda0dc47 100644 --- a/src/bonsaiviewer/MainWindow.cpp +++ b/src/bonsaiviewer/MainWindow.cpp @@ -555,15 +555,25 @@ void MainWindow::setupLoader() { connect(viewport_widget_->viewport(), &ViewportWindow::frameStatsUpdated, this, [this](const ViewportWindow::FrameStats& stats) { if (!status_perf_label_->isVisible()) return; - status_perf_label_->setText( - QString("%1 fps | %2 ms | %3/%4 obj | %5/%6 tri | %7 draws") + const double mb = 1.0 / (1024.0 * 1024.0); + QString text = + QString("%1 fps | %2 ms | %3/%4 obj | %5/%6 tri | %7 draws | VRAM %8/%9 MB") .arg(stats.fps, 0, 'f', 1) .arg(stats.frame_time_ms, 0, 'f', 1) .arg(stats.visible_objects) .arg(stats.total_objects) .arg(stats.visible_triangles) .arg(stats.total_triangles) - .arg(stats.gl_draw_calls)); + .arg(stats.gl_draw_calls) + .arg(double(stats.vram_used_bytes) * mb, 0, 'f', 0) + .arg(double(stats.vram_capacity_bytes) * mb, 0, 'f', 0); + // Device total is only known when a driver backend answered. + if (stats.device_vram_total_bytes > 0) { + text += QString(" | Device %1/%2 MB") + .arg(double(stats.device_vram_used_bytes) * mb, 0, 'f', 0) + .arg(double(stats.device_vram_total_bytes) * mb, 0, 'f', 0); + } + status_perf_label_->setText(text); }); connect(viewport_widget_->viewport(), &ViewportWindow::objectPicked, this, [this](uint32_t object_id) { diff --git a/src/ifcviewer/CMakeLists.txt b/src/ifcviewer/CMakeLists.txt index 897f19d35a..c8e511e50f 100644 --- a/src/ifcviewer/CMakeLists.txt +++ b/src/ifcviewer/CMakeLists.txt @@ -145,6 +145,7 @@ set(IFCVIEWER_CORE_SOURCES InstanceCompose.cpp LodBuilder.cpp FederationMath.cpp + GpuMemory.cpp SidecarCache.cpp SidecarCompress.cpp StreamingLoader.cpp diff --git a/src/ifcviewer/FrameStats.h b/src/ifcviewer/FrameStats.h index 40eb7766a5..938538b4a2 100644 --- a/src/ifcviewer/FrameStats.h +++ b/src/ifcviewer/FrameStats.h @@ -38,6 +38,16 @@ struct FrameStats { std::uint32_t unique_meshes; std::uint32_t gl_draw_calls; // wgpu draw-call count; name kept for bonsai parity std::uint32_t indirect_sub_draws; // sub-draws packed into the chunk-indirect lists + // Chunk geometry pool occupancy (see BufferPool). wgpu exposes no + // adapter-wide VRAM query, so this is the viewer's own allocation, + // not the device total. + std::uint64_t vram_used_bytes; + std::uint64_t vram_capacity_bytes; + // Whole-device VRAM from the driver (NVML / sysfs, see GpuMemory.h). + // Desktop only; zero on web or when no backend could answer, so + // consumers must treat 0 as "unknown" rather than as empty. + std::uint64_t device_vram_used_bytes; + std::uint64_t device_vram_total_bytes; }; #endif // IFCVIEWER_FRAMESTATS_H diff --git a/src/ifcviewer/GpuMemory.cpp b/src/ifcviewer/GpuMemory.cpp new file mode 100644 index 0000000000..dc99ecc44d --- /dev/null +++ b/src/ifcviewer/GpuMemory.cpp @@ -0,0 +1,201 @@ +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + + +#include "GpuMemory.h" + +#include +#include +#include + +#if defined(__linux__) +#include +#include +#endif + +namespace ifcviewer { +namespace { + +#if defined(__linux__) + +// NVML, loaded at run time rather than linked: the viewer must run on machines +// with no NVIDIA driver at all, so a link-time dependency is not an option. +// Only the four entry points needed here are resolved. +struct Nvml { + void* handle = nullptr; + int (*init)() = nullptr; + int (*shutdown)() = nullptr; + int (*device_count)(unsigned*) = nullptr; + int (*handle_by_index)(unsigned, void**) = nullptr; + int (*pci_info)(void*, void*) = nullptr; + int (*memory_info)(void*, unsigned long long*) = nullptr; + + bool load() { + // .so.1 first: the unversioned name is part of the -dev package and is + // frequently absent on user machines. + for (const char* name : {"libnvidia-ml.so.1", "libnvidia-ml.so"}) { + handle = dlopen(name, RTLD_LAZY | RTLD_LOCAL); + if (handle) break; + } + if (!handle) return false; + auto sym = [&](const char* n) { return dlsym(handle, n); }; + init = (int (*)())sym("nvmlInit_v2"); + shutdown = (int (*)())sym("nvmlShutdown"); + device_count = (int (*)(unsigned*))sym("nvmlDeviceGetCount_v2"); + handle_by_index = (int (*)(unsigned, void**))sym("nvmlDeviceGetHandleByIndex_v2"); + pci_info = (int (*)(void*, void*))sym("nvmlDeviceGetPciInfo_v3"); + memory_info = (int (*)(void*, unsigned long long*))sym("nvmlDeviceGetMemoryInfo"); + return init && shutdown && device_count && handle_by_index && memory_info; + } + ~Nvml() { if (handle) dlclose(handle); } +}; + +// nvmlPciInfo_t. Only pciDeviceId is read; the leading char arrays are sized +// from the NVML headers so the offset is right. +struct NvmlPciInfo { + char busIdLegacy[16]; + unsigned domain; + unsigned bus; + unsigned device; + unsigned pciDeviceId; // (device_id << 16) | vendor_id + unsigned pciSubSystemId; + char busId[32]; +}; + +bool queryNvml(std::uint32_t vendor_id, std::uint32_t device_id, GpuMemoryInfo& out) { + Nvml nvml; + if (!nvml.load()) return false; + if (nvml.init() != 0) return false; + + unsigned count = 0; + bool found = false; + if (nvml.device_count(&count) == 0) { + for (unsigned i = 0; i < count && !found; ++i) { + void* dev = nullptr; + if (nvml.handle_by_index(i, &dev) != 0 || !dev) continue; + + // Match the card wgpu picked. With a single NVIDIA device and no + // way to read its ids, fall through to it rather than reporting + // nothing -- a slightly uncertain number beats none. + if (nvml.pci_info && (vendor_id || device_id)) { + NvmlPciInfo pci{}; + if (nvml.pci_info(dev, &pci) == 0) { + const unsigned dev_id = (pci.pciDeviceId >> 16) & 0xFFFF; + const unsigned ven_id = pci.pciDeviceId & 0xFFFF; + if (device_id && dev_id != device_id) continue; + if (vendor_id && ven_id != vendor_id) continue; + } + } else if (count != 1) { + continue; + } + + // nvmlMemory_t: { total, free, used }, all unsigned long long. + // + // This reports more `used` than nvidia-smi does -- measured here, + // 1427 MiB against 1046 MiB, consistently -- because the v1 call + // folds driver-reserved memory into `used` where nvidia-smi + // accounts for it separately. The larger figure is the one worth + // having: what actually allocated on this card topped out around + // 2431 MB, against 2669 MB free by this measure and 3050 MB by + // nvidia-smi's. Budgeting against the optimistic number would + // promise memory that is not there. + unsigned long long mem[3] = {0, 0, 0}; + if (nvml.memory_info(dev, mem) == 0 && mem[0] > 0) { + out.total_bytes = mem[0]; + out.used_bytes = mem[2]; + out.valid = true; + found = true; + } + } + } + nvml.shutdown(); + return found; +} + +// amdgpu and i915 expose VRAM through sysfs. Reads every card and keeps the +// one whose vendor/device ids match, because the first card is often the +// integrated GPU rather than the one in use. +bool readUint64(const std::string& path, std::uint64_t& out) { + FILE* f = std::fopen(path.c_str(), "r"); + if (!f) return false; + unsigned long long v = 0; + const bool ok = std::fscanf(f, "%llu", &v) == 1; + std::fclose(f); + if (ok) out = v; + return ok; +} + +bool readHexId(const std::string& path, std::uint32_t& out) { + FILE* f = std::fopen(path.c_str(), "r"); + if (!f) return false; + unsigned v = 0; + const bool ok = std::fscanf(f, "0x%x", &v) == 1; + std::fclose(f); + if (ok) out = v; + return ok; +} + +bool querySysfs(std::uint32_t vendor_id, std::uint32_t device_id, GpuMemoryInfo& out) { + DIR* dir = opendir("/sys/class/drm"); + if (!dir) return false; + bool found = false; + while (dirent* entry = readdir(dir)) { + const std::string name = entry->d_name; + // "card0", not "card0-DP-1". + if (name.rfind("card", 0) != 0 || name.find('-') != std::string::npos) continue; + const std::string base = "/sys/class/drm/" + name + "/device/"; + + std::uint32_t ven = 0, dev = 0; + if (vendor_id && readHexId(base + "vendor", ven) && ven != vendor_id) continue; + if (device_id && readHexId(base + "device", dev) && dev != device_id) continue; + + std::uint64_t total = 0, used = 0; + if (readUint64(base + "mem_info_vram_total", total) && total > 0) { + readUint64(base + "mem_info_vram_used", used); + out.total_bytes = total; + out.used_bytes = used; + out.valid = true; + found = true; + break; + } + } + closedir(dir); + return found; +} + +#endif // __linux__ + +} // namespace + +GpuMemoryInfo queryGpuMemory(std::uint32_t vendor_id, std::uint32_t device_id) { + GpuMemoryInfo info; +#if defined(__linux__) + if (queryNvml(vendor_id, device_id, info)) return info; + if (querySysfs(vendor_id, device_id, info)) return info; +#else + // Windows (DXGI QueryVideoMemoryInfo) and macOS + // (recommendedMaxWorkingSetSize) both expose this; not implemented here + // because neither can be verified from this machine. `valid` stays false, + // and callers fall back to behaving as they did before. + (void)vendor_id; (void)device_id; +#endif + return info; +} + +} // namespace ifcviewer diff --git a/src/ifcviewer/GpuMemory.h b/src/ifcviewer/GpuMemory.h new file mode 100644 index 0000000000..b26f65942c --- /dev/null +++ b/src/ifcviewer/GpuMemory.h @@ -0,0 +1,62 @@ +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + + +#ifndef GPUMEMORY_H +#define GPUMEMORY_H + +#include + +// How much video memory the card has, and how much of it is in use. +// +// WebGPU deliberately exposes neither -- `maxBufferSize` reports 1 TB on this +// stack and is useless as a proxy -- so this goes outside the graphics API. +// That is legitimate on desktop, where the viewer is a native app, and it is +// the number every other part of the residency story needs: a gauge to show +// the user, a budget to keep the pool under, and a pre-flight check that can +// refuse a model before it wedges the session. +// +// Matching the right GPU matters. On a laptop with switchable graphics the +// obvious sysfs entry is often the *integrated* chip rather than the one wgpu +// selected -- measured here: /sys/class/drm/card1 reports a 512 MB AMD iGPU +// while wgpu is running on a 4 GB GeForce. So the query takes the vendor and +// device ids from WGPUAdapterInfo and matches on them. +namespace ifcviewer { + +struct GpuMemoryInfo { + std::uint64_t total_bytes = 0; + std::uint64_t used_bytes = 0; + // False when no backend could answer -- an unknown driver, a platform + // without a query, or a device the probe could not match. Callers must + // treat that as "unknown" and not as "zero": refusing to load because an + // unavailable query returned 0 would be worse than not asking. + bool valid = false; + + std::uint64_t free_bytes() const { + return total_bytes > used_bytes ? total_bytes - used_bytes : 0; + } +}; + +// Query the GPU wgpu selected. `vendor_id` / `device_id` come from +// wgpuAdapterGetInfo. Cheap enough to call once a second; not per frame. +GpuMemoryInfo queryGpuMemory(std::uint32_t vendor_id, std::uint32_t device_id); + +} // namespace ifcviewer + +#endif diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index f7c01f3f7a..838212a3db 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -28,6 +28,7 @@ #endif #include "CameraMath.h" +#include "GpuMemory.h" #include "InstanceCompose.h" #include "Log.h" @@ -7735,6 +7736,25 @@ void ViewportCore::render() { } stats.gl_draw_calls = draw_calls; stats.indirect_sub_draws = last_sub_draws_; + stats.vram_used_bytes = pool_.total_used_bytes(); + stats.vram_capacity_bytes = pool_.total_capacity_bytes(); +#if !defined(__EMSCRIPTEN__) + if (!device_vram_poll_timer_.isValid() + || device_vram_poll_timer_.elapsed() >= 1000) { + device_vram_poll_timer_.start(); + WGPUAdapterInfo adapter_info = WGPU_ADAPTER_INFO_INIT; + wgpuAdapterGetInfo(adapter_, &adapter_info); + const ifcviewer::GpuMemoryInfo mem = + ifcviewer::queryGpuMemory(adapter_info.vendorID, adapter_info.deviceID); + wgpuAdapterInfoFreeMembers(adapter_info); + if (mem.valid) { + device_vram_used_bytes_ = mem.used_bytes; + device_vram_total_bytes_ = mem.total_bytes; + } + } +#endif + stats.device_vram_used_bytes = device_vram_used_bytes_; + stats.device_vram_total_bytes = device_vram_total_bytes_; host_->onFrameStats(stats); } diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 72ff28ce16..0e4c692f22 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -1485,6 +1485,12 @@ private: std::uint32_t last_visible_objects_ = 0; std::uint32_t last_visible_triangles_ = 0; std::uint32_t last_sub_draws_ = 0; + // Device-wide VRAM readout for FrameStats. The driver query is too + // slow for per-frame use, so it is re-polled at most once a second + // and the last answer is repeated in between. + std::uint64_t device_vram_used_bytes_ = 0; + std::uint64_t device_vram_total_bytes_ = 0; + Stopwatch device_vram_poll_timer_; double last_cull_ms_ = 0.0; double last_cull_compute_ms_ = 0.0; double last_cull_upload_ms_ = 0.0;