fix(render): one fence per frame for the vertex ring; two qt6 adapter fixes

The persistent-mapped instance ring had three slots and recorded a fence per
upload, so a frame recycled a slot whose fence was recorded a few draws earlier
*in that same frame*: from the fourth of the demos' nine uploads on, each one
waited on the frame's own work. It is free on a driver that retires draws
promptly (the GLFW host, 1.07 ms/frame) and ~4 ms of the Qt widget's 7.11 ms
under llvmpipe, and it grows with the trace and cursor counts this library
exists for.

* The ring grows to cover a frame's upload count, the frame's write cursor is
  the ring index (so a frame writes 0, 1, 2, ... and never a slot twice), one
  fence is recorded after the frame's last draw, and the only wait is at the
  start of the next frame. A wait that times out, or a slot written with no
  fence ever recorded behind it, drops the store to the orphan path -- the
  treatment a refused fence already had -- so nothing is ever written into
  storage the GPU may still be reading.
* New test-only counters (PLOT_RING_STATS, the PLOT_PYRAMID_COUNT_* pattern:
  free functions, no member, so no consumer's layout changes) pin a frame's
  upload and wait counts. The new tests assert the frame's slot offsets are
  distinct, that the ring grew past its floor to cover the frame, and that a
  frame waits at most once per store -- read from the counters and from
  glClientWaitSync itself.
* qt6: print the unresolved entry-point names before the load-failure branch,
  which used to return first and leave the one failure this widget can have
  reporting nothing but a generic message.
* qt6: the cached pointer anchor is now in logical pixels and a move's delta is
  the logical difference scaled by the DPR in effect, so a scale change between
  two move events (the window dragged onto another monitor mid-drag) is no
  longer a cross-scale jump applied as a pan. The anchor moved into a
  toolkit-free unit (adapters/qt6/qt_pointer.hpp) with its own test.

GLFW demo headless: 1.07 -> 0.73 ms/frame. Qt widget under xvfb/llvmpipe:
7.1 -> 3.8 ms at dpr 1.0, 6.7 -> 3.6 ms at 1.25, with both upload paths still
rendering and the demo's scripted pan/box-zoom checks passing at both scales.
This commit is contained in:
Martino Ferrari committed 2026-09-28 01:38:09 +02:00
1 parent 1ee2dd9c3d
commit 3c0e7763ab
13 files changed
+626 -113

No files matched your search

+9 -1
View File
@@ -51,10 +51,18 @@ if(TARGET plot::imgui)
target_link_libraries(plot_tests PRIVATE plot::imgui imgui)
endif()
# The Qt adapter's pointer anchor is deliberately toolkit-free (see
# adapters/qt6/qt_pointer.hpp), so this one piece of that adapter's translation is
# tested without linking Qt -- and without `PLOT_BUILD_QT6`, since no Qt target has
# to exist for it to compile.
target_sources(plot_tests PRIVATE test_qt_pointer.cpp)
target_include_directories(plot_tests PRIVATE "${CMAKE_SOURCE_DIR}/adapters")
# Same switches the library is built with: the cost tests read the counters
# behind them (see include/plot/data/detail/{cover,merge}_count.hpp).
target_compile_definitions(plot_tests PRIVATE PLOT_PYRAMID_COUNT_COVERS
PLOT_PYRAMID_COUNT_MERGES)
PLOT_PYRAMID_COUNT_MERGES
PLOT_RING_STATS)
plot_apply_warnings(plot_tests)
add_test(NAME plot_tests COMMAND plot_tests)
+56
View File
@@ -0,0 +1,56 @@
// The Qt adapter's pointer translation, driven directly. The widget's cached
// pointer anchor is a plain value with no toolkit dependency (see
// adapters/qt6/qt_pointer.hpp) precisely so the one thing a screenshot cannot show
// -- which pixel space a drag delta is in -- is testable without a QApplication, a
// window or a display.
#include "harness.hpp"
#include <qt6/qt_pointer.hpp>
#include <cmath>
// The defect this pins: the anchor used to be cached in *device* pixels while the
// next event's delta was a raw difference of device positions, so a DPR change
// between two move events -- the window dragged onto a monitor with a different
// scale factor mid-drag -- mixed two pixel spaces. At 1.0 -> 1.25 a logical
// position of 500 produced a spurious 125 device-pixel delta, which
// `Controller::pan` applies as a fraction of the range span and which also clears
// auto scale: a scale change mid-drag would move the plot by 125 pixels' worth of
// data and silently disarm a live axis.
PLOT_TEST(pointer_anchor_survives_a_dpr_change_between_move_events) {
plot::qt::PointerAnchor anchor;
// The press caches a logical position while the window is on the 1.0 display.
anchor.set({500.0f, 300.0f});
// The next move arrives after the window moved to the 1.25 display, at the same
// logical position: the pointer has not moved, so the delta must be zero -- not
// 500 * 1.25 - 500 * 1.0 = 125 device pixels, which is what comparing two device
// positions across the scale change reported.
const plot::Vec2 still = anchor.device_delta({500.0f, 300.0f}, 1.25);
PLOT_CHECK_NEAR(still.x, 0.0, 1e-6);
PLOT_CHECK_NEAR(still.y, 0.0, 1e-6);
// A real movement is the logical delta scaled by the ratio in effect now, in both
// directions.
const plot::Vec2 moved = anchor.device_delta({503.0f, 295.0f}, 1.25);
PLOT_CHECK_NEAR(moved.x, 3.0 * 1.25, 1e-6);
PLOT_CHECK_NEAR(moved.y, -5.0 * 1.25, 1e-6);
// And the anchor follows the sample it just reported: the following move is
// measured from there, at whatever ratio that event carries.
anchor.set({503.0f, 295.0f});
const plot::Vec2 unchanged = anchor.device_delta({503.0f, 295.0f}, 1.0);
PLOT_CHECK_NEAR(unchanged.x, 0.0, 1e-6);
PLOT_CHECK_NEAR(unchanged.y, 0.0, 1e-6);
}
// The first sample of a gesture has no anchor to measure from, so its delta is
// zero however far the position is from the origin: the first motion of a drag
// must never include the jump to where the drag began.
PLOT_TEST(pointer_anchor_reports_no_movement_before_its_first_sample) {
plot::qt::PointerAnchor anchor;
const plot::Vec2 delta = anchor.device_delta({500.0f, 300.0f}, 1.25);
PLOT_CHECK_NEAR(delta.x, 0.0, 1e-9);
PLOT_CHECK_NEAR(delta.y, 0.0, 1e-9);
}
+199
View File
@@ -9,8 +9,11 @@
#include <plot/gl/api.hpp>
#include <plot/gl/loader.hpp>
#include <plot/data/array_series.hpp>
#include <plot/render/buffer.hpp>
#include <plot/render/detail/ring_stats.hpp>
#include <plot/render/renderer.hpp>
#include <atomic>
#include <cmath>
#include <cstdlib>
#include <vector>
@@ -151,6 +154,37 @@ class RefuseFenceSync {
decltype(plot::gl::glFenceSync) saved_;
};
/// Counts the calls the ring's fence waits make through `glClientWaitSync` -- the
/// one entry point every wait goes through, whatever the store's own bookkeeping
/// says. The test-only counters in <plot/render/detail/ring_stats.hpp> report how
/// often a store decides to wait; this reports what reached the driver, so the two
/// cannot agree by construction.
u64 g_fence_wait_calls = 0;
decltype(plot::gl::glClientWaitSync) g_fence_wait_saved = nullptr;
plot::gl::GLenum count_fence_wait(plot::gl::GLsync sync, plot::gl::GLbitfield flags,
plot::gl::GLuint64 timeout) {
++g_fence_wait_calls;
return g_fence_wait_saved(sync, flags, timeout);
}
class CountFenceWaits {
public:
CountFenceWaits() noexcept : saved_(plot::gl::glClientWaitSync) {
g_fence_wait_saved = saved_;
plot::gl::glClientWaitSync = &count_fence_wait;
}
~CountFenceWaits() { plot::gl::glClientWaitSync = saved_; }
CountFenceWaits(const CountFenceWaits&) = delete;
CountFenceWaits& operator=(const CountFenceWaits&) = delete;
/// Calls made since the counter was installed.
u64 calls() const noexcept { return g_fence_wait_calls; }
private:
decltype(plot::gl::glClientWaitSync) saved_;
};
} // namespace
PLOT_TEST(renders_visible_trace_across_viewport) {
@@ -714,3 +748,168 @@ PLOT_TEST(refused_fence_drops_the_mapped_ring) {
}
ctx.destroy();
}
/// The ring must absorb a frame that issues more uploads than it starts with
/// slots. The shape it replaced -- three slots and a fence per upload -- wrapped
/// within such a frame: from the fourth upload on, each one waited on a fence
/// recorded a few draws *earlier in the same frame*, i.e. an effective per-frame
/// GPU sync. That is free when the driver retires draws promptly (the GLFW host,
/// 1.07 ms a frame) and ~4 ms of the Qt widget's 7.11 ms under llvmpipe, which is
/// exactly the difference `PLOT_FORCE_GL33_UPLOAD=1` shows -- and it is a cliff
/// that grows with the trace and cursor counts this library exists for.
///
/// The ring now grows to cover the frame's uploads, records one fence after the
/// frame's last draw, and waits once, at the start of the next frame, so a frame
/// never synchronises with work it issued itself. None of that is visible in the
/// pixels -- a syncing ring draws the same image -- so the test pins the three
/// properties directly: a frame writes distinct slots (the offsets it hands the
/// vertex array), the ring grows past its floor to cover them, and a frame
/// performs at most one wait.
PLOT_TEST(ring_grows_to_cover_a_frame_and_waits_once_at_its_start) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::render::detail::InstanceStore store;
PLOT_CHECK(store.create(plot::render::UploadMode::PersistentMapped, &st));
if (!store.persistent()) {
ctx.destroy(); // no persistent-mapping path on this driver: nothing to pin
return;
}
constexpr plot::u32 kUploadsPerFrame = 6; // more than the ring's three-slot floor
plot::f32 data[4 * kUploadsPerFrame];
for (plot::u32 i = 0; i < 4 * kUploadsPerFrame; ++i) data[i] = static_cast<plot::f32>(i);
auto counter = [](const std::atomic<plot::u64>& c) {
return c.load(std::memory_order_relaxed);
};
plot::render::detail::ring_uploads().store(0, std::memory_order_relaxed);
plot::render::detail::ring_waits().store(0, std::memory_order_relaxed);
plot::render::detail::ring_slots().store(0, std::memory_order_relaxed);
// The first frame has no fence outstanding, so it must not wait at all -- and it
// must have grown the ring past its floor, because six uploads do not fit in
// three slots.
PLOT_CHECK(store.begin_frame());
for (plot::u32 i = 0; i < kUploadsPerFrame; ++i) {
store.upload(data, kUploadsPerFrame, sizeof(plot::render::LineInstance));
}
PLOT_CHECK(store.mark_drawn());
PLOT_CHECK(counter(plot::render::detail::ring_uploads()) == kUploadsPerFrame);
PLOT_CHECK(counter(plot::render::detail::ring_waits()) == 0);
PLOT_CHECK(counter(plot::render::detail::ring_slots()) > store.kInitialSlots);
PLOT_CHECK(counter(plot::render::detail::ring_slots()) >= kUploadsPerFrame);
// The second frame, measured upload by upload. The ring covers it now, so the
// slot each upload lands in -- the offset the vertex array re-points at -- is
// readable, and the invariant is the offsets being distinct: a slot written
// twice in a frame would be one the GPU could still be reading the first copy
// from, because the only fence is the one at the end of the frame.
PLOT_CHECK(store.begin_frame());
plot::usize offsets[kUploadsPerFrame] = {};
for (plot::u32 i = 0; i < kUploadsPerFrame; ++i) {
store.upload(data, kUploadsPerFrame, sizeof(plot::render::LineInstance));
offsets[i] = store.slot_offset();
}
PLOT_CHECK(store.mark_drawn());
for (plot::u32 i = 0; i < kUploadsPerFrame; ++i) {
for (plot::u32 j = i + 1u; j < kUploadsPerFrame; ++j) {
PLOT_CHECK(offsets[i] != offsets[j]); // no slot reused within the frame
}
}
// ... and this frame waited exactly once: on the fence the first one recorded,
// never on its own work.
PLOT_CHECK(counter(plot::render::detail::ring_uploads()) == 2u * kUploadsPerFrame);
PLOT_CHECK(counter(plot::render::detail::ring_waits()) == 1);
// A third frame the same, so the wait is per frame and not per upload: six
// uploads a frame would be five waits a frame under the shape this replaced.
PLOT_CHECK(store.begin_frame());
for (plot::u32 i = 0; i < kUploadsPerFrame; ++i) {
store.upload(data, kUploadsPerFrame, sizeof(plot::render::LineInstance));
}
PLOT_CHECK(store.mark_drawn());
PLOT_CHECK(counter(plot::render::detail::ring_waits()) == 2);
ctx.destroy();
}
/// The same property at the level the demos run at. A frame of this plot stages
/// what the demos stage -- two grid colour groups, the axis lines, tick labels and
/// titles, the trace, two cursors and the measurement band -- nine-ish uploads, or
/// three times the slots the ring used to have, so the old ring wrapped within the
/// frame several times over. The frame must absorb them with at most one wait per
/// store (two stores), and the upload counter must agree with the draw calls the
/// frame reports, so it is counting this frame's staging and nothing else.
///
/// The wait is checked twice over: once from the store's own counter, and once at
/// `glClientWaitSync`, the call every wait is made through. A nine-upload frame
/// under the shape this replaced made six mid-frame wait sites, each costing the
/// non-blocking probe plus the bounded wait behind it.
PLOT_TEST(a_dense_frame_uploads_more_than_the_ring_floor_without_waiting) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
if (!r.using_persistent_upload()) {
ctx.destroy(); // no persistent-mapping path on this driver: nothing to pin
return;
}
plot::data::ArraySeries s(sine_samples(), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
const plot::u32 tr = p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
p.set_x_range(ax, {0.0, 640.0});
const plot::u32 ca = p.add_cursor(100.0, plot::Color::hex(0x00FF00));
const plot::u32 cb = p.add_cursor(300.0, plot::Color::hex(0xFF00FF));
PLOT_CHECK(p.add_measure(ca, cb, tr) == 0);
auto counter = [](const std::atomic<plot::u64>& c) {
return c.load(std::memory_order_relaxed);
};
plot::render::detail::ring_uploads().store(0, std::memory_order_relaxed);
plot::render::detail::ring_waits().store(0, std::memory_order_relaxed);
plot::render::detail::ring_slots().store(0, std::memory_order_relaxed);
CountFenceWaits driver_waits; // installed after load(), restored with the test
for (plot::u32 index = 0; index < 3u; ++index) {
const plot::u64 uploads_before = counter(plot::render::detail::ring_uploads());
const plot::u64 waits_before = counter(plot::render::detail::ring_waits());
const plot::u64 calls_before = driver_waits.calls();
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const plot::u64 uploads = counter(plot::render::detail::ring_uploads()) - uploads_before;
const plot::u64 waits = counter(plot::render::detail::ring_waits()) - waits_before;
// Every upload lands in a draw this frame reported, so the counter describes
// this frame's staging and no other store's.
PLOT_CHECK(uploads == r.stats().draw_calls);
// The scenario: more uploads than the ring's floor of slots.
PLOT_CHECK(uploads > plot::render::detail::InstanceStore::kInitialSlots);
// ... and not one of them waited: the only wait is the next frame's start,
// one per store.
PLOT_CHECK(waits <= 2);
if (index == 0) PLOT_CHECK(waits == 0);
// At the driver call too: one wait site per store, and a site costs at most
// two calls -- the non-blocking probe, then the bounded wait behind it. Six
// uploads past the old ring's three slots used to cost six sites on their own,
// inside the frame that made them.
const plot::u64 calls = driver_waits.calls() - calls_before;
PLOT_CHECK(calls <= 4u);
if (index == 0) PLOT_CHECK(calls == 0u);
}
// The ring really grew past its floor: what covers a nine-upload frame is not
// what three slots covered.
PLOT_CHECK(counter(plot::render::detail::ring_slots()) >
plot::render::detail::InstanceStore::kInitialSlots);
ctx.destroy();
}