Files
fs2open.github.com/code/tracing/ProfilerOverlay.cpp
T
the-eandClaude Opus 5 b2de895055 Use dynamic uniform buffers to stop per-draw descriptor set churn in Vulkan
The Material and PerDraw descriptor sets were reallocated and fully rewritten on
essentially every draw call. model_draw_list::render_buffer rebinds ModelData with
a fresh uniform buffer offset for each queued draw, and that offset was part of the
Material set's memoization key, so the cache in VulkanDrawManager::applyMaterial
missed every time - taking a frame-pool allocation, a full template write, the
16-entry material texture resolution and a vkUpdateDescriptorSets with it.
GenericData/Matrices did the same to the PerDraw set. renderShadowDraw was worse
still: all three sets allocated and rewritten per shadow draw, with no memoization
at all.

A large asteroid field (Solaris m21) measured 2566 descriptor sets and 19107
descriptor writes per frame at 1606 model draws, and grew an extra descriptor pool
chunk every frame.

Declare the bindings whose offset moves per draw - MaterialBinding::ModelData,
MaterialBinding::ShadowMapData, PerDrawBinding::GenericData and
PerDrawBinding::Matrices - as eUniformBufferDynamic. DescriptorWriter::setBuffer
now splits a dynamic binding's offset out of the descriptor, writing
{buffer, 0, range} and stashing the offset for vkCmdBindDescriptorSets'
pDynamicOffsets, so an offset-only change leaves the set's contents identical and
the memoization caches start hitting. Shader sources are unchanged.

VulkanStateTracker::bindDescriptorSet now compares the dynamic offsets as well as
the set handle. This is required, not an optimization: the common case is now the
same set rebound with a new offset, and the old handle-only redundancy check would
have skipped that bind and fed every draw the previous draw's uniforms.

renderShadowDraw memoizes its three sets the way applyMaterial does. It does not
invalidate applyMaterial's caches - those record what a set contains, the shadow
pass allocates its own sets rather than overwriting theirs, and it binds through
the state tracker, so the next material cache hit still rebinds correctly.

Also switch allocateFrameSet to the non-allocating allocateDescriptorSets overload;
the vector-returning one heap-allocated once per set, thousands of times a frame.
MAX_SETS_PER_POOL stays at 1024 - the per-frame pool growth it used to log was a
symptom of the churn, and one chunk now covers the scene outright.

Measured on that mission in a Debug build, A/B on the same tree with and without
this change:

  descriptor sets/frame  @ ~1370 draws  2566   -> 250-345
  descriptor writes/frame                19107 -> ~1700-2300
  median frametime                       92.9/83.6 ms -> 72.0/66.5 ms
  Render Buffer (profiler overlay)       24.68 ms (30.8%) -> 12.73 ms (18.4%)
  Build Shadow Map                       5.44 ms -> off the top-5 entirely

Release builds already inlined most of this away and are unchanged, as expected.
Verified with the validation layers (sync validation enabled): no new VUIDs versus
the same build without the change.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-11-07 19:36:13 +01:00

266 lines
8.0 KiB
C++

//
//
#include "ProfilerOverlay.h"
#include "tracing.h"
#include "graphics/2d.h"
// ImPlot's Plot*() functions are templates that get instantiated here (unlike ImGui's ordinary
// function API), and their header-inline ImPool<ImPlotItem>::Add() uses memcpy on the
// non-trivially-copyable ImPlotItem -- which trips pstypes.h's memcpy-safety macro. That macro
// exists to catch accidental memcpy of non-POD engine types; ImPlotItem is third-party-internal
// and already safe, so suppress it for just these headers.
#pragma push_macro("memcpy")
#undef memcpy
#include "imgui.h"
#include "implot.h"
#pragma pop_macro("memcpy")
#include "backends/imgui_impl_sdl3.h"
#include <algorithm>
#include <numeric>
namespace tracing {
namespace {
constexpr size_t HISTORY_SIZE = 300; // ~5s at 60 FPS
constexpr double NANOSEC_PER_MS = 1'000'000.0;
SCP_vector<float> History_ms;
void push_history(float frame_ms) {
History_ms.push_back(frame_ms);
if (History_ms.size() > HISTORY_SIZE) {
History_ms.erase(History_ms.begin());
}
}
float history_average() {
if (History_ms.empty()) {
return 0.0f;
}
float sum = std::accumulate(History_ms.begin(), History_ms.end(), 0.0f);
return sum / static_cast<float>(History_ms.size());
}
float history_median() {
if (History_ms.empty()) {
return 0.0f;
}
SCP_vector<float> sorted_copy(History_ms.begin(), History_ms.end());
size_t mid = sorted_copy.size() / 2;
std::nth_element(sorted_copy.begin(), sorted_copy.begin() + mid, sorted_copy.end());
float median = sorted_copy[mid];
if (sorted_copy.size() % 2 == 0) {
std::nth_element(sorted_copy.begin(), sorted_copy.begin() + mid - 1, sorted_copy.end());
median = (median + sorted_copy[mid - 1]) * 0.5f;
}
return median;
}
void draw_frametime_graph() {
if (History_ms.empty()) {
return;
}
if (ImPlot::BeginPlot("##frametime_ms",
ImVec2(-1, 90),
ImPlotFlags_NoMouseText | ImPlotFlags_NoLegend | ImPlotFlags_NoTitle)) {
ImPlot::SetupAxes(nullptr,
"ms",
ImPlotAxisFlags_NoTickLabels | ImPlotAxisFlags_NoGridLines,
ImPlotAxisFlags_AutoFit);
ImPlot::SetupAxisLimits(ImAxis_X1, 0, static_cast<double>(History_ms.size()), ImPlotCond_Always);
ImPlot::PlotLine("frametime", History_ms.data(), static_cast<int>(History_ms.size()));
ImPlot::EndPlot();
}
}
/**
* Maps a category name to a stable color, so a given category keeps the same color across frames
* regardless of how its rank shifts in the breakdown. Uses an FNV-1a hash of the name to pick a
* hue; "Other" gets a fixed neutral grey (see draw_frame_budget_bar).
*/
ImU32 color_for_name(const char* name) {
uint32_t hash = 2166136261u; // FNV-1a
for (const char* p = name; *p != '\0'; ++p) {
hash ^= static_cast<uint8_t>(*p);
hash *= 16777619u;
}
const float hue = static_cast<float>(hash % 360) / 360.0f;
return ImGui::ColorConvertFloat4ToU32(static_cast<ImVec4>(ImColor::HSV(hue, 0.55f, 0.95f)));
}
/**
* Draws a single 100%-stacked horizontal "frame budget" bar: one row whose width is split into
* segments proportional to each category's share of the frame's traced time (top 5 contributors +
* "Other"), followed by a legend with matching swatches, absolute ms and percentage. Cheaper than a
* pie chart -- it's a handful of ImDrawList rectangles with no ImPlot plot setup.
*/
void draw_frame_budget_bar(const frame_overlay_snapshot& snapshot) {
if (snapshot.total_nanosec == 0) {
return;
}
constexpr int MAX_SEGMENTS = 6; // top 5 contributors + "Other"
constexpr ImU32 OTHER_COLOR = IM_COL32(130, 130, 130, 255);
struct segment {
const char* name;
double pct;
float ms;
ImU32 color;
};
segment segments[MAX_SEGMENTS];
int count = 0;
const auto total = static_cast<double>(snapshot.total_nanosec);
auto add_segment = [&](const char* name, uint64_t self_nanosec, ImU32 color) {
segments[count].name = name;
segments[count].pct = 100.0 * static_cast<double>(self_nanosec) / total;
segments[count].ms = static_cast<float>(static_cast<double>(self_nanosec) / NANOSEC_PER_MS);
segments[count].color = color;
count++;
};
for (const auto& contributor : snapshot.top_contributors) {
if (count >= MAX_SEGMENTS) {
break;
}
add_segment(contributor.name.c_str(), contributor.self_nanosec, color_for_name(contributor.name.c_str()));
}
if (snapshot.other_nanosec > 0 && count < MAX_SEGMENTS) {
add_segment("Other", snapshot.other_nanosec, OTHER_COLOR);
}
if (count == 0) {
return;
}
// The stacked bar.
ImDrawList* draw_list = ImGui::GetWindowDrawList();
const ImVec2 origin = ImGui::GetCursorScreenPos();
const float width = ImGui::GetContentRegionAvail().x;
constexpr float height = 22.0f;
draw_list->AddRectFilled(origin, ImVec2(origin.x + width, origin.y + height), IM_COL32(35, 35, 35, 255), 3.0f);
float x = origin.x;
for (int i = 0; i < count; i++) {
float seg_w = width * static_cast<float>(segments[i].pct / 100.0);
// Make sure the final segment reaches the right edge despite float rounding.
const float x_end = (i == count - 1) ? (origin.x + width) : (x + seg_w);
draw_list->AddRectFilled(ImVec2(x, origin.y), ImVec2(x_end, origin.y + height), segments[i].color);
x = x_end;
}
draw_list->AddRect(origin, ImVec2(origin.x + width, origin.y + height), IM_COL32(80, 80, 80, 255), 3.0f);
ImGui::Dummy(ImVec2(width, height));
// The legend.
for (int i = 0; i < count; i++) {
ImGui::ColorButton(segments[i].name,
ImGui::ColorConvertU32ToFloat4(segments[i].color),
ImGuiColorEditFlags_NoTooltip | ImGuiColorEditFlags_NoBorder,
ImVec2(12, 12));
ImGui::SameLine();
ImGui::Text("%s: %.2f ms (%.1f%%)", segments[i].name, segments[i].ms, segments[i].pct);
}
}
/**
* Draws whichever -gr_debug stat groups the active graphics backend collects (see
* gr_get_debug_stats()). Silently omits a group if its "valid" flag is unset, so this stays a
* no-op for backends (or builds) that don't populate a given group.
*/
void draw_graphics_debug_stats() {
gr_debug_stats stats = gr_get_debug_stats();
if (!stats.uniform_buffer_valid && !stats.draw_stats_valid) {
return;
}
ImGui::Separator();
ImGui::TextUnformatted("Graphics API stats (-gr_debug)");
if (stats.uniform_buffer_valid) {
ImGui::Text("Uniform buffer: " SIZE_T_ARG " / " SIZE_T_ARG " bytes used",
stats.uniform_buffer_used,
stats.uniform_buffer_size);
}
if (stats.draw_stats_valid) {
ImGui::Text("Draw calls: %d (%d indexed)", stats.draw_calls, stats.draw_indexed_calls);
ImGui::Text("Vertices: %d Indices: %d", stats.total_vertices, stats.total_indices);
ImGui::Text("Material applies: %d (%d failed, %d no pipeline)",
stats.apply_material_calls,
stats.apply_material_failures,
stats.no_pipeline_skips);
ImGui::Text("Descriptor sets: %d writes: %d pipelines: " SIZE_T_ARG,
stats.descriptor_sets_allocated,
stats.descriptor_writes,
stats.pipeline_count);
if (stats.on_demand_texture_uploads > 0) {
ImGui::Text("On-demand texture uploads: %d", stats.on_demand_texture_uploads);
}
}
}
} // namespace
void profiler_overlay_record_frame() {
const frame_overlay_snapshot& snapshot = get_frame_profiler_overlay_snapshot();
if (!snapshot.valid) {
return;
}
push_history(static_cast<float>(static_cast<double>(snapshot.total_nanosec) / NANOSEC_PER_MS));
}
void profiler_overlay_draw() {
gr_imgui_new_frame();
ImGui_ImplSDL3_NewFrame();
ImGui::NewFrame();
ImGui::SetNextWindowPos(ImVec2(20, 20), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(380, 480), ImGuiCond_FirstUseEver);
ImGui::Begin("Frame Profiler", nullptr, ImGuiWindowFlags_NoFocusOnAppearing);
if (!frame_profiling_active() || History_ms.empty()) {
ImGui::TextUnformatted("Collecting data...");
} else {
float avg_ms = history_average();
float median_ms = history_median();
ImGui::Text("Avg: %.2f ms (%.0f FPS) Median: %.2f ms",
avg_ms,
avg_ms > 0.0f ? 1000.0f / avg_ms : 0.0f,
median_ms);
draw_frametime_graph();
ImGui::Separator();
draw_frame_budget_bar(get_frame_profiler_overlay_snapshot());
}
draw_graphics_debug_stats();
ImGui::End();
ImGui::Render();
gr_imgui_render_draw_data();
}
} // namespace tracing