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
+623 -110

No files matched your search

+8 -1
View File
@@ -62,8 +62,15 @@ if(PLOT_BUILD_TESTS)
# merges so a test can pin the query's O(log n) cost and the append path's # merges so a test can pin the query's O(log n) cost and the append path's
# amortised O(1) cost, neither of which any answer reveals. Off in every # amortised O(1) cost, neither of which any answer reveals. Off in every
# non-test build (see include/plot/data/detail/{cover,merge}_count.hpp). # non-test build (see include/plot/data/detail/{cover,merge}_count.hpp).
#
# PLOT_RING_STATS is the same for the persistent-mapped instance ring: the
# properties a frame must hold there -- no slot written twice within a frame,
# and one fence wait at most, at the start of the frame -- are invisible in the
# pixels, so a test reads counters instead (see
# include/plot/render/detail/ring_stats.hpp).
target_compile_definitions(plot_core PRIVATE PLOT_PYRAMID_COUNT_COVERS target_compile_definitions(plot_core PRIVATE PLOT_PYRAMID_COUNT_COVERS
PLOT_PYRAMID_COUNT_MERGES) PLOT_PYRAMID_COUNT_MERGES
PLOT_RING_STATS)
enable_testing() enable_testing()
add_subdirectory(tests) add_subdirectory(tests)
+28 -17
View File
@@ -1,4 +1,5 @@
#include <qt6/qt_plot_widget.hpp> #include <qt6/qt_plot_widget.hpp>
#include <qt6/qt_pointer.hpp>
#include <plot/gl/loader.hpp> #include <plot/gl/loader.hpp>
@@ -114,21 +115,26 @@ void PlotWidget::initializeGL() {
renderer_.reset(); renderer_.reset();
g_unresolved_count = 0; g_unresolved_count = 0;
if (!plot::gl::load(&resolve_via_qt)) { const bool loaded = plot::gl::load(&resolve_via_qt);
// Printed before the failure branch, not after it: the names collected here are
// precisely what explains a failure -- which 3.3 entry point this context could
// not provide -- so a load that failed must report them too, or the one failure
// this widget can have says nothing but "missing a required entry point".
if (g_unresolved_count > 0) {
std::fprintf(stderr,
"[plot::qt6] QOpenGLContext::getProcAddress() did not resolve %u required"
" entry point(s); plot::gl::load() %s for:",
g_unresolved_count, loaded ? "used its dlsym fallback" : "failed");
for (u32 i = 0; i < g_unresolved_count; ++i) std::fprintf(stderr, " %s", g_unresolved[i]);
std::fprintf(stderr, "\n");
}
if (!loaded) {
init_status_ = Status{ErrorCode::Unsupported, init_status_ = Status{ErrorCode::Unsupported,
"plot::gl::load() failed: the current context is missing a required" "plot::gl::load() failed: the current context is missing a required"
" OpenGL 3.3 entry point"}; " OpenGL 3.3 entry point"};
std::fprintf(stderr, "[plot::qt6] %s\n", init_status_.message); std::fprintf(stderr, "[plot::qt6] %s\n", init_status_.message);
return; return;
} }
if (g_unresolved_count > 0) {
std::fprintf(stderr,
"[plot::qt6] QOpenGLContext::getProcAddress() did not resolve %u required"
" entry point(s); plot::gl::load() used its dlsym fallback for:",
g_unresolved_count);
for (u32 i = 0; i < g_unresolved_count; ++i) std::fprintf(stderr, " %s", g_unresolved[i]);
std::fprintf(stderr, "\n");
}
renderer_.emplace(); renderer_.emplace();
Status status; Status status;
@@ -206,19 +212,24 @@ Vec2 PlotWidget::to_device(const QPointF& p) const noexcept {
void PlotWidget::refresh_viewport() { controller_.set_viewport(device_viewport()); } void PlotWidget::refresh_viewport() { controller_.set_viewport(device_viewport()); }
void PlotWidget::forward_pointer(QMouseEvent* event, bool pressed, bool released) { void PlotWidget::forward_pointer(QMouseEvent* event, bool pressed, bool released) {
const Vec2 p = to_device(event->position()); // The anchor stays logical (see PointerAnchor): a move that arrives after the
// window moved to a display with another scale factor must not measure the gap
// between a position taken at the old DPR and one taken at the new one. The
// delta is the logical difference scaled by the ratio in effect now, so the same
// gesture maps to the same device distance at either scale.
const QPointF position = event->position();
const f64 dpr = static_cast<f64>(devicePixelRatioF());
const Vec2 logical{static_cast<f32>(position.x()), static_cast<f32>(position.y())};
const Vec2 p = to_device(position);
const Vec2 delta = anchor_.device_delta(logical, dpr);
input::PointerEvent e; input::PointerEvent e;
e.x = p.x; e.x = p.x;
e.y = p.y; e.y = p.y;
if (have_pointer_) { e.dx = delta.x;
e.dx = p.x - last_x_; e.dy = delta.y;
e.dy = p.y - last_y_;
}
// A press is also a pointer sample: the next move's delta starts here, or the // A press is also a pointer sample: the next move's delta starts here, or the
// first motion of a drag would include the jump to the click. // first motion of a drag would include the jump to the click.
last_x_ = p.x; anchor_.set(logical);
last_y_ = p.y;
have_pointer_ = true;
e.button = button_from(event->button()); e.button = button_from(event->button());
e.mods = mods_from(event->modifiers()); e.mods = mods_from(event->modifiers());
e.down = any_plot_button(event->buttons()); e.down = any_plot_button(event->buttons());
+18 -10
View File
@@ -18,6 +18,7 @@
#include <plot/math/vec2.hpp> #include <plot/math/vec2.hpp>
#include <plot/plot/plot.hpp> #include <plot/plot/plot.hpp>
#include <plot/render/renderer.hpp> #include <plot/render/renderer.hpp>
#include <qt6/qt_pointer.hpp>
#include <QOpenGLFunctions> #include <QOpenGLFunctions>
#include <QOpenGLWidget> #include <QOpenGLWidget>
@@ -80,14 +81,21 @@ namespace plot::qt {
/// * the viewport handed to the plot is `round(width() * dpr)` x /// * the viewport handed to the plot is `round(width() * dpr)` x
/// `round(height() * dpr)` (rounded, not truncated, so the last column of the /// `round(height() * dpr)` (rounded, not truncated, so the last column of the
/// FBO is inside the plot), and /// FBO is inside the plot), and
/// * every pointer, wheel and key anchor is multiplied by the same /// * every pointer and wheel position is multiplied by the same
/// `devicePixelRatioF()` before it reaches the controller, deltas included. /// `devicePixelRatioF()` before it reaches the controller.
/// ///
/// `device_viewport()` and `to_device()` are the only two places that mapping /// `device_viewport()` and `to_device()` are the only two places that mapping
/// exists, so the viewport and the input cannot disagree -- and they must not: a /// exists, so the viewport and the input cannot disagree -- and they must not: a
/// mismatch renders correctly but pans by the wrong amount, which is invisible on /// mismatch renders correctly but pans by the wrong amount, which is invisible on
/// an unscaled display and wrong on every other one. /// an unscaled display and wrong on every other one.
/// ///
/// The *anchor* a move's delta is measured from is the one thing kept in logical
/// pixels: `devicePixelRatioF()` can change between two move events, and the delta
/// is then a logical difference scaled by the ratio in effect now, never the
/// difference of two device positions taken at different ratios (see
/// `PointerAnchor`). A drag that crosses onto a display with another scale factor
/// therefore pans by the distance the pointer moved, not by a cross-scale jump.
///
/// ## Input /// ## Input
/// ///
/// Qt reports levels and edges directly (`QMouseEvent` press/release are edges, /// Qt reports levels and edges directly (`QMouseEvent` press/release are edges,
@@ -174,9 +182,10 @@ class PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions {
/// The widget's rectangle in device pixels: the plot's viewport, and the size /// The widget's rectangle in device pixels: the plot's viewport, and the size
/// of the framebuffer the frame is drawn into. /// of the framebuffer the frame is drawn into.
Rect device_viewport() const noexcept; Rect device_viewport() const noexcept;
/// A Qt position (logical widget pixels) in device pixels. The one place the /// A Qt position (logical widget pixels) in device pixels. The one place a
/// input mapping exists, so every event is scaled by the same factor the /// pointer position is mapped, so everything the controller sees is scaled by
/// viewport is. /// the same factor the viewport is -- a move's *delta*, whose ratio can change
/// between two events, is scaled by `PointerAnchor` instead.
Vec2 to_device(const QPointF& p) const noexcept; Vec2 to_device(const QPointF& p) const noexcept;
/// Translates one mouse event and hands it to the controller. `pressed` and /// Translates one mouse event and hands it to the controller. `pressed` and
/// `released` are the transitions Qt reports on the event itself. /// `released` are the transitions Qt reports on the event itself.
@@ -191,11 +200,10 @@ class PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions {
FrameFn frame_; FrameFn frame_;
Status init_status_; Status init_status_;
/// Last pointer position in device pixels: the deltas the controller pans with /// The position a move's delta is measured from, in **logical** pixels: Qt
/// are the movement since the previous event, which Qt does not report. /// reports no delta of its own, and the ratio can change between two events
bool have_pointer_ = false; /// (see `PointerAnchor` for why the anchor must not be device pixels).
f32 last_x_ = 0.0f; PointerAnchor anchor_;
f32 last_y_ = 0.0f;
u64 frames_ = 0; u64 frames_ = 0;
f32 last_frame_ms_ = 0.0f; f32 last_frame_ms_ = 0.0f;
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <plot/core/types.hpp>
#include <plot/math/vec2.hpp>
namespace plot::qt {
/// The cached pointer position a move event's movement is measured from, in
/// **logical** widget pixels.
///
/// Logical, not device, pixels on purpose. Qt reports every widget position in
/// logical pixels, and the device-pixel ratio can change between two move events
/// -- the window being dragged onto a monitor with another scale factor is the
/// case -- so an anchor cached in device pixels would compare a position computed
/// at the old ratio with one computed at the new one. At 1.0 -> 1.25 a logical
/// position of 500 would then look like a jump of 125 device pixels, and
/// `Controller::pan` applies a delta as a fraction of the range span (and clears
/// auto scale), so a scale change mid-drag would pan the plot by 125 pixels' worth
/// of data. Keeping the anchor logical and scaling the *difference* by the ratio
/// in effect now makes the same gesture map to the same device distance whatever
/// the window's scale is.
///
/// A plain value with no toolkit dependency: the widget owns one, and this is the
/// part of the adapter's translation a test can drive directly (see
/// tests/test_qt_pointer.cpp).
struct PointerAnchor {
bool have = false; ///< false until the first sample, which has no movement
f32 x = 0.0f; ///< last logical position, widget pixels
f32 y = 0.0f;
/// Movement from the cached position to `logical`, in device pixels at `dpr`.
/// Zero before the first sample, so the first motion of a drag never includes
/// the jump to where the drag began.
Vec2 device_delta(Vec2 logical, f64 dpr) const noexcept {
if (!have) return Vec2{0.0f, 0.0f};
return Vec2{static_cast<f32>((static_cast<f64>(logical.x) - static_cast<f64>(x)) * dpr),
static_cast<f32>((static_cast<f64>(logical.y) - static_cast<f64>(y)) * dpr)};
}
/// Caches `logical` as the position the next movement is measured from. Always
/// logical pixels, whatever the ratio is: see the type's comment.
void set(Vec2 logical) noexcept {
x = logical.x;
y = logical.y;
have = true;
}
};
} // namespace plot::qt
+56 -26
View File
@@ -35,10 +35,12 @@ static_assert(sizeof(TextInstance) == 4 * sizeof(f32), "text instance must stay
/// - `Orphan`: the GL 3.3 baseline. Every upload re-specifies the store with a /// - `Orphan`: the GL 3.3 baseline. Every upload re-specifies the store with a
/// null `glBufferData` (orphaning it, so the driver never has to wait for the /// null `glBufferData` (orphaning it, so the driver never has to wait for the
/// draw that is still reading the previous one) and then refills it. /// draw that is still reading the previous one) and then refills it.
/// - `PersistentMapped`: GL 4.4 / ARB_buffer_storage. Three slots of immutable /// - `PersistentMapped`: GL 4.4 / ARB_buffer_storage. Immutable storage is mapped
/// storage are mapped once and written directly, and the fence recorded after /// once and written directly, one slot per upload. A slot is never written
/// each slot's draw guards its reuse, so the render thread never waits on the /// twice within a frame, one fence is recorded after the frame's last draw, and
/// GPU. The vertex array's attribute pointers move with the slot. /// the only wait is at the start of the next frame -- so the render thread
/// never synchronises with GPU work it has just issued. The vertex array's
/// attribute pointers move with the slot.
enum class UploadMode { Orphan, PersistentMapped }; enum class UploadMode { Orphan, PersistentMapped };
namespace detail { namespace detail {
@@ -50,11 +52,13 @@ namespace detail {
/// `UploadMode` is live. /// `UploadMode` is live.
class InstanceStore { class InstanceStore {
public: public:
/// Slots the persistent ring rotates through. Three is the smallest ring that /// Slots the persistent ring starts with. The floor, not the ring's size: it
/// lets a frame's draw finish while the CPU fills two more. /// grows to cover the uploads a frame actually performs (see `upload()`), and
static constexpr u32 kSlots = 3; /// never shrinks, so a steady workload grows it at most a logarithmic number of
/// Upper bound on the wait for a slot's draw to retire: a driver that has /// times over the life of the store.
/// stopped making progress must not stall the render thread forever. static constexpr u32 kInitialSlots = 3;
/// Upper bound on the wait for the previous frame's draws to retire: a driver
/// that has stopped making progress must not stall the render thread forever.
static constexpr u64 kFenceWaitNs = 1'000'000'000ull; static constexpr u64 kFenceWaitNs = 1'000'000'000ull;
/// Creates the vertex buffer in `mode`. Reports `ErrorCode::Unsupported` while /// Creates the vertex buffer in `mode`. Reports `ErrorCode::Unsupported` while
@@ -64,19 +68,34 @@ class InstanceStore {
/// store created that way on a machine without them would never draw. /// store created that way on a machine without them would never draw.
bool create(UploadMode mode, Status* st = nullptr) noexcept; bool create(UploadMode mode, Status* st = nullptr) noexcept;
/// Starts a frame: waits, once, for the frame before it to retire the draws
/// that read the ring, so no slot about to be written can still be being read.
/// False when that wait timed out -- or when a slot was written without a fence
/// ever being recorded for it -- in which case the store has dropped to the
/// baseline path, which has no such hazard, and the caller reports it (see
/// `Renderer::begin_frame`).
///
/// One wait covers every slot because no slot is written twice within a frame
/// (`upload()` grows the ring rather than wrapping it), so each slot this frame
/// touches was last written in the previous frame or earlier -- and the
/// previous frame's fence sits behind every draw that read one.
bool begin_frame() noexcept;
/// Uploads `count` instances of `instance_bytes`. `Orphan` mode orphans and /// Uploads `count` instances of `instance_bytes`. `Orphan` mode orphans and
/// refills; `PersistentMapped` mode writes the next slot of the ring, waiting /// refills; `PersistentMapped` mode writes the next slot of the ring, growing
/// out any draw still reading it. /// the ring first when the frame has already written all of its slots.
void upload(const void* data, u32 count, usize instance_bytes) noexcept; void upload(const void* data, u32 count, usize instance_bytes) noexcept;
/// Records that the draw the caller just issued read the current slot, so the /// Records the frame's single fence, behind every draw that read the ring, so
/// fence guarding that slot's reuse ends up in the command stream behind the /// the next `begin_frame()` can wait for them. Called once per frame, after the
/// draw. True when the slot is guarded, and always in `Orphan` mode, where /// last draw that consumes the store. True in `Orphan` mode, where nothing
/// nothing reads the store after the orphan. /// reads the store after the orphan, and true when the frame drew nothing from
/// the ring.
/// ///
/// False when the driver refused the fence: the slot would stay unsynchronised, /// False when the driver refused the fence: the slots written this frame would
/// so the store drops the mapped ring for the orphan path instead of rotating /// stay unsynchronised, so the store drops the mapped ring for the orphan path
/// through it (the caller reports the failure -- see `Renderer`). /// instead of rotating through it (the caller reports the failure -- see
/// `Renderer::end_frame`).
bool mark_drawn() noexcept; bool mark_drawn() noexcept;
/// Byte offset the current slot's instances start at: 0 in `Orphan` mode, /// Byte offset the current slot's instances start at: 0 in `Orphan` mode,
@@ -101,24 +120,33 @@ class InstanceStore {
bool persistent() const noexcept { return mode_ == UploadMode::PersistentMapped; } bool persistent() const noexcept { return mode_ == UploadMode::PersistentMapped; }
private: private:
/// Grows the mapped ring to `bytes` per slot. False when the driver refuses the /// (Re)allocates the mapped ring with `slots` slots of `bytes` each. False when
/// storage or the map, in which case the store has fallen back to a fresh /// the driver refuses the storage or the map, in which case the store has fallen
/// `Orphan` buffer and the frame still draws. /// back to a fresh `Orphan` buffer and the frame still draws.
bool grow_persistent(usize bytes) noexcept; bool grow_persistent(usize bytes, u32 slots) noexcept;
/// Deletes every fence, unmaps and releases the GL buffer. `slot_bytes_` drops /// Deletes the frame fence, unmaps and releases the GL buffer. `slot_bytes_`
/// to 0 so the next upload regrows. /// drops to 0 so the next upload regrows.
void release_storage() noexcept; void release_storage() noexcept;
/// Abandons the mapped ring for the baseline path and re-creates the empty
/// buffer that path needs. Used wherever the ring can no longer be guarded.
void drop_to_orphan() noexcept;
gl::Buffer buffer_; gl::Buffer buffer_;
UploadMode mode_ = UploadMode::Orphan; UploadMode mode_ = UploadMode::Orphan;
u32 capacity_ = 0; ///< instances the orphan store can hold u32 capacity_ = 0; ///< instances the orphan store can hold
usize slot_bytes_ = 0; ///< bytes per slot of the persistent ring usize slot_bytes_ = 0; ///< bytes per slot of the persistent ring
u32 slot_ = kSlots - 1u; ///< last slot written; an upload advances it first u32 slot_count_ = kInitialSlots; ///< slots the ring has; grows, never shrinks
u32 slot_ = 0; ///< slot the last upload wrote
/// Slots written since this frame began, and the index the next upload takes.
/// It is the ring index itself, which is what keeps a frame's slots distinct.
u32 frame_uploads_ = 0;
/// Times an upload landed in a new slot. Kept apart from the buffer's own /// Times an upload landed in a new slot. Kept apart from the buffer's own
/// identity (see `epoch()`) so neither has to know about the other. /// identity (see `epoch()`) so neither has to know about the other.
u32 slot_epoch_ = 0; u32 slot_epoch_ = 0;
u8* mapped_ = nullptr; ///< base of the mapped ring, null in `Orphan` mode u8* mapped_ = nullptr; ///< base of the mapped ring, null in `Orphan` mode
gl::Fence fences_[kSlots]; /// Guards every slot the frame just ended wrote. Recorded once, after its last
/// draw; waited on once, at the start of the next frame.
gl::Fence frame_fence_;
}; };
} // namespace detail } // namespace detail
@@ -129,6 +157,7 @@ class LineBuffer {
bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept { bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept {
return store_.create(mode, st); return store_.create(mode, st);
} }
bool begin_frame() noexcept { return store_.begin_frame(); }
void upload(const f32* interleaved, u32 count) noexcept { void upload(const f32* interleaved, u32 count) noexcept {
store_.upload(interleaved, count, sizeof(LineInstance)); store_.upload(interleaved, count, sizeof(LineInstance));
} }
@@ -148,6 +177,7 @@ class TextBuffer {
bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept { bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept {
return store_.create(mode, st); return store_.create(mode, st);
} }
bool begin_frame() noexcept { return store_.begin_frame(); }
void upload(const f32* interleaved, u32 count) noexcept { void upload(const f32* interleaved, u32 count) noexcept {
store_.upload(interleaved, count, sizeof(TextInstance)); store_.upload(interleaved, count, sizeof(TextInstance));
} }
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#ifdef PLOT_RING_STATS
#include <atomic>
#include <plot/core/types.hpp>
namespace plot::render::detail {
/// Test-only instrumentation for the persistent-mapped instance ring (see
/// `InstanceStore`). Every counter exists only when PLOT_RING_STATS is defined
/// -- the test build defines it, on the library and on the suite -- and none of
/// them is a data member of any class, so a consumer compiled without the macro
/// still lays `InstanceStore` out identically. No other build carries a counter
/// or an increment on the upload, frame or wait path.
///
/// The properties they pin are invisible in the pixels a frame draws: a ring that
/// syncs mid-frame rasterises exactly what one that does not does, and a ring that
/// recycles a slot within a frame draws correctly until the GPU happens to be
/// slower than the CPU. So `InstanceStore` counts
///
/// * `ring_uploads()` -- one per upload, i.e. a frame's upload count;
/// * `ring_waits()` -- one per fence wait. At most one per store per frame:
/// the wait is at the start of the frame, on the previous frame's fence;
/// * `ring_slots()` -- the largest ring any store has grown to, which must
/// cover the uploads a frame performs.
///
/// They are process-global rather than per-store because a test that drives one
/// `InstanceStore` reads them directly; a test that drives the whole `Renderer`
/// reads the aggregate, where there are two stores and so at most two waits a
/// frame.
///
/// Not part of any query surface and not meant for production use.
inline std::atomic<u64>& ring_uploads() {
static std::atomic<u64> count{0};
return count;
}
inline std::atomic<u64>& ring_waits() {
static std::atomic<u64> count{0};
return count;
}
inline std::atomic<u64>& ring_slots() {
static std::atomic<u64> count{0};
return count;
}
/// Records `slots` as the ring's largest size so far. A compare-exchange loop
/// rather than a store because two stores report into one counter.
inline void note_ring_slots(u64 slots) noexcept {
std::atomic<u64>& count = ring_slots();
u64 largest = count.load(std::memory_order_relaxed);
while (largest < slots &&
!count.compare_exchange_weak(largest, slots, std::memory_order_relaxed)) {
}
}
} // namespace plot::render::detail
#endif // PLOT_RING_STATS
+11 -1
View File
@@ -80,6 +80,12 @@ class Renderer {
/// `viewport_px` (top-left origin, like every `Rect` in this library) into GL's /// `viewport_px` (top-left origin, like every `Rect` in this library) into GL's
/// bottom-left origin once, clears that region to `style().background` and /// bottom-left origin once, clears that region to `style().background` and
/// empties the staging batches. /// empties the staging batches.
///
/// It is also where the instance stores wait -- once each -- for the previous
/// frame's draws to retire the ring they read, before this frame writes any
/// slot (see `InstanceStore::begin_frame`): a frame never synchronises with the
/// work it issued itself. A store that cannot retire that frame drops to the
/// baseline path, which this frame reports (see `last_frame_had_gl_error()`).
void begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px) noexcept; void begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px) noexcept;
/// Draws `plot`, refreshing its layout, ticks and grid first. /// Draws `plot`, refreshing its layout, ticks and grid first.
@@ -98,6 +104,10 @@ class Renderer {
/// Ends the frame, draining `glGetError()` in a loop so a stale error cannot be /// Ends the frame, draining `glGetError()` in a loop so a stale error cannot be
/// attributed to a later frame (see `last_frame_had_gl_error()`). /// attributed to a later frame (see `last_frame_had_gl_error()`).
/// ///
/// It also records each instance store's single fence for the frame, behind
/// every draw that read the ring, which is what the next `begin_frame()` waits
/// on (see `InstanceStore::mark_drawn`).
///
/// The GL state it leaves behind, so a host can embed the plot without guessing: /// The GL state it leaves behind, so a host can embed the plot without guessing:
/// vertex array 0, program 0, `GL_ARRAY_BUFFER` 0, texture unit 0 active and /// vertex array 0, program 0, `GL_ARRAY_BUFFER` 0, texture unit 0 active and
/// nothing bound to `GL_TEXTURE_2D` on it, blending off, scissoring off, and the /// nothing bound to `GL_TEXTURE_2D` on it, blending off, scissoring off, and the
@@ -115,7 +125,7 @@ class Renderer {
/// True when the last frame reported a GL failure: an error drained from /// True when the last frame reported a GL failure: an error drained from
/// `glGetError()` in `end_frame()`, or a fence the driver refused, which the /// `glGetError()` in `end_frame()`, or a fence the driver refused, which the
/// store reports when the draw that should have been guarded is issued (such a /// store reports when the frame's fence is recorded or waited on (such a
/// frame has already fallen back to the baseline path). Errors are drained in a /// frame has already fallen back to the baseline path). Errors are drained in a
/// loop, so a stale one cannot be attributed to a later frame. /// loop, so a stale one cannot be attributed to a later frame.
bool last_frame_had_gl_error() const noexcept { return gl_error_; } bool last_frame_had_gl_error() const noexcept { return gl_error_; }
+11 -5
View File
@@ -30,11 +30,14 @@ class LineVertexArray {
void upload(const f32* interleaved, u32 segments) noexcept; void upload(const f32* interleaved, u32 segments) noexcept;
void bind() const noexcept { vao_.bind(); } void bind() const noexcept { vao_.bind(); }
/// Records that the draw just issued read the instance buffer's current slot, /// Waits, once, for the previous frame's draws to retire the ring before any
/// which is what lets the mapped path reuse it safely. True in the baseline /// slot of this frame is written (see `InstanceStore::begin_frame`). False when
/// mode, so a caller always calls it the same way. False only when the driver /// the store had to drop to the baseline path; the caller reports it.
/// refused the fence: the buffer has then fallen back to the baseline path, and bool begin_frame() noexcept { return buffer_.begin_frame(); }
/// `persistent()` says so from the next call on. /// Records the frame's single fence, behind every draw that read the ring (see
/// `InstanceStore::mark_drawn`); called once per frame, after its last draw.
/// False only when the driver refused the fence: the buffer has then fallen
/// back to the baseline path, and `persistent()` says so from the next call on.
bool mark_drawn() noexcept { return buffer_.mark_drawn(); } bool mark_drawn() noexcept { return buffer_.mark_drawn(); }
/// True while this array's instance buffer is the mapped ring (see /// True while this array's instance buffer is the mapped ring (see
/// `UploadMode`); a failure that falls back clears it. /// `UploadMode`); a failure that falls back clears it.
@@ -62,6 +65,9 @@ class TextVertexArray {
void upload(const f32* interleaved, u32 glyphs) noexcept; void upload(const f32* interleaved, u32 glyphs) noexcept;
void bind() const noexcept { vao_.bind(); } void bind() const noexcept { vao_.bind(); }
/// The line pipeline's frame pair, for the text store (see
/// `LineVertexArray::begin_frame` / `mark_drawn`).
bool begin_frame() noexcept { return buffer_.begin_frame(); }
bool mark_drawn() noexcept { return buffer_.mark_drawn(); } bool mark_drawn() noexcept { return buffer_.mark_drawn(); }
bool persistent() const noexcept { return buffer_.persistent(); } bool persistent() const noexcept { return buffer_.persistent(); }
+97 -39
View File
@@ -1,7 +1,9 @@
#include <plot/render/buffer.hpp> #include <plot/render/buffer.hpp>
#include <plot/gl/api.hpp> #include <plot/gl/api.hpp>
#include <plot/render/detail/ring_stats.hpp>
#include <algorithm>
#include <cstring> #include <cstring>
namespace plot::render { namespace plot::render {
@@ -16,7 +18,9 @@ bool InstanceStore::create(UploadMode mode, Status* st) noexcept {
} }
capacity_ = 0; capacity_ = 0;
slot_bytes_ = 0; slot_bytes_ = 0;
slot_ = kSlots - 1u; // the first upload takes slot 0 slot_count_ = kInitialSlots;
slot_ = 0;
frame_uploads_ = 0;
// The mapped path additionally needs its entry points, which are resolved // The mapped path additionally needs its entry points, which are resolved
// best-effort (see api.hpp): without them the store would never be able to // best-effort (see api.hpp): without them the store would never be able to
// write anything, so it stays on the baseline path instead. // write anything, so it stays on the baseline path instead.
@@ -34,16 +38,26 @@ void InstanceStore::release_storage() noexcept {
buffer_.unmap(); buffer_.unmap();
mapped_ = nullptr; mapped_ = nullptr;
} }
for (gl::Fence& fence : fences_) { frame_fence_.reset();
fence.reset();
}
buffer_.reset(); buffer_.reset();
slot_bytes_ = 0; slot_bytes_ = 0;
} }
bool InstanceStore::grow_persistent(usize bytes) noexcept { void InstanceStore::drop_to_orphan() noexcept {
// Deleting the old buffer is safe while the GPU may still be reading it: GL // The hazard the ring exists to prevent -- writing storage the GPU may still be
// keeps the storage alive until the last command that references it retires. // reading -- can no longer be guarded, and a frame that draws through the
// baseline path beats one that draws nothing. The old buffer is deleted while
// the GPU may still read it, which is safe: GL keeps its storage alive until the
// last command that references it retires.
release_storage();
mode_ = UploadMode::Orphan;
capacity_ = 0;
frame_uploads_ = 0;
Status retry;
(void)buffer_.create(&retry); // a failed retry leaves nothing to draw with
}
bool InstanceStore::grow_persistent(usize bytes, u32 slots) noexcept {
release_storage(); release_storage();
Status st; Status st;
if (!buffer_.create(&st)) { if (!buffer_.create(&st)) {
@@ -53,8 +67,9 @@ bool InstanceStore::grow_persistent(usize bytes) noexcept {
} }
const gl::GLbitfield flags = const gl::GLbitfield flags =
gl::GL_MAP_WRITE_BIT | gl::GL_MAP_PERSISTENT_BIT | gl::GL_MAP_COHERENT_BIT; gl::GL_MAP_WRITE_BIT | gl::GL_MAP_PERSISTENT_BIT | gl::GL_MAP_COHERENT_BIT;
buffer_.storage(kSlots * bytes, flags); const usize total = usize(slots) * bytes;
mapped_ = static_cast<u8*>(buffer_.map_range(0, kSlots * bytes, flags)); buffer_.storage(total, flags);
mapped_ = static_cast<u8*>(buffer_.map_range(0, total, flags));
if (mapped_ == nullptr) { if (mapped_ == nullptr) {
// The driver refused the storage or the mapping. A frame that draws through // The driver refused the storage or the mapping. A frame that draws through
// the baseline path beats one that draws nothing, so re-create the empty // the baseline path beats one that draws nothing, so re-create the empty
@@ -62,25 +77,72 @@ bool InstanceStore::grow_persistent(usize bytes) noexcept {
// array's attribute pointers went out with the buffer they captured, and the // array's attribute pointers went out with the buffer they captured, and the
// epoch reports it (`gl::Buffer::generation()`), which is what re-points them // epoch reports it (`gl::Buffer::generation()`), which is what re-points them
// at this buffer before the draw. // at this buffer before the draw.
release_storage(); drop_to_orphan();
mode_ = UploadMode::Orphan;
capacity_ = 0;
Status retry;
(void)buffer_.create(&retry); // a failed retry leaves nothing to draw with
return false; return false;
} }
slot_bytes_ = bytes; slot_bytes_ = bytes;
slot_ = kSlots - 1u; // fresh storage: the first upload takes slot 0 slot_count_ = slots;
slot_ = 0;
// Fresh storage: every one of its slots is unwritten, so the frame's slot
// cursor restarts at 0. The draws that read the old buffer are already in the
// command stream and keep it alive until they retire.
frame_uploads_ = 0;
#ifdef PLOT_RING_STATS
note_ring_slots(slot_count_);
#endif
return true; return true;
} }
bool InstanceStore::begin_frame() noexcept {
const u32 written = frame_uploads_;
frame_uploads_ = 0;
if (mode_ != UploadMode::PersistentMapped || mapped_ == nullptr) {
return true; // nothing the GPU reads, so no slot has to be waited for
}
if (written == 0) {
return true; // the frame before this one never read the ring
}
if (!frame_fence_) {
// Slots were written and no fence was ever recorded for them: the caller
// skipped mark_drawn(), so those slots are unguarded and writing any of them
// again could race a read still in flight. The ring is abandoned rather than
// reused, exactly as a refused fence's is.
drop_to_orphan();
return false;
}
#ifdef PLOT_RING_STATS
ring_waits().fetch_add(1, std::memory_order_relaxed);
#endif
// The slots this frame is about to write were all last written a frame ago, and
// this one fence sits behind every draw that read them -- unless the driver has
// not retired it, in which case writing would race the GPU and the ring is
// abandoned for the baseline path. The non-blocking check is the common case.
const bool retired = frame_fence_.wait(0) || frame_fence_.wait(kFenceWaitNs);
frame_fence_.reset();
if (retired) {
return true;
}
drop_to_orphan();
return false;
}
void InstanceStore::upload(const void* data, u32 count, usize instance_bytes) noexcept { void InstanceStore::upload(const void* data, u32 count, usize instance_bytes) noexcept {
if (buffer_.id() == 0 || data == nullptr || count == 0 || instance_bytes == 0) { if (buffer_.id() == 0 || data == nullptr || count == 0 || instance_bytes == 0) {
return; return;
} }
const usize bytes = usize(count) * instance_bytes; const usize bytes = usize(count) * instance_bytes;
if (mode_ == UploadMode::PersistentMapped && bytes > slot_bytes_ && !grow_persistent(bytes)) { if (mode_ == UploadMode::PersistentMapped &&
// The fallback already replaced the store with an orphan buffer. (bytes > slot_bytes_ || frame_uploads_ >= slot_count_)) {
// The frame has written every slot the ring has, or this instance payload is
// wider than a slot: grow rather than wrap. Covering the frame's upload count
// is what lets a single fence, recorded after its last draw, guard all of its
// slots -- and doubling makes the growth rare for a workload whose upload
// count has settled.
const usize want_bytes = std::max(bytes, slot_bytes_);
const u32 want_slots = frame_uploads_ >= slot_count_
? std::max(frame_uploads_ + 1u, slot_count_ * 2u)
: slot_count_;
(void)grow_persistent(want_bytes, want_slots); // a failure leaves the orphan path
} }
if (mode_ == UploadMode::Orphan) { if (mode_ == UploadMode::Orphan) {
@@ -92,18 +154,16 @@ void InstanceStore::upload(const void* data, u32 count, usize instance_bytes) no
return; return;
} }
// Advance the ring, then make sure the draw that last read this slot has // The slot this frame has not written yet: the frame's write cursor is the ring
// retired. The non-blocking check is the common case; the bounded wait only // index, so a frame writes 0, 1, 2, ... and never reaches a slot it already
// costs anything when three frames' geometry is still in flight. // filled. That is the whole reason one fence at the end of the frame is enough.
slot_ = (slot_ + 1u) % kSlots; slot_ = frame_uploads_;
if (fences_[slot_]) { #ifdef PLOT_RING_STATS
if (!fences_[slot_].wait(0)) { ring_uploads().fetch_add(1, std::memory_order_relaxed);
(void)fences_[slot_].wait(kFenceWaitNs); #endif
}
fences_[slot_].reset();
}
// MAP_COHERENT storage needs no explicit flush, so this is the whole transfer. // MAP_COHERENT storage needs no explicit flush, so this is the whole transfer.
std::memcpy(mapped_ + usize(slot_) * slot_bytes_, data, bytes); std::memcpy(mapped_ + usize(slot_) * slot_bytes_, data, bytes);
++frame_uploads_;
++slot_epoch_; ++slot_epoch_;
} }
@@ -111,22 +171,20 @@ bool InstanceStore::mark_drawn() noexcept {
if (mode_ != UploadMode::PersistentMapped || mapped_ == nullptr) { if (mode_ != UploadMode::PersistentMapped || mapped_ == nullptr) {
return true; // nothing reads the store after the draw return true; // nothing reads the store after the draw
} }
if (frame_uploads_ == 0) {
return true; // no draw read the ring this frame, so it needs no guard
}
Status st; Status st;
fences_[slot_].reset(); frame_fence_.reset();
if (fences_[slot_].create(&st)) { if (frame_fence_.create(&st)) {
return true; return true;
} }
// A slot whose fence the driver refused can never be reused safely: the next // A frame whose fence the driver refused leaves the slots it wrote
// unsynchronised, and such a slot can never be written again safely: the next
// upload would memcpy into storage the GPU may still be reading, which is the // upload would memcpy into storage the GPU may still be reading, which is the
// tearing the ring exists to prevent. So the ring is abandoned rather than // tearing the ring exists to prevent. So the ring is abandoned for the orphan
// rotated through -- the map failure's treatment, one step later -- and the // path, which is what the map failure's treatment does one step earlier.
// fresh buffer the orphan path needs is created. Deleting the mapped buffer is drop_to_orphan();
// safe while the draw still reads it: GL keeps the storage alive until then.
release_storage();
mode_ = UploadMode::Orphan;
capacity_ = 0;
Status retry;
(void)buffer_.create(&retry); // a failed retry leaves nothing to draw with
return false; return false;
} }
+19 -10
View File
@@ -163,6 +163,16 @@ void Renderer::begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px) noexcept {
return; return;
} }
// One wait per store, before any slot of this frame is written: the ring never
// synchronises with the work its own frame issued, only with the frame before
// it. A store that cannot retire that frame has dropped to the baseline path,
// which the frame reports.
const bool lines_retired = line_vao_.begin_frame();
const bool glyphs_retired = text_vao_.begin_frame();
if (!lines_retired || !glyphs_retired) {
gl_error_ = true;
}
const gl::GLint vx = static_cast<gl::GLint>(gl_viewport_.x); const gl::GLint vx = static_cast<gl::GLint>(gl_viewport_.x);
const gl::GLint vy = static_cast<gl::GLint>(gl_viewport_.y); const gl::GLint vy = static_cast<gl::GLint>(gl_viewport_.y);
const gl::GLsizei vw = static_cast<gl::GLsizei>(gl_viewport_.w); const gl::GLsizei vw = static_cast<gl::GLsizei>(gl_viewport_.w);
@@ -225,6 +235,15 @@ void Renderer::end_frame() noexcept {
if (!ready_) { if (!ready_) {
return; return;
} }
// The frame's single fence per store, behind every draw that read the ring: the
// next frame's begin_frame() waits for it, and until then nothing is written to
// a slot these draws may still be reading. Recording it here -- after the last
// draw, not after each upload -- is what keeps a frame from waiting on itself.
const bool lines_guarded = line_vao_.mark_drawn();
const bool glyphs_guarded = text_vao_.mark_drawn();
if (!lines_guarded || !glyphs_guarded) {
gl_error_ = true;
}
// Leave the host's context as close to neutral as a frame can: nothing bound to // Leave the host's context as close to neutral as a frame can: nothing bound to
// a pipeline stage, and off every state bit the frame turned on. What remains // a pipeline stage, and off every state bit the frame turned on. What remains
// is the viewport (the caller's to set for its next pass), the pixel-pack // is the viewport (the caller's to set for its next pass), the pixel-pack
@@ -270,13 +289,6 @@ void Renderer::flush_lines(const Mapping& map, Color color, f32 width) noexcept
// One triangle strip per instance, four corners from gl_VertexID. // One triangle strip per instance, four corners from gl_VertexID.
gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4, gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4,
static_cast<gl::GLsizei>(line_vao_.instance_count())); static_cast<gl::GLsizei>(line_vao_.instance_count()));
// The draw is in the command stream now, so this is the point at which the
// mapped path can let a fence follow it. A driver that refused the fence has
// already dropped this store to the baseline path; the frame reports it here
// rather than rotating through a slot nothing guards.
if (!line_vao_.mark_drawn()) {
gl_error_ = true;
}
stats_.staged_segments += segments; stats_.staged_segments += segments;
++stats_.draw_calls; ++stats_.draw_calls;
} }
@@ -299,9 +311,6 @@ void Renderer::flush_text() noexcept {
text_vao_.bind(); text_vao_.bind();
gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4, gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4,
static_cast<gl::GLsizei>(text_vao_.instance_count())); static_cast<gl::GLsizei>(text_vao_.instance_count()));
if (!text_vao_.mark_drawn()) {
gl_error_ = true;
}
++stats_.draw_calls; ++stats_.draw_calls;
} }
glyphs_.clear(); glyphs_.clear();
+9 -1
View File
@@ -51,10 +51,18 @@ if(TARGET plot::imgui)
target_link_libraries(plot_tests PRIVATE plot::imgui imgui) target_link_libraries(plot_tests PRIVATE plot::imgui imgui)
endif() 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 # Same switches the library is built with: the cost tests read the counters
# behind them (see include/plot/data/detail/{cover,merge}_count.hpp). # behind them (see include/plot/data/detail/{cover,merge}_count.hpp).
target_compile_definitions(plot_tests PRIVATE PLOT_PYRAMID_COUNT_COVERS 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) plot_apply_warnings(plot_tests)
add_test(NAME plot_tests COMMAND 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/api.hpp>
#include <plot/gl/loader.hpp> #include <plot/gl/loader.hpp>
#include <plot/data/array_series.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 <plot/render/renderer.hpp>
#include <atomic>
#include <cmath> #include <cmath>
#include <cstdlib> #include <cstdlib>
#include <vector> #include <vector>
@@ -151,6 +154,37 @@ class RefuseFenceSync {
decltype(plot::gl::glFenceSync) saved_; 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 } // namespace
PLOT_TEST(renders_visible_trace_across_viewport) { PLOT_TEST(renders_visible_trace_across_viewport) {
@@ -714,3 +748,168 @@ PLOT_TEST(refused_fence_drops_the_mapped_ring) {
} }
ctx.destroy(); 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();
}