From 9414863cffe5822b473c04989661882b0a2c1591 Mon Sep 17 00:00:00 2001 From: catbot Date: Wed, 17 Jun 2026 19:44:33 +0000 Subject: [PATCH] perf(sync): scope frame-loop barriers to swapchain image + real per-pass stages (#115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inter-pass and acquire/present barriers in the frame loop set both stage masks to ALL_COMMANDS, and the inter-pass dependency used a queue-wide VkMemoryBarrier — fully serialising against every pipeline stage and flushing all caches every frame, when all the next pass needs is the swapchain image the previous one wrote. Replace the inter-pass global VkMemoryBarrier with an image memory barrier scoped to the swapchain image's single colour subresource (as the intra-pass UI barrier already does), and derive the barrier stage masks per pass: RenderPass::SwapchainStage() is overridden by UIRenderer (COMPUTE_SHADER) and RTPass (RAY_TRACING_SHADER), so a compute->compute edge only serialises COMPUTE while an RT pass pulls in RAY_TRACING — the acquire/present frame-edge masks use the real union of the frame's passes (SwapchainStageUnion). The base default and the empty-passes fallback are the conservative COMPUTE | RAY_TRACING | TRANSFER union, so a polymorphic or un-overridden pass can only be over- not under-synchronised. Adds SwapchainBarrierScope (pure CPU) pinning the per-pass derivation, the union narrowing, and the inter-pass barrier scope; FrameLoopSync already drives the real GPU frame loop with validation enabled. Co-Authored-By: Claude Opus 4.8 --- implementations/Crafter.Graphics-Window.cpp | 35 +++- interfaces/Crafter.Graphics-RTPass.cppm | 7 + interfaces/Crafter.Graphics-RenderPass.cppm | 70 ++++++++ interfaces/Crafter.Graphics-UI.cppm | 9 + project.cpp | 29 ++++ tests/SwapchainBarrierScope/main.cpp | 174 ++++++++++++++++++++ 6 files changed, 316 insertions(+), 8 deletions(-) create mode 100644 tests/SwapchainBarrierScope/main.cpp diff --git a/implementations/Crafter.Graphics-Window.cpp b/implementations/Crafter.Graphics-Window.cpp index 7be3d4e..19f1f55 100644 --- a/implementations/Crafter.Graphics-Window.cpp +++ b/implementations/Crafter.Graphics-Window.cpp @@ -815,7 +815,16 @@ void Window::Render() { : VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; acquireBarrier.image = images[currentBuffer]; - vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer], VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 0, nullptr, 0, nullptr, 1, &acquireBarrier); + // Real per-pass stage union for this frame's passes — the swapchain image + // is only ever written by compute/RT (and possibly transfer), never by the + // whole pipeline, so the frame-edge barriers wait on exactly those stages + // instead of ALL_COMMANDS. Derived once here and reused for the present + // barrier below (passes don't change within a Render()). + const VkPipelineStageFlags swapWriterStages = SwapchainStageUnion(passes); + + // The acquire->GENERAL transition only needs to be visible before the first + // pass writes the image, i.e. at the writer stages — not ALL_COMMANDS. + vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer], VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, swapWriterStages, 0, 0, nullptr, 0, nullptr, 1, &acquireBarrier); // Synthesise key-repeat events before listeners run, so the focused // widget's OnTextInput / OnKeyDown sees them in the same frame. @@ -879,18 +888,28 @@ void Window::Render() { passes[i]->Record(drawCmdBuffers[currentBuffer], currentBuffer, *this); if (i + 1 < passes.size()) { - VkMemoryBarrier mb { - .sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER, - .srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT, - .dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT, - }; - vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer], VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 1, &mb, 0, nullptr, 0, nullptr); + // Make pass i's writes to the swapchain image visible to pass i+1. + // The only resource the inter-pass hazard touches is that one image + // (later passes overdraw earlier ones), so use an image memory + // barrier scoped to its subresource instead of a queue-wide + // VkMemoryBarrier — only this image's caches round-trip, exactly as + // the intra-pass UI barrier already does. The stage masks come from + // each pass's own SwapchainStage(), so a compute->compute edge only + // serialises COMPUTE_SHADER while an RT pass on either side pulls in + // RAY_TRACING_SHADER — never the ALL_COMMANDS full-pipeline stall. + VkImageMemoryBarrier ib = BuildSwapchainInterPassBarrier(images[currentBuffer]); + vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer], + passes[i]->SwapchainStage(), passes[i + 1]->SwapchainStage(), + 0, 0, nullptr, 0, nullptr, 1, &ib); } } presentBarrier.image = images[currentBuffer]; - vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer], VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr, 0, nullptr, 1, &presentBarrier); + // The ->PRESENT_SRC transition only needs to wait on the stages that + // actually wrote the image (the per-pass union), not ALL_COMMANDS; present + // itself is gated by the render-finished semaphore, so dst stays BOTTOM. + vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer], swapWriterStages, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr, 0, nullptr, 1, &presentBarrier); Device::CheckVkResult(vkEndCommandBuffer(drawCmdBuffers[currentBuffer])); diff --git a/interfaces/Crafter.Graphics-RTPass.cppm b/interfaces/Crafter.Graphics-RTPass.cppm index 37d9bdd..6a5ebef 100644 --- a/interfaces/Crafter.Graphics-RTPass.cppm +++ b/interfaces/Crafter.Graphics-RTPass.cppm @@ -35,6 +35,13 @@ export namespace Crafter { RTPass(PipelineRTVulkan* p) : pipeline(p) {} + // An RT pass writes the swapchain image from the ray-tracing pipeline, + // so the frame loop's barriers must wait on RAY_TRACING_SHADER — using + // the compute stage here would under-synchronise and corrupt the image. + VkPipelineStageFlags SwapchainStage() const override { + return VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR; + } + void Record(VkCommandBuffer cmd, std::uint32_t frameIdx, Window& window) override { vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_RAY_TRACING_KHR, pipeline->pipeline); // NVIDIA descriptor-heap AS-read workaround (issue #15 / #7): feed diff --git a/interfaces/Crafter.Graphics-RenderPass.cppm b/interfaces/Crafter.Graphics-RenderPass.cppm index e49bc5c..c354336 100644 --- a/interfaces/Crafter.Graphics-RenderPass.cppm +++ b/interfaces/Crafter.Graphics-RenderPass.cppm @@ -16,6 +16,10 @@ You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ +module; +#ifndef CRAFTER_GRAPHICS_WINDOW_DOM +#include "vulkan/vulkan.h" +#endif // !CRAFTER_GRAPHICS_WINDOW_DOM export module Crafter.Graphics:RenderPass; import std; import :GraphicsTypes; @@ -23,8 +27,74 @@ import :GraphicsTypes; export namespace Crafter { struct Window; +#ifndef CRAFTER_GRAPHICS_WINDOW_DOM + // Conservative union of every pipeline stage that can write the swapchain + // storage image: a compute pass via COMPUTE_SHADER, a ray-tracing pass via + // RAY_TRACING_SHADER, plus TRANSFER for any blit/copy writer. Used as the + // RenderPass default (a pass that doesn't narrow its own stage) and as the + // fallback when there are no passes to derive a real union from. Far tighter + // than ALL_COMMANDS, but never under-synchronises a polymorphic pass. + inline constexpr VkPipelineStageFlags kSwapchainWriterStages = + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT + | VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR + | VK_PIPELINE_STAGE_TRANSFER_BIT; +#endif // !CRAFTER_GRAPHICS_WINDOW_DOM + struct RenderPass { virtual void Record(GraphicsCommandBuffer cmd, std::uint32_t frameIdx, Window& window) = 0; virtual ~RenderPass() = default; +#ifndef CRAFTER_GRAPHICS_WINDOW_DOM + // Pipeline stage at which this pass reads and writes the swapchain + // storage image. The frame loop uses this to scope the inter-pass and + // frame-edge barriers to the stages that actually touch the image + // instead of ALL_COMMANDS. A subclass MUST narrow this to its real + // stage (RTPass -> RAY_TRACING_SHADER, a compute pass -> COMPUTE_SHADER): + // the default is the conservative writer union so an un-overridden pass + // can never be under-synchronised, only over-synchronised. + virtual VkPipelineStageFlags SwapchainStage() const { return kSwapchainWriterStages; } +#endif // !CRAFTER_GRAPHICS_WINDOW_DOM }; + +#ifndef CRAFTER_GRAPHICS_WINDOW_DOM + // Union of the swapchain-access stages declared by `passes` — the "real" + // per-pass stage union the frame-edge barriers (acquire dst / present src) + // narrow to: an all-compute frame yields COMPUTE_SHADER only, a frame with + // any RT pass folds in RAY_TRACING_SHADER, and so on. Falls back to the + // conservative writer union when there are no passes (the image is still + // transitioned, just never written), so the barrier is always well-formed. + inline VkPipelineStageFlags SwapchainStageUnion(std::span passes) { + if (passes.empty()) return kSwapchainWriterStages; + VkPipelineStageFlags stages = 0; + for (RenderPass* pass : passes) stages |= pass->SwapchainStage(); + return stages; + } + + // The inter-pass swapchain barrier (replacing the old queue-wide + // VkMemoryBarrier at the #115 location). Scoped to the swapchain image's + // single colour subresource so only that image's shader caches round-trip — + // unrelated buffers/images stay resident — exactly as the intra-pass UI + // barrier already does. The image is bound as a storage image and stays in + // VK_IMAGE_LAYOUT_GENERAL, so this is a pure memory dependency (no layout + // transition). The execution scope (stage masks) is supplied per pass pair + // by the caller via vkCmdPipelineBarrier. + inline VkImageMemoryBarrier BuildSwapchainInterPassBarrier(VkImage image) { + return { + .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, + .srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT, + .dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT, + .oldLayout = VK_IMAGE_LAYOUT_GENERAL, + .newLayout = VK_IMAGE_LAYOUT_GENERAL, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .image = image, + .subresourceRange = { + .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, + .baseMipLevel = 0, + .levelCount = 1, + .baseArrayLayer = 0, + .layerCount = 1, + }, + }; + } +#endif // !CRAFTER_GRAPHICS_WINDOW_DOM } diff --git a/interfaces/Crafter.Graphics-UI.cppm b/interfaces/Crafter.Graphics-UI.cppm index 374ed5a..0eec8f2 100644 --- a/interfaces/Crafter.Graphics-UI.cppm +++ b/interfaces/Crafter.Graphics-UI.cppm @@ -201,6 +201,15 @@ export namespace Crafter { void Record(GraphicsCommandBuffer cmd, std::uint32_t frameIdx, Window& window) override; +#ifndef CRAFTER_GRAPHICS_WINDOW_DOM + // A UI pass draws into the swapchain image entirely from compute + // shaders (see Dispatch), so the frame loop's inter-pass / frame-edge + // barriers only need to synchronise the COMPUTE_SHADER stage for it. + VkPipelineStageFlags SwapchainStage() const override { + return VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT; + } +#endif // !CRAFTER_GRAPHICS_WINDOW_DOM + UIDispatchHeader FillHeader(std::uint32_t itemBufferSlot, std::uint32_t itemCount, std::array clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f}, diff --git a/project.cpp b/project.cpp index 97d51b5..04bb68e 100644 --- a/project.cpp +++ b/project.cpp @@ -473,6 +473,35 @@ extern "C" Configuration CrafterBuildProject(std::span a mbc.GetInterfacesAndImplementations(ifaces, mipBarrierImpls); cfg.tests.push_back(std::move(mipBarrierTest)); + // Issue #115: the frame loop's inter-pass and acquire/present barriers + // no longer use ALL_COMMANDS / a queue-wide VkMemoryBarrier. The stage + // masks are derived per pass via RenderPass::SwapchainStage() (compute + // vs ray-tracing) and unioned across the frame by SwapchainStageUnion, + // and the inter-pass dependency is scoped to the swapchain image by + // BuildSwapchainInterPassBarrier — all pure CPU logic over the pass + // list, so this test drives them directly with no GPU at runtime. + Test swapBarrierTest; + Configuration& sbc = swapBarrierTest.config; + sbc.path = cfg.path; + sbc.name = "SwapchainBarrierScope"; + sbc.outputName = "SwapchainBarrierScope"; + sbc.type = ConfigurationType::Executable; + sbc.target = cfg.target; + sbc.march = cfg.march; + sbc.mtune = cfg.mtune; + sbc.debug = cfg.debug; + sbc.sysroot = cfg.sysroot; + sbc.dependencies = cfg.dependencies; + sbc.externalDependencies = cfg.externalDependencies; + sbc.compileFlags = cfg.compileFlags; + sbc.linkFlags = cfg.linkFlags; + sbc.defines = cfg.defines; + sbc.cFiles = cfg.cFiles; + std::vector swapBarrierImpls(impls.begin(), impls.end()); + swapBarrierImpls.emplace_back("tests/SwapchainBarrierScope/main"); + sbc.GetInterfacesAndImplementations(ifaces, swapBarrierImpls); + cfg.tests.push_back(std::move(swapBarrierTest)); + // Issue #47: the fused UI uber-kernel (shaders/ui-fused.comp.glsl) and // its C++ push-constant mirror UIFusedHeader. Compiles the real shader // with glslang, validates with spirv-val, and pins the push-constant diff --git a/tests/SwapchainBarrierScope/main.cpp b/tests/SwapchainBarrierScope/main.cpp new file mode 100644 index 0000000..eb5e4e3 --- /dev/null +++ b/tests/SwapchainBarrierScope/main.cpp @@ -0,0 +1,174 @@ +/* +Crafter®.Graphics +Copyright (C) 2026 Catcrafts® +catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License version 3.0 as published by the Free Software Foundation; + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +// Issue #115: the frame loop's inter-pass and frame-edge (acquire/present) +// barriers used to set BOTH stage masks to VK_PIPELINE_STAGE_ALL_COMMANDS_BIT +// and the inter-pass dependency to a queue-wide VkMemoryBarrier — flushing +// every cache and fully serialising against the whole pipeline every frame, +// when all the next pass needs is the swapchain image the previous one wrote. +// +// The fix scopes the inter-pass dependency to the swapchain image's single +// colour subresource (BuildSwapchainInterPassBarrier) and narrows the stage +// masks to the stages that actually touch that image, derived per pass via the +// polymorphic RenderPass::SwapchainStage() and unioned across the frame by +// SwapchainStageUnion. The load-bearing correctness constraint is that this +// derivation is per-pass: a compute pass writes via COMPUTE_SHADER, a ray- +// tracing pass via RAY_TRACING_SHADER, so a hardcoded compute mask would +// UNDER-synchronise an RT pass and corrupt the image. All three helpers are +// pure CPU logic over the pass list, so this test drives them directly with no +// GPU device at runtime — mirroring MipChainBarrierBatch / UploadStrategy. + +#include +#include "vulkan/vulkan.h" + +import Crafter.Graphics; +import std; +using namespace Crafter; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +// A pass that never overrides SwapchainStage(): stands in for any future pass +// that forgets to narrow its stage, and must fall back to the conservative +// writer union rather than silently picking one stage (which could under-sync). +struct DefaultPass : RenderPass { + void Record(GraphicsCommandBuffer, std::uint32_t, Window&) override {} +}; + +VkImage SentinelImage() { + return reinterpret_cast(static_cast(0xC0FFEE)); +} + +} // namespace + +int main() { + // ─── per-pass SwapchainStage() polymorphism ───────────────────────────── + // The whole reason the fix is "moderate, not trivial": these two stages + // MUST differ, or scoping an RT pass's barrier to the compute stage would + // under-synchronise it. Pin that they are distinct and each correct. + Check(VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT != VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR, + "compute and ray-tracing write stages are distinct (the under-sync trap)"); + + UIRenderer ui; // real compute UI pass + RTPass rt(nullptr); // real RT pass (SwapchainStage doesn't touch the pipeline) + DefaultPass def; // un-overridden fallback + + // Access through the base pointer — the frame loop only ever sees RenderPass*. + RenderPass* uiBase = &ui; + RenderPass* rtBase = &rt; + RenderPass* defBase = &def; + + Check(uiBase->SwapchainStage() == VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, + "UIRenderer reports the COMPUTE_SHADER stage"); + Check(rtBase->SwapchainStage() == VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR, + "RTPass reports the RAY_TRACING_SHADER stage"); + Check(defBase->SwapchainStage() == kSwapchainWriterStages, + "an un-overridden pass falls back to the conservative writer union"); + + // ─── the conservative writer union constant ───────────────────────────── + constexpr VkPipelineStageFlags expectedUnion = + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT + | VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR + | VK_PIPELINE_STAGE_TRANSFER_BIT; + Check(kSwapchainWriterStages == expectedUnion, + "writer union = COMPUTE_SHADER | RAY_TRACING_SHADER | TRANSFER"); + // The point of the whole change: even the conservative fallback is far + // tighter than the ALL_COMMANDS mask it replaces. + Check(kSwapchainWriterStages != VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, + "writer union is narrower than ALL_COMMANDS"); + + // ─── SwapchainStageUnion over the frame's pass list ───────────────────── + // Empty frame: no writers to derive from, so fall back to the conservative + // union (the image is still transitioned, just never written). + Check(SwapchainStageUnion(std::span{}) == kSwapchainWriterStages, + "empty pass list falls back to the conservative writer union"); + + // All-compute frame: the union narrows to COMPUTE_SHADER ONLY — this is the + // real perf win, no RAY_TRACING / TRANSFER / ALL_COMMANDS dragged in. + { + std::array computeOnly = {uiBase, uiBase}; + VkPipelineStageFlags u = SwapchainStageUnion(computeOnly); + Check(u == VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, + "all-compute frame narrows the union to COMPUTE_SHADER only"); + Check((u & VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR) == 0, + "all-compute frame does NOT pull in RAY_TRACING_SHADER"); + } + + // RT-only frame: narrows to RAY_TRACING_SHADER only. + { + std::array rtOnly = {rtBase}; + Check(SwapchainStageUnion(rtOnly) == VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR, + "RT-only frame narrows the union to RAY_TRACING_SHADER only"); + } + + // Mixed frame: the union folds in BOTH stages — never under-syncs the RT + // pass, never over-syncs to ALL_COMMANDS. + { + std::array mixed = {uiBase, rtBase}; + VkPipelineStageFlags u = SwapchainStageUnion(mixed); + Check(u == (VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT | VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR), + "mixed compute+RT frame unions COMPUTE_SHADER | RAY_TRACING_SHADER"); + Check((u & VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR) != 0, + "mixed frame keeps RAY_TRACING_SHADER (no RT under-sync)"); + } + + // A frame containing an un-overridden pass conservatively widens to the + // full writer union — safe by construction. + { + std::array withDefault = {uiBase, defBase}; + Check(SwapchainStageUnion(withDefault) == kSwapchainWriterStages, + "a frame with an un-overridden pass widens to the conservative union"); + } + + // ─── BuildSwapchainInterPassBarrier ───────────────────────────────────── + // Replaces the queue-wide VkMemoryBarrier: an image memory barrier scoped + // to the swapchain image's single colour subresource, GENERAL->GENERAL (no + // layout change — the storage image stays bound), matching the intra-pass + // UI barrier's access scope. + { + VkImageMemoryBarrier b = BuildSwapchainInterPassBarrier(SentinelImage()); + Check(b.sType == VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, "inter-pass barrier sType is IMAGE_MEMORY_BARRIER"); + Check(b.image == SentinelImage(), "inter-pass barrier carries the swapchain image handle"); + Check(b.oldLayout == VK_IMAGE_LAYOUT_GENERAL && b.newLayout == VK_IMAGE_LAYOUT_GENERAL, + "inter-pass barrier is GENERAL->GENERAL (pure memory dependency, no transition)"); + Check(b.srcAccessMask == VK_ACCESS_SHADER_WRITE_BIT, + "inter-pass barrier src access is shader write"); + Check(b.dstAccessMask == (VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT), + "inter-pass barrier dst access is shader read|write"); + Check(b.srcQueueFamilyIndex == VK_QUEUE_FAMILY_IGNORED && b.dstQueueFamilyIndex == VK_QUEUE_FAMILY_IGNORED, + "inter-pass barrier is not a queue-family transfer"); + Check(b.subresourceRange.aspectMask == VK_IMAGE_ASPECT_COLOR_BIT, + "inter-pass barrier covers the colour aspect"); + Check(b.subresourceRange.levelCount == 1 && b.subresourceRange.layerCount == 1, + "inter-pass barrier is scoped to the single swapchain subresource"); + } + + if (failures != 0) { + std::println("{} check(s) failed", failures); + return EXIT_FAILURE; + } + std::println("all checks passed"); + return EXIT_SUCCESS; +}