diff --git a/.github/workflows/ci-freebsd.yml b/.github/workflows/ci-freebsd.yml index a8f67ac6..2e95c5b6 100644 --- a/.github/workflows/ci-freebsd.yml +++ b/.github/workflows/ci-freebsd.yml @@ -16,9 +16,11 @@ jobs: include: - name: QRhi (FreeBSD) text: OFF + benchmark: OFF examples: OFF - name: QRhi+Text (FreeBSD) text: ON + benchmark: ON examples: ON steps: - name: Checkout repository @@ -28,24 +30,30 @@ jobs: uses: vmactions/freebsd-vm@v1 env: TEXT: ${{ matrix.text }} + BENCHMARK: ${{ matrix.benchmark }} EXAMPLES: ${{ matrix.examples }} with: release: "15.0" usesh: true - envs: 'TEXT EXAMPLES' + envs: 'TEXT BENCHMARK EXAMPLES' prepare: | pkg bootstrap -fy mkdir -p /usr/local/etc/pkg/repos echo 'FreeBSD-ports: { enabled: no }' > /usr/local/etc/pkg/repos/FreeBSD-ports.conf echo 'FreeBSD: { url: https://pkg.FreeBSD.org/${ABI}/latest }' > /usr/local/etc/pkg/repos/FreeBSD.conf pkg update -f - pkg install -y git cmake pkgconf qt6-base qt6-declarative qt6-shadertools + pkg install -y git cmake pkgconf python311 qt6-base qt6-declarative qt6-shadertools xauth xorg-vfbserver run: | + set -e + mkdir -p build cmake -S . -B build \ -DCMAKE_BUILD_TYPE=Release \ -DVNM_PLOT_ENABLE_TEXT=${TEXT} \ -DVNM_PLOT_BUILD_TESTS=ON \ + -DVNM_PLOT_BUILD_BENCHMARK=${BENCHMARK} \ -DVNM_PLOT_BUILD_EXAMPLES=${EXAMPLES} \ -DVNM_PLOT_BUILD_FUNCTION_PLOTTER=OFF cmake --build build --config Release - cd build && env QT_QPA_PLATFORM=offscreen ctest --output-on-failure + Xvfb :99 -screen 0 1280x1024x24 >/tmp/vnm-plot-xvfb.log 2>&1 & + export DISPLAY=:99 + (cd build && ctest --output-on-failure) diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index b6bd517d..265f32e7 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -12,9 +12,14 @@ defaults: env: CMAKE_BUILD_TYPE: Release + # Hosted Xvfb uses Mesa's CPU renderer. Bound its worker count to the job + # memory limit and use its GL 3.3 rasterizer without selecting a QRhi API. + GALLIUM_DRIVER: softpipe + LP_NUM_THREADS: "1" LINUX_SYSTEM_DEPS: >- libgl-dev libx11-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxext-dev libwayland-dev libxkbcommon-dev libvulkan-dev + xvfb xauth jobs: build-rhi: @@ -65,9 +70,7 @@ jobs: - name: Run tests working-directory: build - env: - QT_QPA_PLATFORM: offscreen - run: ctest --output-on-failure + run: xvfb-run -a ctest --output-on-failure - name: Build summary run: | diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index fd3fef15..ec5eed3f 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -57,8 +57,6 @@ jobs: - name: Run tests working-directory: build - env: - QT_QPA_PLATFORM: offscreen run: ctest --output-on-failure -C ${CMAKE_BUILD_TYPE} - name: Build summary diff --git a/.gitignore b/.gitignore index 01419c8d..db47a2c1 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ # Benchmark output files inspector_benchmark_*.txt +inspector_benchmark_*.json +benchmark_phase_trace.jsonl # QtCreator user settings /examples/**/.qtcreator/ @@ -20,6 +22,7 @@ inspector_benchmark_*.txt # Claude workspace metadata .claude/ /repo/ +__pycache__/ # Windows artifacts /nul diff --git a/CMakeLists.txt b/CMakeLists.txt index 7e3fb311..62ffa352 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -17,7 +17,6 @@ option(VNM_PLOT_BUILD_FUNCTION_PLOTTER "Build the Qt Multimedia function_plotter option(VNM_PLOT_ENABLE_TEXT "Enable MSDF text rendering (requires FreeType + msdfgen)" ON) option(VNM_PLOT_BUILD_TESTS "Build test suite" OFF) option(VNM_PLOT_USE_SYSTEM_LIBS "Prefer find_package dependencies over FetchContent when available" OFF) -set(VNM_PLOT_VNM_MSDF_TEXT_VERSION 0.2.0) include(FetchContent) include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/EmbedAssets.cmake) @@ -169,7 +168,7 @@ if(_vnm_plot_missing_msdf_text_targets) _vnm_plot_has_any_msdf_text_target(_vnm_plot_has_partial_msdf_text_targets) if(VNM_PLOT_USE_SYSTEM_LIBS AND NOT _vnm_plot_has_partial_msdf_text_targets) - find_package(vnm_msdf_text ${VNM_PLOT_VNM_MSDF_TEXT_VERSION} CONFIG QUIET + find_package(vnm_msdf_text CONFIG QUIET COMPONENTS ${_vnm_plot_msdf_text_components}) _vnm_plot_alias_existing_msdf_text_targets() _vnm_plot_missing_targets( @@ -492,8 +491,8 @@ message(STATUS "vnm_plot: Building RHI library") # # Bake every shader in shaders/qsb/ into a multi-profile .qsb artifact and # embed it under :/vnm_plot/shaders/qsb/.qsb. Profile set covers Vulkan -# (SPIR-V), Metal 1.2 + 2.1, D3D11 (HLSL 5.0), and desktop GL 4.10 so RHI can -# pick a working artifact on every supported backend. +# (SPIR-V), Metal 1.2 + 2.1, D3D11 (HLSL 5.0), and desktop GL 3.30 + 4.10 so +# RHI can pick a working artifact on every supported backend. # ----------------------------------------------------------------------------- set(VNM_PLOT_QSB_SHADERS @@ -517,10 +516,9 @@ if(VNM_PLOT_ENABLE_TEXT) endif() # Each input shader yields :/vnm_plot/shaders/qsb/..qsb. -# GLSL profile is desktop 410 only; mobile GL ES is out of scope for -# the RHI shaders. They use storage buffers that are unavailable below -# ES 3.1, so emitting a 300 ES artifact would compile-fail at runtime -# on a real ES 3.0 driver. +# The RHI shaders use vertex attributes and std140 uniform blocks, so retain +# desktop GLSL 330 for GL 3.3 contexts and GLSL 410 for existing GL 4 paths. +# Mobile GL ES remains out of scope for this desktop renderer. foreach(_vnm_plot_qsb_shader IN LISTS VNM_PLOT_QSB_SHADERS) get_filename_component(_vnm_plot_qsb_output_dir "${CMAKE_CURRENT_BINARY_DIR}/.qsb/${_vnm_plot_qsb_shader}" @@ -533,7 +531,7 @@ unset(_vnm_plot_qsb_shader) qt_add_shaders(vnm_plot_rhi "vnm_plot_qsb_shaders" PREFIX "/vnm_plot" BASE "${CMAKE_CURRENT_SOURCE_DIR}" - GLSL "410" + GLSL "330,410" HLSL "50" MSL "12,21" OUTPUT_TARGETS VNM_PLOT_RHI_RESOURCE_TARGETS diff --git a/README.md b/README.md index ee562089..5dd08837 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,86 @@ auto series = vnm::plot::Series_builder() plot_widget->add_series(0, series); ``` +### Stacked series + +Give two or more ordinary series the same non-zero stack group. Group `0` +keeps a series independent. + +```cpp +auto f_source = std::make_shared>(); +f_source->set_data(std::vector{{0, 1.0f}, {1'000'000'000, 2.0f}}); + +auto g_source = std::make_shared>(); +g_source->set_data(std::vector{{0, 3.0f}, {1'000'000'000, 4.0f}}); + +const auto access = vnm::plot::make_access_policy( + &sample_t::t_ns, + &sample_t::value); + +auto f = vnm::plot::Series_builder() + .style(vnm::plot::Display_style::AREA) + .stack_group(1) + .data_source(f_source) + .access(access) + .build_shared(); + +auto g = vnm::plot::Series_builder() + .style(vnm::plot::Display_style::AREA) + .stack_group(1) + .data_source(g_source) + .access(access) + .build_shared(); + +plot_widget->add_series(10, f); // bottom +plot_widget->add_series(20, g); // top; cumulative value is f + g +``` + +Series are stacked bottom-to-top in ascending plot-ID order. Reordering a +stack therefore also means updating the caller's plot-ID mapping and series +registrations. `LINE` and `DOTS` draw cumulative values; `AREA` fills each +component's band between its cumulative base and top. Preview sources and +styles are composed the same way in the preview view. + +Each source retains its independently selected LOD and timestamp grid. The +renderer uses their exact timestamp union when it fits the view's composition +budget. Larger unions are interpolated onto a shared deterministic grid that +targets half-pixel spacing, clipped to the common selected-source time domain. +The grid retains both domain endpoints and is capped so one stack view never +materializes more than 1,048,576 cumulative samples across all layers; very +large layer counts therefore use a coarser grid. Main and preview use their own +horizontal pixel widths and budgets. Members must use +matching `Series_interpolation` modes and, after sample and nonfinite-policy +handling, produce a single continuous drawable selected window with monotonic +timestamps. A rejected group fails closed: the affected stack view is omitted +instead of drawing misleading unstacked data, and its reason is queryable via +`stack_status` or `get_stack_status`. When configured, the optional +`Plot_config::log_error` callback may also receive a human-readable message. +Bounded resampling is an accepted `ACTIVE` result, not a rejection. Features +narrower than half a pixel may be omitted, and a `STEP_AFTER` transition may +move to the next grid timestamp; indicators and auto-fit continue to match the +geometry actually rendered. + +Stack acceptance is also queryable without installing a log callback. C++ can +call `Plot_widget::stack_status(group, Series_view_kind::MAIN)` (or `PREVIEW`) +and inspect its typed `state`, `reason`, and `affected_series_ids`. `PENDING` +means no renderer result matches the current data and range, `ACTIVE` means the +view was accepted, and `SUPPRESSED` includes the specific rejection reason. A +singleton group is `ACTIVE` with no rejection because no composition is needed. +QML can call `get_stack_status(group, preview)`; the returned map contains +`group`, uppercase `view`, `state`, and `reason` strings, plus an +`affected_series_ids` list. + +The final cumulative value gets an automatic line overlay two pixels thicker +than `Plot_config::line_width_px`: `#E6DFCC` in dark mode and `#192033` in light +mode. This applies to the main and preview views for `LINE`, `DOTS`, and `AREA` +stacks. + +`PlotIndicator` keeps component text values raw while placing their markers on +the cumulative rendered layers. It adds a text-only `Σ` total and the note +"Markers show cumulative stack positions". `Plot_widget::auto_adjust_view()` +(used by the function plotter's **Fit Vertical** button) fits current cumulative +stack geometry, including `AREA` bases. + **Thread Safety** `Plot_widget` renders on a separate RHI render thread. Treat `series_data_t` as immutable once added. To change series config (style, access policy, preview config, color), update a copy and call `add_series` again with the same id to replace it. Make sure your `Data_source` implementation is safe to read from the render thread. @@ -188,11 +268,13 @@ cmake --build build - `vnm_plot_hello` - renders a sine wave using the example function-source helper - `vnm_plot_preview_config` - preview uses a separate data source and AREA style via `preview_config` -- `function_plotter` - multiple functions, per-series styles, expression evaluation via mexce +- `function_plotter` - expressions, per-series styles, and a stacked-series demo via mexce `function_plotter` is opt-in because it depends on Qt Multimedia and `mexce`. Build it with `-DVNM_PLOT_BUILD_EXAMPLES=ON -DVNM_PLOT_BUILD_FUNCTION_PLOTTER=ON`; point at a local mexce checkout with `-DMEXCE_LOCAL_PATH=...`. +Use its **Σ** control to stack functions, drag a function's color accent or +label to change bottom-to-top order, and use **Fit Vertical** to fit the stack. ## Configuration diff --git a/benchmark/ARCHITECTURE.md b/benchmark/ARCHITECTURE.md index 10a38092..8fd9cda0 100644 --- a/benchmark/ARCHITECTURE.md +++ b/benchmark/ARCHITECTURE.md @@ -16,7 +16,8 @@ profiling report format for reproducible comparisons. ## Major Components - Ring_buffer - Thread-safe circular buffer with overwrite semantics. - - Supports copy-on-snapshot via copy_to() for stable reads. + - Exposes split-span zero-copy views protected by a shared lock. + - Keeps `copy_to()` only as an explicit copying helper; snapshots do not use it. - Brownian_generator - Generates Bar_sample or Trade_sample using geometric Brownian motion. @@ -24,8 +25,9 @@ profiling report format for reproducible comparisons. - Benchmark_data_source - Implements vnm::plot::Data_source. - - Uses copy-on-snapshot to provide contiguous data for rendering. - - Tracks sequence numbers and computes value ranges for auto-scaling. + - Publishes zero-copy snapshots that may contain two ring-buffer spans. + - Reports logical view bytes separately from exact copied bytes (zero). + - Tracks the stable ring revision used by `current_sequence()`. - Benchmark_profiler - Implements vnm::plot::Profiler. @@ -41,11 +43,14 @@ profiling report format for reproducible comparisons. - Generator thread: - Produces samples at the configured rate using steady_clock pacing. - Writes into the ring buffer only; no rendering work. + - A coordinated publication barrier bounds complete logical publications + across every series to the measurement interval. - UI/render thread: - - Executes paintGL() via Qt's event loop. - - Requests snapshots from the Data_source and renders with vnm_plot. - - Records profiling scopes through Plot_config::profiler. + - The offscreen path drives QRhi frames directly. + - Requests zero-copy snapshots from the Data_source and renders with vnm_plot. + - Records profiling scopes, render-thread CPU allocations, and QRhi buffer + allocations through Plot_config::profiler. ## Rendering Pipeline - vnm_plot Asset_loader loads embedded shaders. @@ -81,7 +86,12 @@ Benchmark_profiler produces a stable benchmark report: The report is written at the end of the benchmark run to the configured output folder with the inspector_benchmark_YYYYMMDD_HHMMSS__.txt -naming convention. +naming convention. Windows file operations adapt logical paths to the native +extended-length namespace without exposing that transport spelling in reports. + +The phase trace flushes a fixed eleven aggregate success boundaries outside the +measured frame loop. Only a failure adds a frame-specific boundary, so external +termination retains progress without per-frame trace I/O. ## Configuration and CLI The executable accepts CLI options for duration, data type, seed, rate, ring @@ -93,6 +103,7 @@ resolved once at startup to keep runs reproducible. - benchmark/include/brownian_generator.h - benchmark/include/benchmark_data_source.h - benchmark/include/benchmark_profiler.h +- benchmark/include/path_io.h - benchmark/include/benchmark_window.h - benchmark/src/benchmark_window.cpp - benchmark/src/main.cpp diff --git a/benchmark/CMakeLists.txt b/benchmark/CMakeLists.txt index d611efb7..6be91937 100644 --- a/benchmark/CMakeLists.txt +++ b/benchmark/CMakeLists.txt @@ -6,6 +6,8 @@ # and inherits cmake_minimum_required and project settings from parent. find_package(Qt6 REQUIRED COMPONENTS Core Gui GuiPrivate Quick) +find_package(Python3 REQUIRED COMPONENTS Interpreter) +find_package(Threads REQUIRED) # Find vnm_plot targets (should be available from parent) if(NOT TARGET vnm_plot::rhi) @@ -21,19 +23,23 @@ if(NOT VNM_PLOT_ENABLE_TEXT) message(FATAL_ERROR "vnm_plot benchmark requires text rendering. Configure with -DVNM_PLOT_ENABLE_TEXT=ON or disable the benchmark.") endif() -option(VNM_PLOT_BUILD_BENCHMARK_TESTS "Build benchmark helper tests" OFF) +option(VNM_PLOT_BUILD_BENCHMARK_TESTS "Build benchmark helper tests" ON) # ----------------------------------------------------------------------------- # Benchmark executable # ----------------------------------------------------------------------------- set(BENCHMARK_SOURCES + src/allocation_tracker.cpp src/main.cpp src/benchmark_window.cpp ) set(BENCHMARK_HEADERS include/ring_buffer.h + include/allocation_tracker.h + include/path_io.h + include/publication_rate_clock.h include/sample_types.h include/brownian_generator.h include/benchmark_profiler.h @@ -58,6 +64,9 @@ target_include_directories(vnm_plot_benchmark ) target_compile_features(vnm_plot_benchmark PRIVATE cxx_std_20) +target_compile_definitions(vnm_plot_benchmark PRIVATE + VNM_PLOT_BENCHMARK_BUILD_TYPE="$" +) # Link dependencies target_link_libraries(vnm_plot_benchmark @@ -68,11 +77,13 @@ target_link_libraries(vnm_plot_benchmark Qt6::Gui Qt6::GuiPrivate Qt6::Quick + Threads::Threads ) # Platform-specific settings if(WIN32) target_compile_definitions(vnm_plot_benchmark PRIVATE NOMINMAX) + target_link_libraries(vnm_plot_benchmark PRIVATE psapi) # Console application for benchmark output set_target_properties(vnm_plot_benchmark PROPERTIES WIN32_EXECUTABLE FALSE @@ -130,7 +141,7 @@ target_include_directories(test_ring_buffer target_compile_features(test_ring_buffer PRIVATE cxx_std_20) -target_link_libraries(test_ring_buffer PRIVATE vnm_plot::data) +target_link_libraries(test_ring_buffer PRIVATE vnm_plot::data Threads::Threads) if(WIN32) target_compile_definitions(test_ring_buffer PRIVATE NOMINMAX) @@ -184,6 +195,7 @@ message(STATUS "vnm_plot: Building Brownian generator tests") # ----------------------------------------------------------------------------- add_executable(test_benchmark_profiler + src/allocation_tracker.cpp src/test_benchmark_profiler.cpp ${BENCHMARK_HEADERS} ) @@ -229,7 +241,7 @@ target_include_directories(test_benchmark_data_source target_compile_features(test_benchmark_data_source PRIVATE cxx_std_20) -target_link_libraries(test_benchmark_data_source PRIVATE vnm_plot::data) +target_link_libraries(test_benchmark_data_source PRIVATE vnm_plot::data Threads::Threads) if(WIN32) target_compile_definitions(test_benchmark_data_source PRIVATE NOMINMAX) @@ -244,4 +256,45 @@ endif() add_test(NAME benchmark_data_source_tests COMMAND test_benchmark_data_source) message(STATUS "vnm_plot: Building benchmark data source tests") + +add_test( + NAME benchmark_native_smoke + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/validate_smoke.py" + --executable "$" + --output-dir "${CMAKE_CURRENT_BINARY_DIR}/smoke-reports/native" + --graphics-backend native + --frames 3 +) +set_tests_properties(benchmark_native_smoke PROPERTIES + TIMEOUT 60 + ENVIRONMENT "PYTHONDONTWRITEBYTECODE=1" +) + +add_test( + NAME benchmark_native_stacked_area_smoke + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/validate_smoke.py" + --executable "$" + --output-dir "${CMAKE_CURRENT_BINARY_DIR}/smoke-reports/native-stacked-area" + --graphics-backend native + --frames 3 + --stacked +) +set_tests_properties(benchmark_native_stacked_area_smoke PROPERTIES + TIMEOUT 60 + ENVIRONMENT "PYTHONDONTWRITEBYTECODE=1" +) + +add_test( + NAME benchmark_phase_trace_flush + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/test_phase_trace_flush.py" + --executable "$" +) +set_tests_properties(benchmark_phase_trace_flush PROPERTIES + TIMEOUT 45 + ENVIRONMENT "PYTHONDONTWRITEBYTECODE=1" +) + endif() diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 00000000..0e46e351 --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,51 @@ +# vnm_plot benchmark + +`vnm_plot_benchmark` measures the complete vnm_plot render path with synthetic +Bars or Trades data. It supports multiple independent series, static data, and +live publication through ring buffers. + +The executable has two backend controls: + +- `--backend qrhi|qrhi-offscreen` selects the presentation path. +- `--graphics-backend native|d3d11|metal|vulkan|opengl|null` selects the + graphics API. `native` accepts whichever non-Null API Qt selects; `null` is + used only when requested explicitly. + +For repeatable runs, use an exact frame count and explicit workload settings: + +```powershell +build-text\benchmark\vnm_plot_benchmark.exe ` + --backend qrhi-offscreen ` + --graphics-backend native ` + --static --data-type bars --render-style line ` + --series-count 8 --seed 42 ` + --warmup-frames 2 --frames 120 --finish ` + --scenario static-bars-line-8 ` + --output-dir benchmark-results +``` + +Each successful run writes a human-readable `.txt` report, a raw `.json` +report, and `benchmark_phase_trace.jsonl`. The JSON report records the command, +build type, compiler, Qt version, requested and actual QRhi backend/device, +renderer environment, workload settings, raw observations, and calculated +p50/p95/p99 values. High-rate metrics use deterministic reservoir sampling; +aggregate count/total/min/max values are not sampled. + +Snapshot accounting distinguishes logical `benchmark.snapshot.view_bytes` from +physical `benchmark.snapshot.copied_bytes`. Render-thread CPU allocation +count/bytes cover the measured frame while excluding the profiler's own +storage. `renderer.frame.gpu_buffer_allocation_*` separately counts QRhi buffer +creation. + +The phase trace flushes aggregate setup, backend creation, warm-up, +measurement, generator shutdown, and completion boundaries outside the +measured frame loop. A frame-specific record is emitted only on failure, so a +terminated run shows its last completed phase without adding filesystem work +to measured frames. + +On Windows, benchmark file operations support extended-length native paths +while reports keep their ordinary logical path spelling. + +The `benchmark_native_smoke` CTest runs a short offscreen render, validates the +selected backend, reads pixels, rejects clear-only output, checks key renderer +counters, and validates the phase trace. diff --git a/benchmark/include/allocation_tracker.h b/benchmark/include/allocation_tracker.h new file mode 100644 index 00000000..b29dc94d --- /dev/null +++ b/benchmark/include/allocation_tracker.h @@ -0,0 +1,71 @@ +// vnm_plot Benchmark - Render-thread CPU allocation tracking + +#ifndef VNM_PLOT_BENCHMARK_ALLOCATION_TRACKER_H +#define VNM_PLOT_BENCHMARK_ALLOCATION_TRACKER_H + +#include + +namespace vnm::benchmark { + +struct Thread_allocation_measurement { + std::uint64_t count = 0; + std::uint64_t bytes = 0; +}; + +void begin_thread_allocation_measurement() noexcept; +Thread_allocation_measurement end_thread_allocation_measurement() noexcept; +void suspend_thread_allocation_measurement() noexcept; +void resume_thread_allocation_measurement() noexcept; + +class Thread_allocation_suppression { +public: + Thread_allocation_suppression() noexcept + { + suspend_thread_allocation_measurement(); + } + + ~Thread_allocation_suppression() + { + resume_thread_allocation_measurement(); + } + + Thread_allocation_suppression(const Thread_allocation_suppression&) = delete; + Thread_allocation_suppression& operator=(const Thread_allocation_suppression&) = delete; +}; + +class Thread_allocation_scope { +public: + explicit Thread_allocation_scope(bool enabled) noexcept + : m_enabled(enabled) + { + if (m_enabled) { + begin_thread_allocation_measurement(); + } + } + + ~Thread_allocation_scope() + { + if (m_enabled) { + (void)end_thread_allocation_measurement(); + } + } + + Thread_allocation_scope(const Thread_allocation_scope&) = delete; + Thread_allocation_scope& operator=(const Thread_allocation_scope&) = delete; + + Thread_allocation_measurement finish() noexcept + { + if (!m_enabled) { + return {}; + } + m_enabled = false; + return end_thread_allocation_measurement(); + } + +private: + bool m_enabled; +}; + +} // namespace vnm::benchmark + +#endif // VNM_PLOT_BENCHMARK_ALLOCATION_TRACKER_H diff --git a/benchmark/include/benchmark_data_source.h b/benchmark/include/benchmark_data_source.h index dc439ea7..55dddab6 100644 --- a/benchmark/include/benchmark_data_source.h +++ b/benchmark/include/benchmark_data_source.h @@ -9,6 +9,8 @@ #include +#include +#include #include #include @@ -42,11 +44,19 @@ class Benchmark_data_source : public vnm::plot::Data_source { return {vnm::plot::data_snapshot_t{}, Status::FAILED}; } + const auto started = std::chrono::steady_clock::now(); auto view = m_buffer.view(); - m_snapshot_sequence = view.sequence; - - if (view.count == 0) { - return {vnm::plot::data_snapshot_t{}, Status::EMPTY}; + m_snapshot_sequence.store(view.sequence, std::memory_order_release); + + if (m_profiler) { + const double snapshot_ns = std::chrono::duration( + std::chrono::steady_clock::now() - started).count(); + m_profiler->record_observation("benchmark.snapshot.time_ns", snapshot_ns); + m_profiler->record_counter("benchmark.snapshot.count"); + m_profiler->record_observation( + "benchmark.snapshot.view_bytes", + static_cast(view.count * sizeof(T))); + m_profiler->record_observation("benchmark.snapshot.copied_bytes", 0.0); } vnm::plot::data_snapshot_t snapshot; @@ -56,6 +66,11 @@ class Benchmark_data_source : public vnm::plot::Data_source { snapshot.sequence = view.sequence; snapshot.data2 = view.data2; snapshot.count2 = view.count2; + + if (view.count == 0) { + return {snapshot, Status::EMPTY}; + } + snapshot.hold = view.lock; return { @@ -82,20 +97,22 @@ class Benchmark_data_source : public vnm::plot::Data_source { } uint64_t current_sequence(size_t lod_level = 0) const override { - (void)lod_level; - // Ring_buffer::clear() resets its sequence, so it cannot be exposed - // as the monotonic skip key required by Data_source::current_sequence. - return 0; + return lod_level == 0 ? m_buffer.sequence() : 0; } /// Get current sequence for change detection uint64_t sequence() const { - return m_snapshot_sequence; + return m_snapshot_sequence.load(std::memory_order_acquire); + } + + void set_profiler(vnm::plot::Profiler* profiler) noexcept { + m_profiler = profiler; } private: Ring_buffer& m_buffer; - uint64_t m_snapshot_sequence = 0; + std::atomic m_snapshot_sequence{0}; + vnm::plot::Profiler* m_profiler = nullptr; }; /// Create a Data_access_policy for Bar_sample. The renderer owns the GPU-side diff --git a/benchmark/include/benchmark_profiler.h b/benchmark/include/benchmark_profiler.h index 15bd8a5d..eb0466b4 100644 --- a/benchmark/include/benchmark_profiler.h +++ b/benchmark/include/benchmark_profiler.h @@ -4,6 +4,9 @@ #ifndef VNM_PLOT_BENCHMARK_PROFILER_H #define VNM_PLOT_BENCHMARK_PROFILER_H +#include "allocation_tracker.h" +#include "path_io.h" + #include #include @@ -17,10 +20,13 @@ #include #include #include +#include #include #include +#include #include #include +#include namespace vnm::benchmark { @@ -42,6 +48,8 @@ struct Report_metadata { double volatility = 0.0; std::size_t ring_capacity = 0; std::size_t samples_generated = 0; + std::map reproduction; + std::vector invocation_args; }; /// Profiler that builds hierarchical timing tree with scope aggregation. @@ -58,6 +66,7 @@ class Benchmark_profiler : public vnm::plot::Profiler { /// Begin a named scope. Nested calls create child scopes. void begin_scope(const char* name) override { + Thread_allocation_suppression suppress_instrumentation_allocations; auto start_time = std::chrono::steady_clock::now(); const char* scope_name = name ? name : ""; @@ -78,6 +87,7 @@ class Benchmark_profiler : public vnm::plot::Profiler { /// End the current scope and record timing. void end_scope() override { + Thread_allocation_suppression suppress_instrumentation_allocations; auto end_time = std::chrono::steady_clock::now(); std::lock_guard lock(m_mutex); @@ -103,12 +113,13 @@ class Benchmark_profiler : public vnm::plot::Profiler { void record_observation(const char* name, double value) override { + Thread_allocation_suppression suppress_instrumentation_allocations; if (!name || !std::isfinite(value)) { return; } std::lock_guard lock(m_mutex); - record_observation_locked(name, 1, value, value, value); + record_observation_locked(name, 1, value, value, value, true); } void record_observation_summary( @@ -118,6 +129,7 @@ class Benchmark_profiler : public vnm::plot::Profiler { double min, double max) { + Thread_allocation_suppression suppress_instrumentation_allocations; if (!name || call_count == 0 || !std::isfinite(total) || !std::isfinite(min) || !std::isfinite(max)) { @@ -125,23 +137,61 @@ class Benchmark_profiler : public vnm::plot::Profiler { } std::lock_guard lock(m_mutex); - record_observation_locked(name, call_count, total, min, max); + record_observation_locked(name, call_count, total, min, max, false); + } + + void ensure_observation(const char* name, double value = 0.0) + { + Thread_allocation_suppression suppress_instrumentation_allocations; + if (!name || !std::isfinite(value)) { + return; + } + std::lock_guard lock(m_mutex); + if (m_observations.find(name) == m_observations.end()) { + record_observation_locked(name, 1, value, value, value, true); + } } private: + static constexpr std::size_t k_max_retained_samples = 8192; + void record_observation_locked( const char* name, uint64_t call_count, double total, double min, - double max) + double max, + bool retain_sample) { - auto& stats = m_observations[name]; + auto [stats_it, inserted] = m_observations.try_emplace(name); + auto& stats = stats_it->second; + if (inserted) { + std::uint64_t seed = 1'469'598'103'934'665'603ull; + for (const unsigned char byte : std::string(name)) { + seed ^= byte; + seed *= 1'099'511'628'211ull; + } + stats.reservoir_rng.seed(seed); + } stats.name = name; stats.call_count += call_count; stats.total += total; stats.min = std::min(stats.min, min); stats.max = std::max(stats.max, max); + if (retain_sample) { + if (stats.samples.size() < k_max_retained_samples) { + stats.samples.push_back(total); + } + else { + std::uniform_int_distribution distribution( + 0, + stats.call_count - 1); + const std::uint64_t candidate = distribution(stats.reservoir_rng); + if (candidate < k_max_retained_samples) { + stats.samples[static_cast(candidate)] = total; + } + } + } } public: @@ -213,14 +263,101 @@ class Benchmark_profiler : public vnm::plot::Profiler { std::filesystem::path output_path = meta.output_directory / filename; // Ensure directory exists - std::filesystem::create_directories(meta.output_directory); + std::filesystem::create_directories(path_for_file_io(meta.output_directory)); // Write report - std::ofstream ofs(output_path); - if (ofs) { - ofs << generate_report(meta); + std::ofstream ofs(path_for_file_io(output_path)); + if (!ofs) { + throw std::runtime_error("cannot open benchmark report: " + output_path.string()); + } + ofs << generate_report(meta); + if (!ofs) { + throw std::runtime_error("cannot write benchmark report: " + output_path.string()); } + write_raw_report(meta); + + return output_path; + } + + std::filesystem::path raw_report_path(const Report_metadata& meta) const + { + return meta.output_directory / generate_raw_filename(meta); + } + + std::filesystem::path write_raw_report(const Report_metadata& meta) const + { + const std::filesystem::path output_path = raw_report_path(meta); + std::filesystem::create_directories(path_for_file_io(meta.output_directory)); + + std::lock_guard lock(m_mutex); + std::ofstream ofs(path_for_file_io(output_path)); + if (!ofs) { + throw std::runtime_error("cannot open raw benchmark report: " + output_path.string()); + } + + ofs << "{\n \"schema_version\": 1,\n"; + ofs << " \"retained_sample_limit_per_metric\": " + << k_max_retained_samples << ",\n"; + ofs << " \"metadata\": {\n"; + write_json_string_field(ofs, "session", meta.session, true, 4); + write_json_string_field(ofs, "stream", meta.stream, true, 4); + write_json_string_field(ofs, "data_type", meta.data_type, true, 4); + write_json_string_field(ofs, "backend", meta.backend, true, 4); + write_json_number_field(ofs, "target_duration_seconds", meta.target_duration, true, 4); + write_json_string_field(ofs, "started_at_utc", format_utc_time(meta.started_at), true, 4); + write_json_string_field(ofs, "generated_at_utc", format_utc_time(meta.generated_at), + !meta.reproduction.empty(), 4); + std::size_t reproduction_index = 0; + for (const auto& [name, value] : meta.reproduction) { + write_json_string_field( + ofs, + name, + value, + ++reproduction_index < meta.reproduction.size(), + 4); + } + ofs << " },\n \"invocation_args\": ["; + for (std::size_t i = 0; i < meta.invocation_args.size(); ++i) { + if (i > 0) { + ofs << ", "; + } + ofs << "\"" << json_escape(meta.invocation_args[i]) << "\""; + } + ofs << "],\n \"observations\": {\n"; + + std::size_t observation_index = 0; + for (const auto& [name, stats] : m_observations) { + ofs << " \"" << json_escape(name) << "\": {\n"; + ofs << " \"count\": " << stats.call_count << ",\n"; + ofs << " \"retained_sample_count\": " << stats.samples.size() << ",\n"; + ofs << " \"total\": " << json_number(stats.total) << ",\n"; + const double mean = stats.call_count > 0 + ? stats.total / static_cast(stats.call_count) + : 0.0; + ofs << " \"mean\": " << json_number(mean) << ",\n"; + ofs << " \"min\": " << json_number(stats.min) << ",\n"; + ofs << " \"max\": " << json_number(stats.max) << ",\n"; + write_json_percentile(ofs, "p50", stats.samples, 0.50); + write_json_percentile(ofs, "p95", stats.samples, 0.95); + write_json_percentile(ofs, "p99", stats.samples, 0.99); + ofs << " \"samples\": ["; + for (std::size_t i = 0; i < stats.samples.size(); ++i) { + if (i > 0) { + ofs << ", "; + } + ofs << json_number(stats.samples[i]); + } + ofs << "]\n }"; + if (++observation_index < m_observations.size()) { + ofs << ","; + } + ofs << "\n"; + } + ofs << " }\n}\n"; + if (!ofs) { + throw std::runtime_error("cannot write raw benchmark report: " + output_path.string()); + } return output_path; } @@ -257,6 +394,8 @@ class Benchmark_profiler : public vnm::plot::Profiler { double total = 0.0; double min = std::numeric_limits::max(); double max = 0.0; + std::vector samples; + std::mt19937_64 reservoir_rng; }; struct Thread_context { @@ -295,7 +434,10 @@ class Benchmark_profiler : public vnm::plot::Profiler { gmtime_r(&time_t_val, &tm_val); #endif std::ostringstream oss; - oss << std::put_time(&tm_val, "%Y%m%d_%H%M%S"); + const auto milliseconds = std::chrono::duration_cast( + tp.time_since_epoch()) % 1000; + oss << std::put_time(&tm_val, "%Y%m%d_%H%M%S_") + << std::setw(3) << std::setfill('0') << milliseconds.count(); return oss.str(); } @@ -342,6 +484,10 @@ class Benchmark_profiler : public vnm::plot::Profiler { if (meta.include_extended) { os << " - volatility: " << std::fixed << std::setprecision(4) << meta.volatility << "\n"; } + + for (const auto& [name, value] : meta.reproduction) { + os << " - " << name << ": " << value << "\n"; + } } void write_observations(std::ostream& os) const @@ -387,6 +533,96 @@ class Benchmark_profiler : public vnm::plot::Profiler { return oss.str(); } + static std::string generate_raw_filename(const Report_metadata& meta) + { + std::filesystem::path filename(generate_filename(meta)); + filename.replace_extension(".json"); + return filename.generic_string(); + } + + static std::string json_escape(const std::string& value) + { + std::ostringstream oss; + for (const unsigned char c : value) { + switch (c) { + case '\"': oss << "\\\""; break; + case '\\': oss << "\\\\"; break; + case '\b': oss << "\\b"; break; + case '\f': oss << "\\f"; break; + case '\n': oss << "\\n"; break; + case '\r': oss << "\\r"; break; + case '\t': oss << "\\t"; break; + default: + if (c < 0x20) { + oss << "\\u" << std::hex << std::setw(4) << std::setfill('0') + << static_cast(c) << std::dec << std::setfill(' '); + } + else { + oss << static_cast(c); + } + break; + } + } + return oss.str(); + } + + static std::string json_number(double value) + { + if (!std::isfinite(value)) { + return "null"; + } + std::ostringstream oss; + oss << std::setprecision(17) << value; + return oss.str(); + } + + static double percentile(std::vector values, double fraction) + { + if (values.empty()) { + return std::numeric_limits::quiet_NaN(); + } + std::sort(values.begin(), values.end()); + const double index = fraction * static_cast(values.size() - 1); + const std::size_t lower = static_cast(std::floor(index)); + const std::size_t upper = static_cast(std::ceil(index)); + const double weight = index - static_cast(lower); + return values[lower] * (1.0 - weight) + values[upper] * weight; + } + + static void write_json_string_field( + std::ostream& os, + const std::string& name, + const std::string& value, + bool comma, + int indent) + { + os << std::string(static_cast(indent), ' ') + << "\"" << json_escape(name) << "\": \"" << json_escape(value) << "\"" + << (comma ? "," : "") << "\n"; + } + + static void write_json_number_field( + std::ostream& os, + const std::string& name, + double value, + bool comma, + int indent) + { + os << std::string(static_cast(indent), ' ') + << "\"" << json_escape(name) << "\": " << json_number(value) + << (comma ? "," : "") << "\n"; + } + + static void write_json_percentile( + std::ostream& os, + const char* name, + const std::vector& samples, + double fraction) + { + os << " \"" << name << "\": " + << json_number(percentile(samples, fraction)) << ",\n"; + } + // Extract short name from fully qualified scope name (e.g., "renderer.frame.foo" -> "foo") static std::string short_name(const std::string& full_name) { diff --git a/benchmark/include/benchmark_window.h b/benchmark/include/benchmark_window.h index 13ca2fed..bd1b75fa 100644 --- a/benchmark/include/benchmark_window.h +++ b/benchmark/include/benchmark_window.h @@ -7,6 +7,7 @@ #include "benchmark_constants.h" #include "benchmark_data_source.h" #include "benchmark_profiler.h" +#include "publication_rate_clock.h" #include "brownian_generator.h" #include "ring_buffer.h" #include "sample_types.h" @@ -28,17 +29,23 @@ #include #include +#include +#include #include #include #include +#include #include #include +#include class QRhi; class QRhiRenderBuffer; class QRhiRenderPassDescriptor; class QRhiTextureRenderTarget; +class QRhiTexture; class QOffscreenSurface; +class QVulkanInstance; namespace vnm::benchmark { @@ -56,14 +63,29 @@ struct Benchmark_config { double volatility = 0.02; double rate = 1000.0; // samples per second std::size_t ring_capacity = 100000; + std::size_t series_count = 1; + bool stack_series = false; bool extended_metadata = false; bool quiet = false; // Suppress console output during benchmark bool show_text = true; // Text/font rendering (default: on) std::string backend = "qrhi-offscreen"; // "qrhi" or "qrhi-offscreen" + // Requested graphics API. "native" selects the platform's normal QRhi + // backend; Null is used only when explicitly requested. + std::string graphics_backend = "native"; bool finish = false; // Wait for GPU completion after offscreen frames - // Visual-diff mode: pre-fill 5 fixed samples (4-segment zig-zag) and - // skip the generator. Useful for side-by-side rendering comparisons. + int framebuffer_width = 1200; + int framebuffer_height = 720; + std::size_t sample_count = 4; + std::size_t warmup_frames = 2; + // Zero keeps duration-based execution. Nonzero runs exactly this many + // measured frames after warmup and is preferred for repeatable measurements. + std::size_t measured_frames = 0; + std::string scenario = "ad-hoc"; + bool capture_pixel_checksum = false; + // Static mode pre-fills a representative deterministic data window and + // skips the producer thread. bool static_data = false; + std::size_t static_sample_count = 10'000; // Line thickness in pixels (default 1.5 for animated runs; bumped via // --line-px when comparing visuals so segments are easy to inspect). double line_width_px = 1.5; @@ -73,6 +95,16 @@ struct Benchmark_config { double point_diameter_px = 1.0; }; +struct Graphics_device_info { + std::string backend = "uninitialized"; + std::string device_name = "unknown"; + std::string device_type = "unknown"; + std::string fallback_surface_requested_format = "not-applicable"; + std::string fallback_surface_resolved_format = "not-applicable"; + std::uint64_t device_id = 0; + std::uint64_t vendor_id = 0; +}; + /// QRhi benchmark window using the public QQuickRhiItem Plot_widget path. class Benchmark_rhi_window : public QQuickWindow { Q_OBJECT @@ -86,6 +118,12 @@ class Benchmark_rhi_window : public QQuickWindow { const Benchmark_config& config() const { return m_config; } std::size_t samples_generated() const { return m_samples_generated.load(); } std::chrono::system_clock::time_point started_at() const { return m_started_at; } + Graphics_device_info graphics_device_info() const; + std::size_t measured_frame_count() const { return m_measured_frame_count; } + std::uint64_t pixel_checksum() const { return 0; } + std::size_t pixel_nonuniform_count() const { return 0; } + std::size_t stack_sum_pixel_count() const { return 0; } + std::string phase_trace_path() const { return {}; } signals: void benchmark_finished(); @@ -100,21 +138,22 @@ private slots: void stop_generator_thread(); void fill_static_data(); void update_plot_view(); + void record_final_statistics(); Benchmark_config m_config; Benchmark_profiler m_profiler; - std::unique_ptr> m_bar_buffer; - std::unique_ptr> m_trade_buffer; - std::unique_ptr> m_bar_source; - std::unique_ptr> m_trade_source; + std::vector>> m_bar_buffers; + std::vector>> m_trade_buffers; + std::vector>> m_bar_sources; + std::vector>> m_trade_sources; Brownian_generator m_generator; std::thread m_generator_thread; std::atomic m_stop_generator{false}; vnm::plot::Plot_widget* m_plot = nullptr; - std::shared_ptr m_series; + std::vector> m_series; vnm::plot::Plot_config m_render_config; @@ -133,6 +172,11 @@ private slots: double m_timer_interval_max_ms = 0.0; std::chrono::system_clock::time_point m_started_at; std::atomic m_samples_generated{0}; + mutable std::mutex m_graphics_info_mutex; + Graphics_device_info m_graphics_info; + std::size_t m_measured_frame_count = 0; + std::size_t m_warmup_frames_remaining = 0; + bool m_measurement_finished = false; }; /// Direct QRhi benchmark that renders into an offscreen target without Qt Quick. @@ -147,6 +191,12 @@ class Benchmark_rhi_offscreen_runner const Benchmark_config& config() const { return m_config; } std::size_t samples_generated() const { return m_samples_generated.load(); } std::chrono::system_clock::time_point started_at() const { return m_started_at; } + Graphics_device_info graphics_device_info() const { return m_graphics_info; } + std::size_t measured_frame_count() const { return m_measured_frame_count; } + std::uint64_t pixel_checksum() const { return m_pixel_checksum; } + std::size_t pixel_nonuniform_count() const { return m_pixel_nonuniform_count; } + std::size_t stack_sum_pixel_count() const { return m_stack_sum_pixel_count; } + std::string phase_trace_path() const { return m_phase_trace_path; } bool run(std::string& error_message); @@ -156,25 +206,40 @@ class Benchmark_rhi_offscreen_runner void setup_series(); void generator_thread_func(); void stop_generator_thread(); + void pause_generator_publication(); + void resume_generator_publication(); + bool wait_for_generator_permission( + Publication_rate_clock::duration& paused_duration); void fill_static_data(); bool initialize_rhi(std::string& error_message); - bool render_frame(std::string& error_message); + bool render_frame(std::string& error_message, bool measured); + void record_final_statistics(double measured_seconds); + void record_phase(const char* phase, std::size_t frame = 0) const; Benchmark_config m_config; Benchmark_profiler m_profiler; - std::unique_ptr> m_bar_buffer; - std::unique_ptr> m_trade_buffer; - std::unique_ptr> m_bar_source; - std::unique_ptr> m_trade_source; + std::vector>> m_bar_buffers; + std::vector>> m_trade_buffers; + std::vector>> m_bar_sources; + std::vector>> m_trade_sources; Brownian_generator m_generator; std::thread m_generator_thread; std::atomic m_stop_generator{false}; + std::atomic m_generator_pause_requested{false}; + std::atomic m_generator_rate_rebase_requested{false}; + std::mutex m_generator_control_mutex; + std::condition_variable m_generator_control_cv; + bool m_generator_paused = false; std::unique_ptr m_fallback_surface; +#if QT_CONFIG(vulkan) && __has_include() + std::unique_ptr m_vulkan_instance; +#endif std::unique_ptr m_rhi; std::unique_ptr m_color_buffer; + std::unique_ptr m_resolve_texture; std::unique_ptr m_render_target; std::unique_ptr m_render_pass_descriptor; @@ -198,6 +263,20 @@ class Benchmark_rhi_offscreen_runner float m_v_max = 110.0f; std::chrono::system_clock::time_point m_started_at; std::atomic m_samples_generated{0}; + Graphics_device_info m_graphics_info; + std::size_t m_measured_frame_count = 0; + double m_cold_setup_ms = 0.0; + double m_cold_backend_ms = 0.0; + double m_cold_first_submission_ms = 0.0; + double m_cold_shader_pipeline_prepare_ms = 0.0; + double m_cold_total_ms = 0.0; + double m_last_prepare_ms = 0.0; + std::uint64_t m_pixel_checksum = 0; + std::size_t m_pixel_nonuniform_count = 0; + std::size_t m_stack_sum_pixel_count = 0; + std::chrono::steady_clock::time_point m_phase_trace_started; + std::string m_phase_trace_path; + mutable std::ofstream m_phase_trace; }; } // namespace vnm::benchmark diff --git a/benchmark/include/path_io.h b/benchmark/include/path_io.h new file mode 100644 index 00000000..5309e537 --- /dev/null +++ b/benchmark/include/path_io.h @@ -0,0 +1,41 @@ +// vnm_plot Benchmark - Native filesystem path adaptation + +#ifndef VNM_PLOT_BENCHMARK_PATH_IO_H +#define VNM_PLOT_BENCHMARK_PATH_IO_H + +#include +#include + +namespace vnm::benchmark { + +inline std::filesystem::path path_for_file_io( + const std::filesystem::path& logical_path) +{ +#if defined(_WIN32) + if (logical_path.empty()) { + return logical_path; + } + + const std::wstring& logical_native = logical_path.native(); + if (logical_native.starts_with(L"\\\\?\\") || + logical_native.starts_with(L"\\\\.\\")) { + return logical_path; + } + + const std::filesystem::path absolute_path = ( + logical_path.is_absolute() + ? logical_path + : std::filesystem::absolute(logical_path)).lexically_normal(); + const std::wstring& native = absolute_path.native(); + if (native.starts_with(L"\\\\")) { + return std::filesystem::path(L"\\\\?\\UNC\\" + native.substr(2)); + } + return std::filesystem::path(L"\\\\?\\" + native); +#else + return logical_path; +#endif +} + +} // namespace vnm::benchmark + +#endif // VNM_PLOT_BENCHMARK_PATH_IO_H diff --git a/benchmark/include/publication_rate_clock.h b/benchmark/include/publication_rate_clock.h new file mode 100644 index 00000000..31dba82f --- /dev/null +++ b/benchmark/include/publication_rate_clock.h @@ -0,0 +1,50 @@ +// vnm_plot Benchmark - Pause-aware publication rate schedule + +#ifndef VNM_PLOT_BENCHMARK_PUBLICATION_RATE_CLOCK_H +#define VNM_PLOT_BENCHMARK_PUBLICATION_RATE_CLOCK_H + +#include +#include +#include + +namespace vnm::benchmark { + +class Publication_rate_clock { +public: + using clock = std::chrono::steady_clock; + using duration = clock::duration; + using time_point = clock::time_point; + + explicit Publication_rate_clock(time_point started_at = clock::now()) noexcept + : m_started_at(started_at) + { + } + + void exclude_pause(duration paused) noexcept + { + m_started_at += paused; + } + + void rebase(time_point now, std::size_t generated_samples) noexcept + { + m_started_at = now; + m_base_samples = generated_samples; + } + + std::size_t target_samples(time_point now, double samples_per_second) const noexcept + { + const double elapsed_seconds = std::max( + 0.0, + std::chrono::duration(now - m_started_at).count()); + return m_base_samples + + static_cast(elapsed_seconds * samples_per_second); + } + +private: + time_point m_started_at; + std::size_t m_base_samples = 0; +}; + +} // namespace vnm::benchmark + +#endif // VNM_PLOT_BENCHMARK_PUBLICATION_RATE_CLOCK_H diff --git a/benchmark/include/ring_buffer.h b/benchmark/include/ring_buffer.h index 5d8e682c..0fb725aa 100644 --- a/benchmark/include/ring_buffer.h +++ b/benchmark/include/ring_buffer.h @@ -8,8 +8,11 @@ #include #include +#include +#include #include #include +#include #include #include #include @@ -23,6 +26,18 @@ namespace vnm::benchmark { template class Ring_buffer { public: + struct Statistics { + std::size_t occupancy = 0; + std::size_t high_water_occupancy = 0; + std::uint64_t revision = 0; + std::uint64_t published_samples = 0; + std::uint64_t overwritten_samples = 0; + std::uint64_t producer_wait_count = 0; + std::uint64_t producer_wait_total_ns = 0; + std::uint64_t producer_wait_min_ns = 0; + std::uint64_t producer_wait_max_ns = 0; + }; + /// Result of a copy operation struct Copy_result { std::size_t count; ///< Number of samples copied @@ -67,25 +82,36 @@ class Ring_buffer { /// Push a single sample. Overwrites oldest if full. void push(const T& sample) { + const auto wait_started = std::chrono::steady_clock::now(); std::unique_lock lock(m_mutex); + const double wait_ns = producer_wait_ns(wait_started); const std::size_t cap = m_buffer.size(); const std::size_t h = m_head.load(std::memory_order_relaxed); const std::size_t t = m_tail.load(std::memory_order_relaxed); - const uint64_t seq = m_sequence.load(std::memory_order_relaxed); + const std::size_t count = m_count.load(std::memory_order_relaxed); - // Check if buffer is currently full (head == tail && has data) - const bool was_full = (h == t) && (seq > 0); + const bool was_full = count == cap; m_buffer[h] = sample; m_head.store((h + 1) % cap, std::memory_order_relaxed); - // If buffer was full, advance tail to discard oldest if (was_full) { m_tail.store((t + 1) % cap, std::memory_order_relaxed); } + else { + const std::size_t new_count = count + 1; + m_count.store(new_count, std::memory_order_relaxed); + update_high_water(new_count); + } - m_sequence.fetch_add(1, std::memory_order_release); + publish_next_revision(); + lock.unlock(); + m_published_samples.fetch_add(1, std::memory_order_relaxed); + if (was_full) { + m_overwritten_samples.fetch_add(1, std::memory_order_relaxed); + } + record_producer_wait(wait_ns); } /// Push multiple samples. Overwrites oldest as needed. @@ -94,31 +120,41 @@ class Ring_buffer { return; } + const auto wait_started = std::chrono::steady_clock::now(); std::unique_lock lock(m_mutex); + const double wait_ns = producer_wait_ns(wait_started); const std::size_t cap = m_buffer.size(); + std::size_t occupancy = m_count.load(std::memory_order_relaxed); + std::size_t overwritten = 0; + std::uint64_t revision = m_revision.load(std::memory_order_relaxed); for (std::size_t i = 0; i < count; ++i) { const std::size_t h = m_head.load(std::memory_order_relaxed); const std::size_t t = m_tail.load(std::memory_order_relaxed); - const uint64_t seq = m_sequence.load(std::memory_order_relaxed); - - // Check if buffer is currently full (head == tail && has data) - const bool was_full = (h == t) && (seq > 0); + const bool was_full = occupancy == cap; m_buffer[h] = samples[i]; m_head.store((h + 1) % cap, std::memory_order_relaxed); - // If buffer was full, advance tail to discard oldest if (was_full) { m_tail.store((t + 1) % cap, std::memory_order_relaxed); + ++overwritten; + } + else { + ++occupancy; } - m_sequence.fetch_add(1, std::memory_order_relaxed); + revision = next_revision(revision); } - // Release fence for change-detection consistency - std::atomic_thread_fence(std::memory_order_release); + m_count.store(occupancy, std::memory_order_relaxed); + update_high_water(occupancy); + m_revision.store(revision, std::memory_order_release); + lock.unlock(); + m_published_samples.fetch_add(count, std::memory_order_relaxed); + m_overwritten_samples.fetch_add(overwritten, std::memory_order_relaxed); + record_producer_wait(wait_ns); } // ------------------------------------------------------------------------- @@ -131,39 +167,22 @@ class Ring_buffer { View_result view; view.lock = std::make_shared>(m_mutex); - const std::size_t h = m_head.load(std::memory_order_acquire); const std::size_t t = m_tail.load(std::memory_order_acquire); - const uint64_t seq = m_sequence.load(std::memory_order_acquire); - view.sequence = seq; + const std::size_t count = m_count.load(std::memory_order_acquire); + view.sequence = m_revision.load(std::memory_order_acquire); - if (h == t && seq == 0) { + if (count == 0) { return view; } const std::size_t cap = m_buffer.size(); - if (h == t) { - // Full buffer - view.data = m_buffer.data() + t; - view.count = cap; - if (t > 0) { - view.data2 = m_buffer.data(); - view.count2 = t; - } - return view; - } - - if (h > t) { - // Contiguous - view.data = m_buffer.data() + t; - view.count = h - t; - return view; - } - - // Wrapped view.data = m_buffer.data() + t; - view.data2 = m_buffer.data(); - view.count2 = h; - view.count = (cap - t) + h; + view.count = count; + const std::size_t first_count = std::min(count, cap - t); + if (first_count < count) { + view.data2 = m_buffer.data(); + view.count2 = count - first_count; + } return view; } @@ -175,51 +194,30 @@ class Ring_buffer { VNM_PLOT_PROFILE_SCOPE(m_profiler, "renderer.frame.data_copy"); std::shared_lock lock(m_mutex); - const std::size_t h = m_head.load(std::memory_order_acquire); const std::size_t t = m_tail.load(std::memory_order_acquire); - const uint64_t seq = m_sequence.load(std::memory_order_acquire); + const std::size_t count = m_count.load(std::memory_order_acquire); + const uint64_t revision = m_revision.load(std::memory_order_acquire); - if (h == t && seq == 0) { - // Empty buffer (never written to) + if (count == 0) { dest.clear(); - return {0, seq}; + return {0, revision}; } const std::size_t cap = m_buffer.size(); - std::size_t count; - - if (h > t) { - // Contiguous: [tail, head) - count = h - t; - dest.resize(count); - std::copy(m_buffer.begin() + t, m_buffer.begin() + h, dest.begin()); - } - else - if (h < t) { - // Wrapped: [tail, cap) + [0, head) - const std::size_t part1 = cap - t; - const std::size_t part2 = h; - count = part1 + part2; - dest.resize(count); - std::copy(m_buffer.begin() + t, m_buffer.end(), dest.begin()); - std::copy(m_buffer.begin(), m_buffer.begin() + h, dest.begin() + part1); - } - else { - // h == t && seq > 0: buffer is full (all N elements valid) - count = cap; - dest.resize(count); - // Copy from tail to end, then from beginning to tail - std::copy(m_buffer.begin() + t, m_buffer.end(), dest.begin()); - std::copy(m_buffer.begin(), m_buffer.begin() + t, dest.begin() + (cap - t)); + const std::size_t first_count = std::min(count, cap - t); + dest.resize(count); + std::copy_n(m_buffer.begin() + t, first_count, dest.begin()); + if (first_count < count) { + std::copy_n(m_buffer.begin(), count - first_count, dest.begin() + first_count); } - return {count, seq}; + return {count, revision}; } /// Query current sequence without copying (for change detection). /// Returns sequence number which increments on every push. uint64_t sequence() const { - return m_sequence.load(std::memory_order_acquire); + return m_revision.load(std::memory_order_acquire); } // ------------------------------------------------------------------------- @@ -234,38 +232,117 @@ class Ring_buffer { /// Return current number of valid samples. /// Note: This is a snapshot and may change immediately after return. std::size_t size() const { - std::shared_lock lock(m_mutex); - const std::size_t h = m_head.load(std::memory_order_acquire); - const std::size_t t = m_tail.load(std::memory_order_acquire); - const uint64_t seq = m_sequence.load(std::memory_order_acquire); - - if (h == t) { - return seq == 0 ? 0 : m_buffer.size(); // Empty or full - } - if (h > t) { - return h - t; - } - return m_buffer.size() - t + h; + return m_count.load(std::memory_order_acquire); } /// Check if buffer is empty. bool empty() const { - return m_sequence.load(std::memory_order_acquire) == 0; + return m_count.load(std::memory_order_acquire) == 0; } - /// Clear all data and reset sequence. + /// Clear all data while advancing the stable revision. void clear() { std::unique_lock lock(m_mutex); m_head.store(0, std::memory_order_relaxed); m_tail.store(0, std::memory_order_relaxed); - m_sequence.store(0, std::memory_order_release); + m_count.store(0, std::memory_order_relaxed); + publish_next_revision(); + } + + Statistics statistics() const { + Statistics stats; + stats.occupancy = m_count.load(std::memory_order_acquire); + stats.high_water_occupancy = m_high_water_occupancy.load(std::memory_order_acquire); + stats.revision = m_revision.load(std::memory_order_acquire); + stats.published_samples = m_published_samples.load(std::memory_order_acquire); + stats.overwritten_samples = m_overwritten_samples.load(std::memory_order_acquire); + stats.producer_wait_count = m_producer_wait_count.load(std::memory_order_acquire); + stats.producer_wait_total_ns = m_producer_wait_total_ns.load(std::memory_order_acquire); + const std::uint64_t wait_min = m_producer_wait_min_ns.load(std::memory_order_acquire); + stats.producer_wait_min_ns = wait_min == std::numeric_limits::max() + ? 0 + : wait_min; + stats.producer_wait_max_ns = m_producer_wait_max_ns.load(std::memory_order_acquire); + return stats; + } + + /// Start a measurement interval without changing content or revision. + void reset_measurement_statistics() + { + std::unique_lock lock(m_mutex); + const std::size_t occupancy = m_count.load(std::memory_order_relaxed); + m_high_water_occupancy.store(occupancy, std::memory_order_relaxed); + m_published_samples.store(0, std::memory_order_relaxed); + m_overwritten_samples.store(0, std::memory_order_relaxed); + m_producer_wait_count.store(0, std::memory_order_relaxed); + m_producer_wait_total_ns.store(0, std::memory_order_relaxed); + m_producer_wait_min_ns.store( + std::numeric_limits::max(), + std::memory_order_relaxed); + m_producer_wait_max_ns.store(0, std::memory_order_relaxed); } private: + static std::uint64_t next_revision(std::uint64_t revision) { + return revision == std::numeric_limits::max() + ? 1 + : revision + 1; + } + + void publish_next_revision() { + const std::uint64_t revision = m_revision.load(std::memory_order_relaxed); + m_revision.store(next_revision(revision), std::memory_order_release); + } + + void update_high_water(std::size_t occupancy) { + std::size_t previous = m_high_water_occupancy.load(std::memory_order_relaxed); + while (previous < occupancy && !m_high_water_occupancy.compare_exchange_weak( + previous, + occupancy, + std::memory_order_relaxed)) + {} + } + + static double producer_wait_ns(std::chrono::steady_clock::time_point wait_started) { + return std::chrono::duration( + std::chrono::steady_clock::now() - wait_started).count(); + } + + void record_producer_wait(double wait_ns) { + const std::uint64_t value = wait_ns <= 0.0 + ? 0 + : static_cast(std::ceil(wait_ns)); + m_producer_wait_count.fetch_add(1, std::memory_order_relaxed); + m_producer_wait_total_ns.fetch_add(value, std::memory_order_relaxed); + + std::uint64_t previous_min = m_producer_wait_min_ns.load(std::memory_order_relaxed); + while (value < previous_min && !m_producer_wait_min_ns.compare_exchange_weak( + previous_min, + value, + std::memory_order_relaxed)) + {} + + std::uint64_t previous_max = m_producer_wait_max_ns.load(std::memory_order_relaxed); + while (value > previous_max && !m_producer_wait_max_ns.compare_exchange_weak( + previous_max, + value, + std::memory_order_relaxed)) + {} + } + std::vector m_buffer; std::atomic m_head{0}; ///< Write position (next slot to write) std::atomic m_tail{0}; ///< Start of valid data - std::atomic m_sequence{0}; ///< Increments on every push + std::atomic m_count{0}; + std::atomic m_high_water_occupancy{0}; + std::atomic m_revision{1}; + std::atomic m_published_samples{0}; + std::atomic m_overwritten_samples{0}; + std::atomic m_producer_wait_count{0}; + std::atomic m_producer_wait_total_ns{0}; + std::atomic m_producer_wait_min_ns{ + std::numeric_limits::max()}; + std::atomic m_producer_wait_max_ns{0}; mutable std::shared_mutex m_mutex; ///< Protects m_buffer during copy vnm::plot::Profiler* m_profiler = nullptr; }; diff --git a/benchmark/src/allocation_tracker.cpp b/benchmark/src/allocation_tracker.cpp new file mode 100644 index 00000000..cfb76b8c --- /dev/null +++ b/benchmark/src/allocation_tracker.cpp @@ -0,0 +1,228 @@ +// vnm_plot Benchmark - Render-thread CPU allocation tracking + +#include "allocation_tracker.h" + +#include +#include + +#if defined(_MSC_VER) +#include +#endif + +namespace { + +thread_local bool g_measure_allocations = false; +thread_local std::uint32_t g_allocation_suppression_depth = 0; +thread_local std::uint64_t g_allocation_count = 0; +thread_local std::uint64_t g_allocation_bytes = 0; +void record_allocation(std::size_t size) noexcept +{ + if (g_measure_allocations && g_allocation_suppression_depth == 0) { + ++g_allocation_count; + g_allocation_bytes += static_cast(size); + } +} + +void* allocate(std::size_t size) +{ + const std::size_t retained_size = size == 0 ? 1 : size; + void* memory = std::malloc(retained_size); + if (!memory) { + throw std::bad_alloc(); + } + record_allocation(retained_size); + return memory; +} + +void* allocate_aligned(std::size_t size, std::size_t alignment) +{ + const std::size_t retained_size = size == 0 ? 1 : size; + const std::size_t effective_alignment = alignment < sizeof(void*) + ? sizeof(void*) + : alignment; + void* memory = nullptr; +#if defined(_MSC_VER) + memory = _aligned_malloc(retained_size, effective_alignment); +#else + if (posix_memalign(&memory, effective_alignment, retained_size) != 0) { + memory = nullptr; + } +#endif + if (!memory) { + throw std::bad_alloc(); + } + record_allocation(retained_size); + return memory; +} + +void free_aligned(void* memory) noexcept +{ +#if defined(_MSC_VER) + _aligned_free(memory); +#else + std::free(memory); +#endif +} + +} // namespace + +namespace vnm::benchmark { + +void begin_thread_allocation_measurement() noexcept +{ + g_allocation_count = 0; + g_allocation_bytes = 0; + g_measure_allocations = true; +} + +Thread_allocation_measurement end_thread_allocation_measurement() noexcept +{ + g_measure_allocations = false; + return {g_allocation_count, g_allocation_bytes}; +} + +void suspend_thread_allocation_measurement() noexcept +{ + ++g_allocation_suppression_depth; +} + +void resume_thread_allocation_measurement() noexcept +{ + if (g_allocation_suppression_depth > 0) { + --g_allocation_suppression_depth; + } +} + +} // namespace vnm::benchmark + +void* operator new(std::size_t size) +{ + return allocate(size); +} + +void* operator new[](std::size_t size) +{ + return allocate(size); +} + +void operator delete(void* memory) noexcept +{ + std::free(memory); +} + +void operator delete[](void* memory) noexcept +{ + std::free(memory); +} + +void operator delete(void* memory, std::size_t) noexcept +{ + std::free(memory); +} + +void operator delete[](void* memory, std::size_t) noexcept +{ + std::free(memory); +} + +void* operator new(std::size_t size, const std::nothrow_t&) noexcept +{ + try { + return allocate(size); + } + catch (...) { + return nullptr; + } +} + +void* operator new[](std::size_t size, const std::nothrow_t&) noexcept +{ + try { + return allocate(size); + } + catch (...) { + return nullptr; + } +} + +void operator delete(void* memory, const std::nothrow_t&) noexcept +{ + std::free(memory); +} + +void operator delete[](void* memory, const std::nothrow_t&) noexcept +{ + std::free(memory); +} + +void* operator new(std::size_t size, std::align_val_t alignment) +{ + return allocate_aligned(size, static_cast(alignment)); +} + +void* operator new[](std::size_t size, std::align_val_t alignment) +{ + return allocate_aligned(size, static_cast(alignment)); +} + +void operator delete(void* memory, std::align_val_t) noexcept +{ + free_aligned(memory); +} + +void operator delete[](void* memory, std::align_val_t) noexcept +{ + free_aligned(memory); +} + +void operator delete(void* memory, std::size_t, std::align_val_t) noexcept +{ + free_aligned(memory); +} + +void operator delete[](void* memory, std::size_t, std::align_val_t) noexcept +{ + free_aligned(memory); +} + +void* operator new( + std::size_t size, + std::align_val_t alignment, + const std::nothrow_t&) noexcept +{ + try { + return allocate_aligned(size, static_cast(alignment)); + } + catch (...) { + return nullptr; + } +} + +void* operator new[]( + std::size_t size, + std::align_val_t alignment, + const std::nothrow_t&) noexcept +{ + try { + return allocate_aligned(size, static_cast(alignment)); + } + catch (...) { + return nullptr; + } +} + +void operator delete( + void* memory, + std::align_val_t, + const std::nothrow_t&) noexcept +{ + free_aligned(memory); +} + +void operator delete[]( + void* memory, + std::align_val_t, + const std::nothrow_t&) noexcept +{ + free_aligned(memory); +} diff --git a/benchmark/src/benchmark_window.cpp b/benchmark/src/benchmark_window.cpp index ed12092b..7fb720ca 100644 --- a/benchmark/src/benchmark_window.cpp +++ b/benchmark/src/benchmark_window.cpp @@ -1,32 +1,213 @@ // vnm_plot Benchmark - Window Implementation #include "benchmark_window.h" +#include "allocation_tracker.h" +#include "path_io.h" #include #include #include +#include #include #include #include +#include #include +#if QT_CONFIG(vulkan) && __has_include() +#include +#endif #include +#include #include #include #include #include #include +#include +#include #include #include +#include #include +#if defined(Q_OS_WIN) +#include +#include +#elif defined(Q_OS_UNIX) +#include +#endif + namespace vnm::benchmark { namespace { +std::string device_type_name(QRhiDriverInfo::DeviceType type) +{ + switch (type) { + case QRhiDriverInfo::IntegratedDevice: return "integrated"; + case QRhiDriverInfo::DiscreteDevice: return "discrete"; + case QRhiDriverInfo::ExternalDevice: return "external"; + case QRhiDriverInfo::VirtualDevice: return "virtual"; + case QRhiDriverInfo::CpuDevice: return "cpu"; + case QRhiDriverInfo::UnknownDevice: return "unknown"; + } + return "unknown"; +} + +std::string frame_op_result_name(QRhi::FrameOpResult result) +{ + switch (result) { + case QRhi::FrameOpSuccess: return "success"; + case QRhi::FrameOpError: return "error"; + case QRhi::FrameOpSwapChainOutOfDate: return "swap-chain-out-of-date"; + case QRhi::FrameOpDeviceLost: return "device-lost"; + } + return "unknown"; +} + +std::string surface_format_identity(const QSurfaceFormat& format) +{ + const char* profile = "none"; + if (format.profile() == QSurfaceFormat::CoreProfile) { + profile = "core"; + } + else + if (format.profile() == QSurfaceFormat::CompatibilityProfile) { + profile = "compatibility"; + } + return std::to_string(format.majorVersion()) + "." + + std::to_string(format.minorVersion()) + "|" + profile + "|" + + std::to_string(format.samples()); +} + +Graphics_device_info graphics_device_info_from_rhi(QRhi& rhi) +{ + const QRhiDriverInfo driver = rhi.driverInfo(); + Graphics_device_info info; + info.backend = rhi.backendName(); + info.device_name = std::string(driver.deviceName.constData(), driver.deviceName.size()); + info.device_type = device_type_name(driver.deviceType); + info.device_id = driver.deviceId; + info.vendor_id = driver.vendorId; + return info; +} + +std::uint64_t process_memory_high_water_bytes() +{ +#if defined(Q_OS_WIN) + PROCESS_MEMORY_COUNTERS_EX counters{}; + counters.cb = sizeof(counters); + if (GetProcessMemoryInfo( + GetCurrentProcess(), + reinterpret_cast(&counters), + sizeof(counters))) + { + return static_cast(counters.PeakWorkingSetSize); + } +#elif defined(Q_OS_UNIX) + rusage usage{}; + if (getrusage(RUSAGE_SELF, &usage) == 0) { +#if defined(Q_OS_MACOS) + return static_cast(usage.ru_maxrss); +#else + return static_cast(usage.ru_maxrss) * 1024u; +#endif + } +#endif + return 0; +} + +double steady_clock_resolution_ns() +{ + double resolution = std::numeric_limits::max(); + auto previous = std::chrono::steady_clock::now(); + for (int sample = 0; sample < 10'000; ++sample) { + const auto current = std::chrono::steady_clock::now(); + const double delta = std::chrono::duration( + current - previous).count(); + if (delta > 0.0) { + resolution = std::min(resolution, delta); + } + previous = current; + } + return std::isfinite(resolution) ? resolution : 0.0; +} + +void ensure_interval_observations(Benchmark_profiler& profiler) +{ + profiler.ensure_observation("benchmark.producer.lock_wait_ns"); + profiler.ensure_observation("renderer.series_window.monotonicity_scan_count"); + profiler.ensure_observation("renderer.series_window.monotonicity_scan_samples"); + profiler.ensure_observation("renderer.frame.gpu_buffer_allocation_bytes"); + profiler.ensure_observation("renderer.frame.gpu_buffer_allocation_count"); + profiler.ensure_observation("benchmark.frame.cpu_allocation_bytes"); + profiler.ensure_observation("benchmark.frame.cpu_allocation_count"); +} + +template +void record_ring_statistics( + Benchmark_profiler& profiler, + const std::vector>>& buffers, + double measured_seconds) +{ + if (buffers.empty()) { + return; + } + typename Ring_buffer::Statistics total; + std::uint64_t producer_wait_min_ns = std::numeric_limits::max(); + for (const auto& buffer : buffers) { + const auto stats = buffer->statistics(); + total.occupancy += stats.occupancy; + total.high_water_occupancy += stats.high_water_occupancy; + total.published_samples += stats.published_samples; + total.overwritten_samples += stats.overwritten_samples; + total.producer_wait_count += stats.producer_wait_count; + total.producer_wait_total_ns += stats.producer_wait_total_ns; + if (stats.producer_wait_count > 0) { + producer_wait_min_ns = std::min(producer_wait_min_ns, stats.producer_wait_min_ns); + total.producer_wait_max_ns = std::max( + total.producer_wait_max_ns, + stats.producer_wait_max_ns); + } + } + profiler.record_observation("benchmark.ring.occupancy", static_cast(total.occupancy)); + profiler.record_observation( + "benchmark.ring.high_water_occupancy", + static_cast(total.high_water_occupancy)); + profiler.record_observation( + "benchmark.ring.published_samples", + static_cast(total.published_samples)); + profiler.record_observation( + "benchmark.ring.overwritten_samples", + static_cast(total.overwritten_samples)); + if (measured_seconds > 0.0) { + profiler.record_observation( + "benchmark.ring.published_samples_per_second", + static_cast(total.published_samples) / measured_seconds); + } + if (total.producer_wait_count > 0) { + profiler.record_observation_summary( + "benchmark.producer.lock_wait_ns", + total.producer_wait_count, + static_cast(total.producer_wait_total_ns), + static_cast(producer_wait_min_ns), + static_cast(total.producer_wait_max_ns)); + } +} + +template +void reset_ring_measurement_statistics( + std::vector>>& buffers) +{ + for (auto& buffer : buffers) { + buffer->reset_measurement_statistics(); + } +} + glm::mat4 to_glm_mat4(const QMatrix4x4& matrix) { const float* data = matrix.constData(); @@ -51,7 +232,8 @@ void update_view_range_from_source( std::int64_t& t_max, std::int64_t& t_available_min, float& v_min, - float& v_max) + float& v_max, + std::size_t stack_size) { if (!source) { return; @@ -90,6 +272,12 @@ void update_view_range_from_source( t_max = t_last; t_min = std::max(t_first, t_last - k_window_ns); + // Release the direct-view shared lock before query_v_range() takes its own + // snapshot. On writer-priority shared_mutex implementations, retaining the + // first read lock while a producer queues can deadlock this thread's nested + // read behind the waiting writer. + result = {}; + vnm::plot::Data_access_policy access = data_type == "Trades" ? make_trade_access_policy() : make_bar_access_policy(); @@ -113,6 +301,11 @@ void update_view_range_from_source( } v_min = lo - padding; v_max = hi + padding; + if (stack_size > 1) { + const float scale = static_cast(stack_size); + v_min = std::min(0.0f, v_min * scale); + v_max = std::max(0.0f, v_max * scale); + } } } @@ -137,17 +330,31 @@ Benchmark_rhi_window::Benchmark_rhi_window(const Benchmark_config& config) setFormat(format); setTitle("vnm_plot Benchmark QRhi"); - resize(1200, 720); + resize(m_config.framebuffer_width, m_config.framebuffer_height); if (m_config.data_type == "Trades") { - m_trade_buffer = std::make_unique>(m_config.ring_capacity); - m_trade_buffer->set_profiler(&m_profiler); - m_trade_source = std::make_unique>(*m_trade_buffer); + m_trade_buffers.reserve(m_config.series_count); + m_trade_sources.reserve(m_config.series_count); + for (std::size_t index = 0; index < m_config.series_count; ++index) { + auto buffer = std::make_unique>(m_config.ring_capacity); + buffer->set_profiler(&m_profiler); + auto source = std::make_unique>(*buffer); + source->set_profiler(&m_profiler); + m_trade_buffers.push_back(std::move(buffer)); + m_trade_sources.push_back(std::move(source)); + } } else { - m_bar_buffer = std::make_unique>(m_config.ring_capacity); - m_bar_buffer->set_profiler(&m_profiler); - m_bar_source = std::make_unique>(*m_bar_buffer); + m_bar_buffers.reserve(m_config.series_count); + m_bar_sources.reserve(m_config.series_count); + for (std::size_t index = 0; index < m_config.series_count; ++index) { + auto buffer = std::make_unique>(m_config.ring_capacity); + buffer->set_profiler(&m_profiler); + auto source = std::make_unique>(*buffer); + source->set_profiler(&m_profiler); + m_bar_buffers.push_back(std::move(buffer)); + m_bar_sources.push_back(std::move(source)); + } } m_render_config.dark_mode = true; @@ -183,11 +390,24 @@ Benchmark_rhi_window::Benchmark_rhi_window(const Benchmark_config& config) }); connect(&m_render_timer, &QTimer::timeout, this, &Benchmark_rhi_window::on_render_timer); connect(&m_benchmark_timer, &QTimer::timeout, this, &Benchmark_rhi_window::on_benchmark_timeout); + connect( + this, + &QQuickWindow::sceneGraphInitialized, + this, + [this]() { + auto* rhi = static_cast(rendererInterface()->getResource( + this, + QSGRendererInterface::RhiResource)); + if (!rhi) { + return; + } + std::lock_guard lock(m_graphics_info_mutex); + m_graphics_info = graphics_device_info_from_rhi(*rhi); + }, + Qt::DirectConnection); setup_series(); - m_started_at = std::chrono::system_clock::now(); - if (m_config.static_data) { fill_static_data(); } @@ -196,9 +416,15 @@ Benchmark_rhi_window::Benchmark_rhi_window(const Benchmark_config& config) m_generator_thread = std::thread(&Benchmark_rhi_window::generator_thread_func, this); } + m_warmup_frames_remaining = m_config.warmup_frames; + if (m_warmup_frames_remaining == 0) { + m_started_at = std::chrono::system_clock::now(); + if (m_config.measured_frames == 0) { + m_benchmark_timer.setSingleShot(true); + m_benchmark_timer.start(static_cast(m_config.duration_seconds * 1000)); + } + } m_render_timer.start(0); - m_benchmark_timer.setSingleShot(true); - m_benchmark_timer.start(static_cast(m_config.duration_seconds * 1000)); } Benchmark_rhi_window::~Benchmark_rhi_window() @@ -208,6 +434,12 @@ Benchmark_rhi_window::~Benchmark_rhi_window() m_plot = nullptr; } +Graphics_device_info Benchmark_rhi_window::graphics_device_info() const +{ + std::lock_guard lock(m_graphics_info_mutex); + return m_graphics_info; +} + void Benchmark_rhi_window::stop_generator_thread() { if (m_generator_thread.joinable()) { @@ -231,10 +463,16 @@ void Benchmark_rhi_window::generator_thread_func() while (current_count < static_cast(target_samples) && !m_stop_generator.load()) { if (m_config.data_type == "Trades") { - m_trade_buffer->push(m_generator.next_trade()); + const Trade_sample sample = m_generator.next_trade(); + for (auto& buffer : m_trade_buffers) { + buffer->push(sample); + } } else { - m_bar_buffer->push(m_generator.next_bar()); + const Bar_sample sample = m_generator.next_bar(); + for (auto& buffer : m_bar_buffers) { + buffer->push(sample); + } } ++m_samples_generated; ++current_count; @@ -246,74 +484,65 @@ void Benchmark_rhi_window::generator_thread_func() void Benchmark_rhi_window::fill_static_data() { - constexpr std::array ts_ns = { - int64_t{ 230'000'000}, - int64_t{ 1'230'000'000}, - int64_t{ 2'230'000'000}, - int64_t{ 3'230'000'000}, - int64_t{ 4'230'000'000} - }; - const std::array vs = {-0.43f, 0.43f, -0.43f, 0.43f, -0.43f}; - - for (std::size_t i = 0; i < ts_ns.size(); ++i) { - if (m_config.data_type == "Trades") { - Trade_sample s{}; - s.timestamp = ts_ns[i]; - s.price = vs[i]; - s.size = 1.0f; - m_trade_buffer->push(s); + if (m_config.data_type == "Trades") { + std::vector samples(m_config.static_sample_count); + m_generator.generate_trades(samples.data(), samples.size()); + for (auto& buffer : m_trade_buffers) { + buffer->push_batch(samples.data(), samples.size()); } - else { - Bar_sample b{}; - b.timestamp = ts_ns[i]; - b.open = vs[i]; - b.high = vs[i]; - b.low = vs[i]; - b.close = vs[i]; - b.volume = 1.0f; - m_bar_buffer->push(b); + } + else { + std::vector samples(m_config.static_sample_count); + m_generator.generate_bars(samples.data(), samples.size()); + for (auto& buffer : m_bar_buffers) { + buffer->push_batch(samples.data(), samples.size()); } } + m_samples_generated.store(m_config.static_sample_count); } void Benchmark_rhi_window::setup_series() { - m_series = std::make_shared(); - m_series->enabled = true; - m_series->color = glm::vec4(0.2f, 0.7f, 0.9f, 1.0f); - - if (m_config.data_type == "Trades") { - m_series->set_data_source_ref(*m_trade_source); - m_series->access = make_trade_access_policy(); - } - else { - m_series->set_data_source_ref(*m_bar_source); - m_series->access = make_bar_access_policy(); - } - const std::string& style = !m_config.style.empty() ? m_config.style : (m_config.data_type == "Trades" ? std::string("Dots") : std::string("Area")); + m_series.reserve(m_config.series_count); + for (std::size_t index = 0; index < m_config.series_count; ++index) { + auto series = std::make_shared(); + series->enabled = true; + const float blend = static_cast(index % 8) / 8.0f; + series->color = glm::vec4(0.2f + 0.5f * blend, 0.7f, 0.9f - 0.5f * blend, 1.0f); - if (style == "Dots") { - m_series->style = vnm::plot::Display_style::DOTS; - } - else - if (style == "Line") { - m_series->style = vnm::plot::Display_style::LINE; - } - else { - m_series->style = vnm::plot::Display_style::AREA; - } + if (m_config.data_type == "Trades") { + series->set_data_source_ref(*m_trade_sources[index]); + series->access = make_trade_access_policy(); + } + else { + series->set_data_source_ref(*m_bar_sources[index]); + series->access = make_bar_access_policy(); + } + + if (style == "Dots") { + series->style = vnm::plot::Display_style::DOTS; + } + else + if (style == "Line") { + series->style = vnm::plot::Display_style::LINE; + } + else { + series->style = vnm::plot::Display_style::AREA; + } - m_plot->add_series(1, m_series); + m_plot->add_series(static_cast(index + 1), series); + m_series.push_back(std::move(series)); + } } void Benchmark_rhi_window::update_plot_view() { vnm::plot::Data_source* source = m_config.data_type == "Trades" - ? static_cast(m_trade_source.get()) - : static_cast(m_bar_source.get()); + ? static_cast(m_trade_sources.front().get()) + : static_cast(m_bar_sources.front().get()); update_view_range_from_source( source, @@ -322,7 +551,8 @@ void Benchmark_rhi_window::update_plot_view() m_t_max, m_t_available_min, m_v_min, - m_v_max); + m_v_max, + 1); vnm::plot::Plot_view view; view.t_range = std::pair{m_t_min, m_t_max}; @@ -334,6 +564,9 @@ void Benchmark_rhi_window::update_plot_view() void Benchmark_rhi_window::on_render_timer() { + if (m_measurement_finished) { + return; + } const auto now = std::chrono::steady_clock::now(); if (m_last_timer_tick.time_since_epoch().count() != 0) { const double elapsed_ms = @@ -342,27 +575,77 @@ void Benchmark_rhi_window::on_render_timer() m_timer_interval_total_ms += elapsed_ms; m_timer_interval_min_ms = std::min(m_timer_interval_min_ms, elapsed_ms); m_timer_interval_max_ms = std::max(m_timer_interval_max_ms, elapsed_ms); + if (m_warmup_frames_remaining == 0) { + m_profiler.record_observation("benchmark.frame.total_ms", elapsed_ms); + } } m_last_timer_tick = now; update_plot_view(); m_plot->update(); update(); + + if (m_warmup_frames_remaining > 0) { + --m_warmup_frames_remaining; + if (m_warmup_frames_remaining == 0) { + reset_ring_measurement_statistics(m_bar_buffers); + reset_ring_measurement_statistics(m_trade_buffers); + m_profiler.reset(); + m_started_at = std::chrono::system_clock::now(); + m_last_timer_tick = {}; + m_timer_interval_count = 0; + m_timer_interval_total_ms = 0.0; + m_timer_interval_min_ms = std::numeric_limits::max(); + m_timer_interval_max_ms = 0.0; + if (m_config.measured_frames == 0) { + m_benchmark_timer.setSingleShot(true); + m_benchmark_timer.start(static_cast(m_config.duration_seconds * 1000)); + } + } + return; + } + + ++m_measured_frame_count; + m_profiler.record_counter("benchmark.frame.output_count"); + if (m_config.measured_frames > 0 && + m_measured_frame_count >= m_config.measured_frames) + { + on_benchmark_timeout(); + } } void Benchmark_rhi_window::on_benchmark_timeout() { + if (m_measurement_finished) { + return; + } + m_measurement_finished = true; stop_generator_thread(); m_render_timer.stop(); + m_benchmark_timer.stop(); m_profiler.record_observation_summary( "qrhi.scheduler.timer_interval", m_timer_interval_count, m_timer_interval_total_ms, m_timer_interval_min_ms, m_timer_interval_max_ms); + record_final_statistics(); emit benchmark_finished(); } +void Benchmark_rhi_window::record_final_statistics() +{ + const double measured_seconds = m_timer_interval_total_ms > 0.0 + ? m_timer_interval_total_ms / 1000.0 + : m_config.duration_seconds; + record_ring_statistics(m_profiler, m_bar_buffers, measured_seconds); + record_ring_statistics(m_profiler, m_trade_buffers, measured_seconds); + m_profiler.record_observation( + "benchmark.memory.process_high_water_bytes", + static_cast(process_memory_high_water_bytes())); + ensure_interval_observations(m_profiler); +} + Benchmark_rhi_offscreen_runner::Benchmark_rhi_offscreen_runner(const Benchmark_config& config) : m_config(config), @@ -387,14 +670,28 @@ Benchmark_rhi_offscreen_runner::~Benchmark_rhi_offscreen_runner() void Benchmark_rhi_offscreen_runner::setup_data_source() { if (m_config.data_type == "Trades") { - m_trade_buffer = std::make_unique>(m_config.ring_capacity); - m_trade_buffer->set_profiler(&m_profiler); - m_trade_source = std::make_unique>(*m_trade_buffer); + m_trade_buffers.reserve(m_config.series_count); + m_trade_sources.reserve(m_config.series_count); + for (std::size_t index = 0; index < m_config.series_count; ++index) { + auto buffer = std::make_unique>(m_config.ring_capacity); + buffer->set_profiler(&m_profiler); + auto source = std::make_unique>(*buffer); + source->set_profiler(&m_profiler); + m_trade_buffers.push_back(std::move(buffer)); + m_trade_sources.push_back(std::move(source)); + } } else { - m_bar_buffer = std::make_unique>(m_config.ring_capacity); - m_bar_buffer->set_profiler(&m_profiler); - m_bar_source = std::make_unique>(*m_bar_buffer); + m_bar_buffers.reserve(m_config.series_count); + m_bar_sources.reserve(m_config.series_count); + for (std::size_t index = 0; index < m_config.series_count; ++index) { + auto buffer = std::make_unique>(m_config.ring_capacity); + buffer->set_profiler(&m_profiler); + auto source = std::make_unique>(*buffer); + source->set_profiler(&m_profiler); + m_bar_buffers.push_back(std::move(buffer)); + m_bar_sources.push_back(std::move(source)); + } } } @@ -434,57 +731,83 @@ void Benchmark_rhi_offscreen_runner::setup_rendering() void Benchmark_rhi_offscreen_runner::setup_series() { - const int series_id = 1; - auto series = std::make_shared(); - series->enabled = true; - series->color = glm::vec4(0.2f, 0.7f, 0.9f, 1.0f); - - if (m_config.data_type == "Trades") { - series->set_data_source_ref(*m_trade_source); - series->access = make_trade_access_policy(); - } - else { - series->set_data_source_ref(*m_bar_source); - series->access = make_bar_access_policy(); - } - const std::string& style = !m_config.style.empty() ? m_config.style : (m_config.data_type == "Trades" ? std::string("Dots") : std::string("Area")); + for (std::size_t index = 0; index < m_config.series_count; ++index) { + auto series = std::make_shared(); + series->enabled = true; + series->stack_group = m_config.stack_series ? 1 : 0; + const float blend = static_cast(index % 8) / 8.0f; + series->color = glm::vec4(0.2f + 0.5f * blend, 0.7f, 0.9f - 0.5f * blend, 1.0f); - if (style == "Dots") { - series->style = vnm::plot::Display_style::DOTS; - } - else - if (style == "Line") { - series->style = vnm::plot::Display_style::LINE; - } - else { - series->style = vnm::plot::Display_style::AREA; - } + if (m_config.data_type == "Trades") { + series->set_data_source_ref(*m_trade_sources[index]); + series->access = make_trade_access_policy(); + } + else { + series->set_data_source_ref(*m_bar_sources[index]); + series->access = make_bar_access_policy(); + } - m_series_map[series_id] = series; + if (style == "Dots") { + series->style = vnm::plot::Display_style::DOTS; + } + else + if (style == "Line") { + series->style = vnm::plot::Display_style::LINE; + } + else { + series->style = vnm::plot::Display_style::AREA; + } + + m_series_map[static_cast(index + 1)] = std::move(series); + } } void Benchmark_rhi_offscreen_runner::generator_thread_func() { - const double samples_per_second = m_config.rate; - const double ns_per_sample = 1e9 / samples_per_second; - - auto start_time = std::chrono::steady_clock::now(); + Publication_rate_clock rate_clock; while (!m_stop_generator.load()) { + Publication_rate_clock::duration paused_duration{}; + if (!wait_for_generator_permission(paused_duration)) { + break; + } + rate_clock.exclude_pause(paused_duration); auto now = std::chrono::steady_clock::now(); - double elapsed_ns = std::chrono::duration(now - start_time).count(); - double target_samples = elapsed_ns / ns_per_sample; + if (m_generator_rate_rebase_requested.exchange(false, std::memory_order_acq_rel)) { + rate_clock.rebase(now, m_samples_generated.load()); + } + std::size_t target_samples = rate_clock.target_samples(now, m_config.rate); std::size_t current_count = m_samples_generated.load(); - while (current_count < static_cast(target_samples) && !m_stop_generator.load()) { + while (current_count < target_samples && !m_stop_generator.load()) { + paused_duration = {}; + if (!wait_for_generator_permission(paused_duration)) { + break; + } + rate_clock.exclude_pause(paused_duration); + now = std::chrono::steady_clock::now(); + if (m_generator_rate_rebase_requested.exchange( + false, + std::memory_order_acq_rel)) { + current_count = m_samples_generated.load(); + rate_clock.rebase(now, current_count); + target_samples = rate_clock.target_samples(now, m_config.rate); + continue; + } if (m_config.data_type == "Trades") { - m_trade_buffer->push(m_generator.next_trade()); + const Trade_sample sample = m_generator.next_trade(); + for (auto& buffer : m_trade_buffers) { + buffer->push(sample); + } } else { - m_bar_buffer->push(m_generator.next_bar()); + const Bar_sample sample = m_generator.next_bar(); + for (auto& buffer : m_bar_buffers) { + buffer->push(sample); + } } ++m_samples_generated; ++current_count; @@ -498,53 +821,117 @@ void Benchmark_rhi_offscreen_runner::stop_generator_thread() { if (m_generator_thread.joinable()) { m_stop_generator.store(true); + m_generator_pause_requested.store(false); + m_generator_control_cv.notify_all(); m_generator_thread.join(); } } -void Benchmark_rhi_offscreen_runner::fill_static_data() +void Benchmark_rhi_offscreen_runner::pause_generator_publication() { - constexpr std::array ts_ns = { - int64_t{ 230'000'000}, - int64_t{ 1'230'000'000}, - int64_t{ 2'230'000'000}, - int64_t{ 3'230'000'000}, - int64_t{ 4'230'000'000} - }; - const std::array vs = {-0.43f, 0.43f, -0.43f, 0.43f, -0.43f}; + if (!m_generator_thread.joinable()) { + return; + } + m_generator_pause_requested.store(true, std::memory_order_release); + m_generator_control_cv.notify_all(); + std::unique_lock lock(m_generator_control_mutex); + m_generator_control_cv.wait(lock, [&]() { + return m_generator_paused || m_stop_generator.load(); + }); +} - for (std::size_t i = 0; i < ts_ns.size(); ++i) { - if (m_config.data_type == "Trades") { - Trade_sample s{}; - s.timestamp = ts_ns[i]; - s.price = vs[i]; - s.size = 1.0f; - m_trade_buffer->push(s); +void Benchmark_rhi_offscreen_runner::resume_generator_publication() +{ + if (!m_generator_thread.joinable()) { + return; + } + m_generator_rate_rebase_requested.store(true, std::memory_order_release); + m_generator_pause_requested.store(false, std::memory_order_release); + m_generator_control_cv.notify_all(); + std::unique_lock lock(m_generator_control_mutex); + m_generator_control_cv.wait(lock, [&]() { + return !m_generator_paused || m_stop_generator.load(); + }); +} + +bool Benchmark_rhi_offscreen_runner::wait_for_generator_permission( + Publication_rate_clock::duration& paused_duration) +{ + if (!m_generator_pause_requested.load(std::memory_order_acquire)) { + return !m_stop_generator.load(); + } + const auto pause_started = Publication_rate_clock::clock::now(); + std::unique_lock lock(m_generator_control_mutex); + m_generator_paused = true; + m_generator_control_cv.notify_all(); + m_generator_control_cv.wait(lock, [&]() { + return !m_generator_pause_requested.load(std::memory_order_acquire) || + m_stop_generator.load(); + }); + m_generator_paused = false; + m_generator_control_cv.notify_all(); + paused_duration += Publication_rate_clock::clock::now() - pause_started; + return !m_stop_generator.load(); +} + +void Benchmark_rhi_offscreen_runner::fill_static_data() +{ + if (m_config.data_type == "Trades") { + std::vector samples(m_config.static_sample_count); + m_generator.generate_trades(samples.data(), samples.size()); + const std::int64_t timestamp_offset = + (samples[1].timestamp - samples[0].timestamp) / + static_cast(m_trade_buffers.size()); + for (std::size_t index = 0; index < m_trade_buffers.size(); ++index) { + auto& buffer = m_trade_buffers[index]; + buffer->push_batch(samples.data(), samples.size()); + if (m_config.stack_series && index + 1 < m_trade_buffers.size()) { + for (auto& sample : samples) { + sample.timestamp += timestamp_offset; + } + } } - else { - Bar_sample b{}; - b.timestamp = ts_ns[i]; - b.open = vs[i]; - b.high = vs[i]; - b.low = vs[i]; - b.close = vs[i]; - b.volume = 1.0f; - m_bar_buffer->push(b); + } + else { + std::vector samples(m_config.static_sample_count); + m_generator.generate_bars(samples.data(), samples.size()); + const std::int64_t timestamp_offset = + (samples[1].timestamp - samples[0].timestamp) / + static_cast(m_bar_buffers.size()); + for (std::size_t index = 0; index < m_bar_buffers.size(); ++index) { + auto& buffer = m_bar_buffers[index]; + buffer->push_batch(samples.data(), samples.size()); + if (m_config.stack_series && index + 1 < m_bar_buffers.size()) { + for (auto& sample : samples) { + sample.timestamp += timestamp_offset; + } + } } } + m_samples_generated.store(m_config.static_sample_count); } bool Benchmark_rhi_offscreen_runner::initialize_rhi(std::string& error_message) { - auto create_null_rhi = [&]() { + std::string fallback_surface_requested_format = "not-applicable"; + std::string fallback_surface_resolved_format = "not-applicable"; + const auto create_null_rhi = [&]() { QRhiNullInitParams params; m_rhi.reset(QRhi::create(QRhi::Null, ¶ms)); + return m_rhi != nullptr; }; - auto create_opengl_rhi = [&]() { + const auto create_opengl_rhi = [&]() { #if QT_CONFIG(opengl) - m_fallback_surface.reset(QRhiGles2InitParams::newFallbackSurface()); QRhiGles2InitParams params; + fallback_surface_requested_format = surface_format_identity(params.format); + m_fallback_surface.reset(QRhiGles2InitParams::newFallbackSurface()); + if (!m_fallback_surface || !m_fallback_surface->isValid()) { + error_message = "cold.fallback_surface: failed to create a valid offscreen surface"; + m_fallback_surface.reset(); + return false; + } + fallback_surface_resolved_format = surface_format_identity(m_fallback_surface->format()); params.fallbackSurface = m_fallback_surface.get(); m_rhi.reset(QRhi::create(QRhi::OpenGLES2, ¶ms)); if (!m_rhi) { @@ -556,87 +943,154 @@ bool Benchmark_rhi_offscreen_runner::initialize_rhi(std::string& error_message) #endif }; - const QByteArray forced_backend = qgetenv("QSG_RHI_BACKEND").trimmed().toLower(); - if (!forced_backend.isEmpty()) { - if (forced_backend == "opengl" || forced_backend == "gles" || - forced_backend == "gles2") - { - if (!create_opengl_rhi()) { - error_message = "QSG_RHI_BACKEND requests OpenGL, but offscreen OpenGL QRhi creation failed"; - return false; - } - } - else - if (forced_backend == "null") { - create_null_rhi(); - } - else - if (forced_backend == "d3d11") { + const auto create_d3d11_rhi = [&]() { #ifdef Q_OS_WIN - QRhiD3D11InitParams params; - m_rhi.reset(QRhi::create(QRhi::D3D11, ¶ms)); - if (!m_rhi) { - error_message = "QSG_RHI_BACKEND=d3d11 was requested, but D3D11 QRhi creation failed"; - return false; - } + QRhiD3D11InitParams params; + m_rhi.reset(QRhi::create(QRhi::D3D11, ¶ms)); + return m_rhi != nullptr; #else - error_message = "QSG_RHI_BACKEND=d3d11 is only supported on Windows"; - return false; + return false; #endif - } - else { - error_message = - "Unsupported QSG_RHI_BACKEND for offscreen benchmark: " + - std::string(forced_backend.constData(), forced_backend.size()); + }; + + const auto create_metal_rhi = [&]() { +#if defined(Q_OS_MACOS) && QT_CONFIG(metal) + QRhiMetalInitParams params; + m_rhi.reset(QRhi::create(QRhi::Metal, ¶ms)); + return m_rhi != nullptr; +#else + return false; +#endif + }; + + const auto create_vulkan_rhi = [&]() { +#if QT_CONFIG(vulkan) && __has_include() + m_vulkan_instance = std::make_unique(); + if (!m_vulkan_instance->create()) { + m_vulkan_instance.reset(); return false; } + QRhiVulkanInitParams params; + params.inst = m_vulkan_instance.get(); + m_rhi.reset(QRhi::create(QRhi::Vulkan, ¶ms)); + if (!m_rhi) { + m_vulkan_instance.reset(); + } + return m_rhi != nullptr; +#else + return false; +#endif + }; + + const std::string& requested = m_config.graphics_backend; + bool created = false; + if (requested == "null") { + created = create_null_rhi(); } - else { -#ifdef Q_OS_WIN - QRhiD3D11InitParams params; - m_rhi.reset(QRhi::create(QRhi::D3D11, ¶ms)); + else + if (requested == "opengl") { + created = create_opengl_rhi(); + } + else + if (requested == "d3d11") { + created = create_d3d11_rhi(); + } + else + if (requested == "metal") { + created = create_metal_rhi(); + } + else + if (requested == "vulkan") { + created = create_vulkan_rhi(); + } + else + if (requested == "native") { +#if defined(Q_OS_WIN) + created = create_d3d11_rhi(); +#elif defined(Q_OS_MACOS) + created = create_metal_rhi(); #else - create_opengl_rhi(); + created = create_opengl_rhi(); #endif } - if (!m_rhi) { - create_null_rhi(); + if (!created || !m_rhi) { + error_message = "cold.backend_create: failed to create requested offscreen QRhi backend '" + + requested + "'"; + return false; } - if (!m_rhi) { - error_message = "Failed to create offscreen QRhi"; + if (m_rhi->backend() == QRhi::Null && requested != "null") { + error_message = "cold.backend_validate: requested '" + requested + + "' but QRhi created the Null backend"; return false; } - const QSize size(1200, 720); - const int sample_count = 4; + m_graphics_info = graphics_device_info_from_rhi(*m_rhi); + m_graphics_info.fallback_surface_requested_format = + fallback_surface_requested_format; + m_graphics_info.fallback_surface_resolved_format = + fallback_surface_resolved_format; + + const QSize size(m_config.framebuffer_width, m_config.framebuffer_height); + const int sample_count = static_cast(m_config.sample_count); m_color_buffer.reset(m_rhi->newRenderBuffer( QRhiRenderBuffer::Color, size, sample_count, {}, QRhiTexture::RGBA8)); + if (!m_color_buffer) { + error_message = "Failed to allocate QRhi offscreen color buffer"; + return false; + } if (!m_color_buffer->create()) { error_message = "Failed to create QRhi offscreen color buffer"; return false; } + if (m_config.capture_pixel_checksum) { + m_resolve_texture.reset(m_rhi->newTexture( + QRhiTexture::RGBA8, + size, + 1, + QRhiTexture::RenderTarget | QRhiTexture::UsedAsTransferSource)); + if (!m_resolve_texture || !m_resolve_texture->create()) { + error_message = "cold.render_target: failed to create pixel readback resolve texture"; + return false; + } + } + QRhiColorAttachment color_attachment; color_attachment.setRenderBuffer(m_color_buffer.get()); + if (m_resolve_texture) { + color_attachment.setResolveTexture(m_resolve_texture.get()); + } QRhiTextureRenderTargetDescription rt_desc(color_attachment); m_render_target.reset(m_rhi->newTextureRenderTarget(rt_desc)); + if (!m_render_target) { + error_message = "Failed to allocate QRhi offscreen render target"; + return false; + } m_render_pass_descriptor.reset(m_render_target->newCompatibleRenderPassDescriptor()); + if (!m_render_pass_descriptor) { + error_message = "Failed to allocate QRhi offscreen render pass descriptor"; + return false; + } m_render_target->setRenderPassDescriptor(m_render_pass_descriptor.get()); if (!m_render_target->create()) { error_message = "Failed to create QRhi offscreen render target"; return false; } - return true; } -bool Benchmark_rhi_offscreen_runner::render_frame(std::string& error_message) +bool Benchmark_rhi_offscreen_runner::render_frame( + std::string& error_message, + bool measured) { + Thread_allocation_scope allocation_scope(measured); + const auto frame_started = std::chrono::steady_clock::now(); + const auto submission_started = frame_started; QRhiCommandBuffer* cb = nullptr; if (m_rhi->beginOffscreenFrame(&cb) != QRhi::FrameOpSuccess || !cb) { error_message = "QRhi beginOffscreenFrame failed"; @@ -646,12 +1100,12 @@ bool Benchmark_rhi_offscreen_runner::render_frame(std::string& error_message) VNM_PLOT_PROFILE_SCOPE(&m_profiler, "renderer"); VNM_PLOT_PROFILE_SCOPE(&m_profiler, "renderer.frame"); - const int fb_w = 1200; - const int fb_h = 720; + const int fb_w = m_config.framebuffer_width; + const int fb_h = m_config.framebuffer_height; vnm::plot::Data_source* source = m_config.data_type == "Trades" - ? static_cast(m_trade_source.get()) - : static_cast(m_bar_source.get()); + ? static_cast(m_trade_sources.front().get()) + : static_cast(m_bar_sources.front().get()); { VNM_PLOT_PROFILE_SCOPE(&m_profiler, "renderer.frame.update_view_range"); update_view_range_from_source( @@ -661,7 +1115,8 @@ bool Benchmark_rhi_offscreen_runner::render_frame(std::string& error_message) m_t_max, m_t_available_min, m_v_min, - m_v_max); + m_v_max, + m_config.stack_series ? m_config.series_count : 1); } const double adjusted_reserved_height = k_base_label_height_px + k_adjusted_preview_height; @@ -767,7 +1222,13 @@ bool Benchmark_rhi_offscreen_runner::render_frame(std::string& error_message) { VNM_PLOT_PROFILE_SCOPE(&m_profiler, "renderer.frame.render_passes"); + const auto planning_started = std::chrono::steady_clock::now(); m_series_renderer.prepare(frame_ctx, m_series_map); + m_last_prepare_ms = std::chrono::duration( + std::chrono::steady_clock::now() - planning_started).count(); + if (measured) { + m_profiler.record_observation("benchmark.planning.time_ms", m_last_prepare_ms); + } m_chrome_renderer.render_grid_and_backgrounds(frame_ctx, m_primitives); const std::size_t back_layer_end = m_primitives.queued_op_count(); m_chrome_renderer.render_zero_line(frame_ctx, m_primitives); @@ -799,27 +1260,146 @@ bool Benchmark_rhi_offscreen_runner::render_frame(std::string& error_message) m_primitives.reset_frame(); } - m_rhi->endOffscreenFrame(); - if (m_config.finish) { + QRhiReadbackResult readback; + if (measured && m_config.capture_pixel_checksum && m_resolve_texture) { + QRhiResourceUpdateBatch* readback_updates = m_rhi->nextResourceUpdateBatch(); + readback_updates->readBackTexture( + QRhiReadbackDescription(m_resolve_texture.get()), + &readback); + cb->resourceUpdate(readback_updates); + } + + const QRhi::FrameOpResult end_result = m_rhi->endOffscreenFrame(); + if (end_result != QRhi::FrameOpSuccess) { + error_message = "measure.end_offscreen_frame: " + frame_op_result_name(end_result); + return false; + } + if (measured) { + const double submission_ms = std::chrono::duration( + std::chrono::steady_clock::now() - submission_started).count(); + m_profiler.record_observation("benchmark.frame.submission_ms", submission_ms); + } + if (m_config.finish || (measured && m_config.capture_pixel_checksum)) { VNM_PLOT_PROFILE_SCOPE(&m_profiler, "renderer.frame.finish"); - m_rhi->finish(); + const auto finish_started = std::chrono::steady_clock::now(); + const QRhi::FrameOpResult finish_result = m_rhi->finish(); + if (finish_result != QRhi::FrameOpSuccess) { + error_message = "measure.finish: " + frame_op_result_name(finish_result); + return false; + } + if (measured) { + const double finish_ms = std::chrono::duration( + std::chrono::steady_clock::now() - finish_started).count(); + m_profiler.record_observation("benchmark.frame.gpu_finish_ms", finish_ms); + } + } + if (measured && m_config.capture_pixel_checksum) { + if (readback.data.isEmpty()) { + error_message = "measure.pixel_readback: QRhi returned no pixel data"; + return false; + } + std::uint64_t checksum = 1'469'598'103'934'665'603ull; + for (const unsigned char byte : readback.data) { + checksum ^= byte; + checksum *= 1'099'511'628'211ull; + } + m_pixel_checksum = checksum; + m_pixel_nonuniform_count = 0; + m_stack_sum_pixel_count = 0; + if (readback.data.size() >= 4) { + const auto* pixels = reinterpret_cast( + readback.data.constData()); + for (qsizetype offset = 4; offset + 4 <= readback.data.size(); offset += 4) { + if (std::memcmp(pixels, pixels + offset, 4) != 0) { + ++m_pixel_nonuniform_count; + } + } + for (qsizetype offset = 0; offset + 4 <= readback.data.size(); offset += 4) { + const bool rgba = + pixels[offset] == 230 && + pixels[offset + 1] == 223 && + pixels[offset + 2] == 204; + const bool bgra = + pixels[offset] == 204 && + pixels[offset + 1] == 223 && + pixels[offset + 2] == 230; + if (rgba || bgra) { + ++m_stack_sum_pixel_count; + } + } + } + m_profiler.record_observation( + "benchmark.frame.pixel_readback_bytes", + static_cast(readback.data.size())); + m_profiler.record_observation( + "benchmark.frame.pixel_checksum_low32", + static_cast(checksum & 0xffff'ffffull)); + m_profiler.record_observation( + "benchmark.frame.pixel_nonuniform_count", + static_cast(m_pixel_nonuniform_count)); + m_profiler.record_observation( + "benchmark.frame.stack_sum_pixel_count", + static_cast(m_stack_sum_pixel_count)); + } + if (measured) { + const Thread_allocation_measurement allocations = allocation_scope.finish(); + const double frame_ms = std::chrono::duration( + std::chrono::steady_clock::now() - frame_started).count(); + m_profiler.record_observation( + "benchmark.frame.cpu_allocation_count", + static_cast(allocations.count)); + m_profiler.record_observation( + "benchmark.frame.cpu_allocation_bytes", + static_cast(allocations.bytes)); + m_profiler.record_observation("benchmark.frame.total_ms", frame_ms); + m_profiler.record_counter("benchmark.frame.output_count"); + ++m_measured_frame_count; } return true; } bool Benchmark_rhi_offscreen_runner::run(std::string& error_message) { + m_phase_trace_started = std::chrono::steady_clock::now(); + const auto trace_identity = std::chrono::system_clock::now().time_since_epoch().count(); + m_phase_trace_path = ( + std::filesystem::path(m_config.output_directory) / + ("benchmark_phase_trace_" + std::to_string(trace_identity) + "_" + + std::to_string(QCoreApplication::applicationPid()) + ".jsonl")).string(); + m_phase_trace.open( + path_for_file_io(m_phase_trace_path), + std::ios::out | std::ios::trunc); + if (!m_phase_trace) { + error_message = "phase_trace.open: failed to open " + m_phase_trace_path; + return false; + } + error_message.reserve(384); + record_phase("cold.setup.begin"); + const auto cold_started = std::chrono::steady_clock::now(); + const auto setup_started = cold_started; setup_data_source(); setup_rendering(); setup_series(); - - if (!initialize_rhi(error_message)) { + record_phase("cold.setup.end"); + m_cold_setup_ms = std::chrono::duration( + std::chrono::steady_clock::now() - setup_started).count(); + + const auto backend_started = std::chrono::steady_clock::now(); + record_phase("cold.backend_create.begin"); + try { + if (!initialize_rhi(error_message)) { + record_phase("cold.backend_create.failed"); + return false; + } + } + catch (const std::bad_alloc&) { + record_phase("cold.backend_create.failed"); + error_message = "cold.backend_create: std::bad_alloc"; return false; } - - m_started_at = std::chrono::system_clock::now(); - const auto steady_start = std::chrono::steady_clock::now(); - const auto duration = std::chrono::duration(m_config.duration_seconds); + record_phase("cold.backend_create.end"); + m_cold_backend_ms = std::chrono::duration( + std::chrono::steady_clock::now() - backend_started).count(); if (m_config.static_data) { fill_static_data(); @@ -829,15 +1409,120 @@ bool Benchmark_rhi_offscreen_runner::run(std::string& error_message) m_generator_thread = std::thread(&Benchmark_rhi_offscreen_runner::generator_thread_func, this); } - while (std::chrono::steady_clock::now() - steady_start < duration) { - if (!render_frame(error_message)) { + record_phase("warmup.begin"); + for (std::size_t frame = 0; frame < m_config.warmup_frames; ++frame) { + const auto warmup_started = std::chrono::steady_clock::now(); + if (!render_frame(error_message, false)) { + record_phase("warmup.frame.failed", frame); + error_message = "warmup.frame: " + error_message; stop_generator_thread(); return false; } + if (frame == 0) { + m_cold_first_submission_ms = std::chrono::duration( + std::chrono::steady_clock::now() - warmup_started).count(); + m_cold_shader_pipeline_prepare_ms = m_last_prepare_ms; + } + } + record_phase("warmup.end"); + + m_cold_total_ms = std::chrono::duration( + std::chrono::steady_clock::now() - cold_started).count(); + const double clock_resolution_ns = steady_clock_resolution_ns(); + pause_generator_publication(); + reset_ring_measurement_statistics(m_bar_buffers); + reset_ring_measurement_statistics(m_trade_buffers); + m_profiler.reset(); + m_profiler.record_observation("benchmark.cold.setup_ms", m_cold_setup_ms); + m_profiler.record_observation("benchmark.cold.backend_create_ms", m_cold_backend_ms); + if (m_config.warmup_frames > 0) { + m_profiler.record_observation( + "benchmark.cold.first_submission_ms", + m_cold_first_submission_ms); + m_profiler.record_observation( + "benchmark.cold.series_shader_pipeline_prepare_ms", + m_cold_shader_pipeline_prepare_ms); } + m_profiler.record_observation("benchmark.cold.total_ms", m_cold_total_ms); + m_profiler.record_observation( + "benchmark.clock.steady_resolution_ns", + clock_resolution_ns); + m_profiler.record_observation( + "benchmark.warmup.frame_count", + static_cast(m_config.warmup_frames)); + + record_phase("measure.begin"); + m_started_at = std::chrono::system_clock::now(); + const auto measured_started = std::chrono::steady_clock::now(); + resume_generator_publication(); + const auto duration = std::chrono::duration(m_config.duration_seconds); + const auto render_measured_frame = [&](std::size_t frame) { + if (render_frame(error_message, true)) { + return true; + } + record_phase("measure.frame.failed", frame); + error_message = "measure.frame: " + error_message; + return false; + }; + + if (m_config.measured_frames > 0) { + for (std::size_t frame = 0; frame < m_config.measured_frames; ++frame) { + if (!render_measured_frame(frame)) { + stop_generator_thread(); + return false; + } + } + } + else { + std::size_t frame = 0; + while (std::chrono::steady_clock::now() - measured_started < duration) { + if (!render_measured_frame(frame++)) { + stop_generator_thread(); + return false; + } + } + } + + pause_generator_publication(); + const auto measured_ended = std::chrono::steady_clock::now(); + record_phase("measure.end"); + const double measured_seconds = std::chrono::duration( + measured_ended - measured_started).count(); + record_final_statistics(measured_seconds); + + const auto shutdown_started = std::chrono::steady_clock::now(); + record_phase("shutdown.generator.begin"); stop_generator_thread(); + record_phase("shutdown.generator.end"); + const double shutdown_ms = std::chrono::duration( + std::chrono::steady_clock::now() - shutdown_started).count(); + m_profiler.record_observation("benchmark.shutdown.generator_ms", shutdown_ms); + record_phase("complete"); return true; } +void Benchmark_rhi_offscreen_runner::record_phase( + const char* phase, + std::size_t frame) const +{ + const double elapsed_ms = std::chrono::duration( + std::chrono::steady_clock::now() - m_phase_trace_started).count(); + if (m_phase_trace) { + m_phase_trace << "{\"elapsed_ms\":" << std::setprecision(17) << elapsed_ms + << ",\"phase\":\"" << phase << "\",\"frame\":" << frame << "}\n"; + m_phase_trace.flush(); + } +} + +void Benchmark_rhi_offscreen_runner::record_final_statistics(double measured_seconds) +{ + record_ring_statistics(m_profiler, m_bar_buffers, measured_seconds); + record_ring_statistics(m_profiler, m_trade_buffers, measured_seconds); + m_profiler.record_observation( + "benchmark.memory.process_high_water_bytes", + static_cast(process_memory_high_water_bytes())); + ensure_interval_observations(m_profiler); +} + } // namespace vnm::benchmark diff --git a/benchmark/src/main.cpp b/benchmark/src/main.cpp index 71d0cb04..762f3b3c 100644 --- a/benchmark/src/main.cpp +++ b/benchmark/src/main.cpp @@ -1,20 +1,30 @@ // vnm_plot Synthetic Benchmark // Stage 7: CLI polish with validation and improved UX -#include "benchmark_window.h" #include "benchmark_profiler.h" +#include "benchmark_window.h" +#include "path_io.h" +#include #include #include +#include +#include #include +#include #include +#include +#include +#include #include #include #include #include #include +#include #include +#include namespace { @@ -25,6 +35,43 @@ constexpr int k_exit_success = 0; constexpr int k_exit_invalid_args = 1; constexpr int k_exit_runtime_error = 2; +std::string compiler_identity() +{ +#if defined(__GNUC__) && !defined(__clang__) + return "GNU " + std::to_string(__GNUC__) + "." + + std::to_string(__GNUC_MINOR__) + "." + std::to_string(__GNUC_PATCHLEVEL__); +#elif defined(__clang__) + return "Clang " + std::to_string(__clang_major__) + "." + + std::to_string(__clang_minor__) + "." + std::to_string(__clang_patchlevel__); +#elif defined(_MSC_VER) + return "MSVC " + std::to_string(_MSC_VER) + "." + std::to_string(_MSC_FULL_VER); +#else + return "unknown"; +#endif +} + +std::string normalized_graphics_backend(std::string name) +{ + std::string normalized; + for (const unsigned char c : name) { + if (std::isalnum(c)) { + normalized.push_back(static_cast(std::tolower(c))); + } + } + if (normalized == "opengles2" || normalized == "gles2" || normalized == "gles") { + return "opengl"; + } + if (normalized == "direct3d11") { + return "d3d11"; + } + return normalized; +} + +std::string bytes_to_string(const QByteArray& value) +{ + return std::string(value.constData(), value.size()); +} + void print_version() { std::cout << "vnm_plot_benchmark version " << k_version << "\n"; @@ -43,8 +90,10 @@ void print_usage(const char* program_name) << " --stream Stream name for report (default: SIM)\n" << " --data-type bars|trades (default: bars)\n" << " --backend qrhi|qrhi-offscreen (default: qrhi-offscreen)\n" + << " --graphics-backend native|d3d11|metal|vulkan|opengl|null (default: native)\n" << " --render-style