fix(Vulkan) Work around a Vulkan driver OOM on Mali devices (#11271) 37439d5fb6

The Mali Vulkan driver/device was running out of memory internally, occasionally. This adds a hook into the testing window that gets run after each GM finishes, which TestingWindowAndroidVulkan now uses to tear the device down completely. The device then gets rebuilt as needed.

Co-authored-by: Josh Jersild <joshua@rive.app>
This commit is contained in:
JoshJRive
2025-12-13 03:43:05 +00:00
parent 8d869457e9
commit 6134855b7f
6 changed files with 246 additions and 163 deletions

View File

@@ -1 +1 @@
1dd286c979e2168ac0f899ca55b562ace933c3e3 37439d5fb6388d0315fdea9b9772f0213ef1dfbc

View File

@@ -21,6 +21,7 @@ public:
bool coreFeaturesOnly = false; bool coreFeaturesOnly = false;
const char* gpuNameFilter = nullptr; const char* gpuNameFilter = nullptr;
bool headless = false; bool headless = false;
bool printInitializationMessage = true;
uint32_t minimumSupportedAPIVersion = VK_API_VERSION_1_0; uint32_t minimumSupportedAPIVersion = VK_API_VERSION_1_0;
@@ -29,6 +30,13 @@ public:
VkSurfaceKHR presentationSurfaceForDeviceSelection = VK_NULL_HANDLE; VkSurfaceKHR presentationSurfaceForDeviceSelection = VK_NULL_HANDLE;
}; };
struct DriverVersion
{
uint32_t major;
uint32_t minor;
uint32_t patch;
};
VulkanDevice(VulkanInstance&, const Options&); VulkanDevice(VulkanInstance&, const Options&);
~VulkanDevice(); ~VulkanDevice();
@@ -66,6 +74,8 @@ public:
const std::string& name() const { return m_name; } const std::string& name() const { return m_name; }
const DriverVersion& driverVersion() const { return m_driverVersion; }
private: private:
struct FindDeviceResult struct FindDeviceResult
{ {
@@ -73,9 +83,7 @@ private:
std::string deviceName; std::string deviceName;
VkPhysicalDeviceType deviceType; VkPhysicalDeviceType deviceType;
uint32_t deviceAPIVersion; uint32_t deviceAPIVersion;
uint32_t driverVersionMajor; DriverVersion driverVersion;
uint32_t driverVersionMinor;
uint32_t driverVersionPatch;
}; };
static FindDeviceResult findCompatiblePhysicalDevice( static FindDeviceResult findCompatiblePhysicalDevice(
@@ -103,6 +111,7 @@ private:
std::vector<VkQueueFamilyProperties> m_queueFamilyProperties; std::vector<VkQueueFamilyProperties> m_queueFamilyProperties;
std::string m_name; std::string m_name;
DriverVersion m_driverVersion;
VkPhysicalDevice m_physicalDevice; VkPhysicalDevice m_physicalDevice;
VkDevice m_device; VkDevice m_device;

View File

@@ -32,33 +32,37 @@ static const char* physicalDeviceTypeName(VkPhysicalDeviceType type)
} }
} }
void unpackDriverVersion(const VkPhysicalDeviceProperties& props, VulkanDevice::DriverVersion unpackDriverVersion(
uint32_t* majorOut, const VkPhysicalDeviceProperties& props)
uint32_t* minorOut,
uint32_t* patchOut)
{ {
if (props.vendorID == VULKAN_VENDOR_NVIDIA) if (props.vendorID == VULKAN_VENDOR_NVIDIA)
{ {
// NVidia uses 10|8|8|6 encoding for driver version. We'll ignore the // NVidia uses 10|8|8|6 encoding for driver version. We'll ignore the
// fourth version section. // fourth version section.
*majorOut = props.driverVersion >> 22; return {
*minorOut = (props.driverVersion >> 14) & 0xff; .major = props.driverVersion >> 22,
*patchOut = (props.driverVersion >> 6) & 0xff; .minor = (props.driverVersion >> 14) & 0xff,
.patch = (props.driverVersion >> 6) & 0xff,
};
} }
#ifdef _WIN32 #ifdef _WIN32
else if (props.vendorID == VULKAN_VENDOR_INTEL) else if (props.vendorID == VULKAN_VENDOR_INTEL)
{ {
*majorOut = props.driverVersion >> 14; return {
*minorOut = props.driverVersion & 0x3fff; .major = props.driverVersion >> 14,
*patchOut = 0; .minor = props.driverVersion & 0x3fff,
.patch = 0,
};
} }
#endif #endif
else else
{ {
// Everything else seems to use the standard Vulkan encoding. // Everything else seems to use the standard Vulkan encoding.
*majorOut = VK_API_VERSION_MAJOR(props.driverVersion); return {
*minorOut = VK_API_VERSION_MINOR(props.driverVersion); .major = VK_API_VERSION_MAJOR(props.driverVersion),
*patchOut = VK_API_VERSION_PATCH(props.driverVersion); .minor = VK_API_VERSION_MINOR(props.driverVersion),
.patch = VK_API_VERSION_PATCH(props.driverVersion),
};
} }
} }
@@ -83,6 +87,7 @@ VulkanDevice::VulkanDevice(VulkanInstance& instance, const Options& opts)
opts.minimumSupportedAPIVersion); opts.minimumSupportedAPIVersion);
m_physicalDevice = findResult.physicalDevice; m_physicalDevice = findResult.physicalDevice;
m_name = findResult.deviceName; m_name = findResult.deviceName;
m_driverVersion = findResult.driverVersion;
DEFINE_AND_LOAD_INSTANCE_FUNC(vkGetPhysicalDeviceFeatures, instance); DEFINE_AND_LOAD_INSTANCE_FUNC(vkGetPhysicalDeviceFeatures, instance);
assert(vkGetPhysicalDeviceFeatures != nullptr); assert(vkGetPhysicalDeviceFeatures != nullptr);
@@ -252,35 +257,39 @@ VulkanDevice::VulkanDevice(VulkanInstance& instance, const Options& opts)
LOAD_MEMBER_INSTANCE_FUNC(vkGetPhysicalDeviceSurfaceCapabilitiesKHR, LOAD_MEMBER_INSTANCE_FUNC(vkGetPhysicalDeviceSurfaceCapabilitiesKHR,
instance); instance);
printf("==== Vulkan %i.%i.%i GPU (%s): %s (driver %i.%i.%i) [ ", if (opts.printInitializationMessage)
VK_API_VERSION_MAJOR(m_riveVulkanFeatures.apiVersion),
VK_API_VERSION_MINOR(m_riveVulkanFeatures.apiVersion),
VK_API_VERSION_PATCH(m_riveVulkanFeatures.apiVersion),
physicalDeviceTypeName(findResult.deviceType),
findResult.deviceName.c_str(),
findResult.driverVersionMajor,
findResult.driverVersionMinor,
findResult.driverVersionPatch);
struct CommaSeparator
{ {
const char* m_separator = ""; printf("==== Vulkan %i.%i.%i GPU (%s): %s (driver %i.%i.%i) [ ",
const char* operator*() { return std::exchange(m_separator, ", "); } VK_API_VERSION_MAJOR(m_riveVulkanFeatures.apiVersion),
} commaSeparator; VK_API_VERSION_MINOR(m_riveVulkanFeatures.apiVersion),
if (m_riveVulkanFeatures.independentBlend) VK_API_VERSION_PATCH(m_riveVulkanFeatures.apiVersion),
printf("%sindependentBlend", *commaSeparator); physicalDeviceTypeName(findResult.deviceType),
if (m_riveVulkanFeatures.fillModeNonSolid) findResult.deviceName.c_str(),
printf("%sfillModeNonSolid", *commaSeparator); findResult.driverVersion.major,
if (m_riveVulkanFeatures.fragmentStoresAndAtomics) findResult.driverVersion.minor,
printf("%sfragmentStoresAndAtomics", *commaSeparator); findResult.driverVersion.patch);
if (m_riveVulkanFeatures.shaderClipDistance) struct CommaSeparator
printf("%sshaderClipDistance", *commaSeparator); {
if (m_riveVulkanFeatures.rasterizationOrderColorAttachmentAccess) const char* m_separator = "";
printf("%srasterizationOrderColorAttachmentAccess", *commaSeparator); const char* operator*() { return std::exchange(m_separator, ", "); }
if (m_riveVulkanFeatures.fragmentShaderPixelInterlock) } commaSeparator;
printf("%sfragmentShaderPixelInterlock", *commaSeparator); if (m_riveVulkanFeatures.independentBlend)
if (m_riveVulkanFeatures.VK_KHR_portability_subset) printf("%sindependentBlend", *commaSeparator);
printf("%sVK_KHR_portability_subset", *commaSeparator); if (m_riveVulkanFeatures.fillModeNonSolid)
printf(" ] ====\n"); printf("%sfillModeNonSolid", *commaSeparator);
if (m_riveVulkanFeatures.fragmentStoresAndAtomics)
printf("%sfragmentStoresAndAtomics", *commaSeparator);
if (m_riveVulkanFeatures.shaderClipDistance)
printf("%sshaderClipDistance", *commaSeparator);
if (m_riveVulkanFeatures.rasterizationOrderColorAttachmentAccess)
printf("%srasterizationOrderColorAttachmentAccess",
*commaSeparator);
if (m_riveVulkanFeatures.fragmentShaderPixelInterlock)
printf("%sfragmentShaderPixelInterlock", *commaSeparator);
if (m_riveVulkanFeatures.VK_KHR_portability_subset)
printf("%sVK_KHR_portability_subset", *commaSeparator);
printf(" ] ====\n");
}
} }
VulkanDevice::~VulkanDevice() { m_vkDestroyDevice(m_device, nullptr); } VulkanDevice::~VulkanDevice() { m_vkDestroyDevice(m_device, nullptr); }
@@ -417,16 +426,12 @@ VulkanDevice::FindDeviceResult VulkanDevice::findCompatiblePhysicalDevice(
if (strstr(props.deviceName, nameFilter) != nullptr) if (strstr(props.deviceName, nameFilter) != nullptr)
{ {
uint32_t major, minor, patch;
unpackDriverVersion(props, &major, &minor, &patch);
matchResult = { matchResult = {
.physicalDevice = device, .physicalDevice = device,
.deviceName = props.deviceName, .deviceName = props.deviceName,
.deviceType = props.deviceType, .deviceType = props.deviceType,
.deviceAPIVersion = props.apiVersion, .deviceAPIVersion = props.apiVersion,
.driverVersionMajor = major, .driverVersion = unpackDriverVersion(props),
.driverVersionMinor = minor,
.driverVersionPatch = patch,
}; };
matchedDeviceNames.push_back(std::string{props.deviceName}); matchedDeviceNames.push_back(std::string{props.deviceName});
} }
@@ -484,17 +489,12 @@ VulkanDevice::FindDeviceResult VulkanDevice::findCompatiblePhysicalDevice(
if (!onlyAcceptDesiredType || if (!onlyAcceptDesiredType ||
props.deviceType == desiredDeviceType) props.deviceType == desiredDeviceType)
{ {
uint32_t major, minor, patch;
unpackDriverVersion(props, &major, &minor, &patch);
return { return {
.physicalDevice = device, .physicalDevice = device,
.deviceName = props.deviceName, .deviceName = props.deviceName,
.deviceType = props.deviceType, .deviceType = props.deviceType,
.deviceAPIVersion = props.apiVersion, .deviceAPIVersion = props.apiVersion,
.driverVersionMajor = major, .driverVersion = unpackDriverVersion(props),
.driverVersionMinor = minor,
.driverVersionPatch = patch,
}; };
} }
} }

View File

@@ -170,6 +170,9 @@ public:
m_height = height; m_height = height;
} }
// This is called by the gm testing after each GM runs
virtual void onceAfterGM() {}
struct FrameOptions struct FrameOptions
{ {
uint32_t clearColor; uint32_t clearColor;

View File

@@ -25,6 +25,7 @@ TestingWindow* TestingWindow::MakeAndroidVulkan(const BackendParams&,
#include <vk_mem_alloc.h> #include <vk_mem_alloc.h>
#include <android/native_app_glue/android_native_app_glue.h> #include <android/native_app_glue/android_native_app_glue.h>
#include <android/log.h> #include <android/log.h>
#include <thread>
using namespace rive; using namespace rive;
using namespace rive::gpu; using namespace rive::gpu;
@@ -45,7 +46,7 @@ class TestingWindowAndroidVulkan : public TestingWindow
public: public:
TestingWindowAndroidVulkan(const BackendParams& backendParams, TestingWindowAndroidVulkan(const BackendParams& backendParams,
ANativeWindow* window) : ANativeWindow* window) :
m_backendParams(backendParams) m_backendParams(backendParams), m_window(window)
{ {
using namespace rive_vkb; using namespace rive_vkb;
@@ -80,7 +81,7 @@ public:
VkAndroidSurfaceCreateInfoKHR androidSurfaceCreateInfo = { VkAndroidSurfaceCreateInfoKHR androidSurfaceCreateInfo = {
.sType = VK_STRUCTURE_TYPE_ANDROID_SURFACE_CREATE_INFO_KHR, .sType = VK_STRUCTURE_TYPE_ANDROID_SURFACE_CREATE_INFO_KHR,
.window = window, .window = m_window,
}; };
auto pfnvkCreateAndroidSurfaceKHR = auto pfnvkCreateAndroidSurfaceKHR =
m_instance->loadInstanceFunc<PFN_vkCreateAndroidSurfaceKHR>( m_instance->loadInstanceFunc<PFN_vkCreateAndroidSurfaceKHR>(
@@ -90,99 +91,11 @@ public:
&androidSurfaceCreateInfo, &androidSurfaceCreateInfo,
nullptr, nullptr,
&m_windowSurface)); &m_windowSurface));
m_device = std::make_unique<VulkanDevice>(
*m_instance,
VulkanDevice::Options{
.coreFeaturesOnly = m_backendParams.core,
});
m_renderContext = RenderContextVulkanImpl::MakeContext(
m_instance->vkInstance(),
m_device->vkPhysicalDevice(),
m_device->vkDevice(),
m_device->vulkanFeatures(),
m_instance->getVkGetInstanceProcAddrPtr(),
{.forceAtomicMode = backendParams.atomic});
auto windowCapabilities =
m_device->getSurfaceCapabilities(m_windowSurface);
auto swapOpts = VulkanSwapchain::Options{
.formatPreferences =
{
{
.format = m_backendParams.srgb
? VK_FORMAT_R8G8B8A8_SRGB
: VK_FORMAT_R8G8B8A8_UNORM,
.colorSpace = VK_COLOR_SPACE_SRGB_NONLINEAR_KHR,
},
// Fall back to either ordering of ARGB
{
.format = VK_FORMAT_R8G8B8A8_UNORM,
.colorSpace = VK_COLOR_SPACE_SRGB_NONLINEAR_KHR,
},
{
.format = VK_FORMAT_B8G8R8A8_UNORM,
.colorSpace = VK_COLOR_SPACE_SRGB_NONLINEAR_KHR,
},
},
.presentModePreferences =
{
VK_PRESENT_MODE_IMMEDIATE_KHR,
VK_PRESENT_MODE_FIFO_KHR,
},
.imageUsageFlags = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT |
VK_IMAGE_USAGE_TRANSFER_SRC_BIT |
VK_IMAGE_USAGE_TRANSFER_DST_BIT,
};
if ((windowCapabilities.supportedUsageFlags &
VK_IMAGE_USAGE_INPUT_ATTACHMENT_BIT) != 0)
{
swapOpts.imageUsageFlags |= VK_IMAGE_USAGE_INPUT_ATTACHMENT_BIT;
}
m_swapchain =
std::make_unique<rive_vkb::VulkanSwapchain>(*m_instance,
*m_device,
ref_rcp(vk()),
m_windowSurface,
swapOpts);
m_androidWindowWidth = m_width = m_swapchain->width();
m_androidWindowHeight = m_height = m_swapchain->height();
m_renderTarget =
impl()->makeRenderTarget(m_width,
m_height,
m_swapchain->imageFormat(),
m_swapchain->imageUsageFlags());
if (m_device->name() == "Mali-G76" || m_device->name() == "Mali-G72")
{
// These devices (like the Huawei P30 or Galaxy S10) will end up
// with a DEVICE_LOST error if we blit to the screen, so don't.
m_allowBlitOffscreenToScreen = false;
}
if (m_device->name() == "PowerVR Rogue GM9446")
{
// These devices (like the Oppo Reno 3 Pro) only give correct
// results when rendering to an off-screen texture.
m_alwaysUseOffscreenTexture = true;
}
} }
~TestingWindowAndroidVulkan() ~TestingWindowAndroidVulkan()
{ {
// Destroy the swapchain first because it synchronizes for in-flight destroyRenderContext();
// command buffers.
m_swapchain = nullptr;
m_renderContext.reset();
m_renderTarget.reset();
m_overflowTexture.reset();
if (m_windowSurface != VK_NULL_HANDLE) if (m_windowSurface != VK_NULL_HANDLE)
{ {
@@ -192,21 +105,40 @@ public:
} }
} }
Factory* factory() override { return m_renderContext.get(); } Factory* factory() override
{
// Some GMs call factory() during construction (before the call to
// resize will rebuild the device), so this function also needs to build
// the render context
if (m_renderContext == nullptr)
{
makeDeviceAndRenderContext();
}
return m_renderContext.get();
}
rive::gpu::RenderContext* renderContext() const override rive::gpu::RenderContext* renderContext() const override
{ {
assert(m_renderContext != nullptr);
return m_renderContext.get(); return m_renderContext.get();
} }
rive::gpu::RenderTarget* renderTarget() const override rive::gpu::RenderTarget* renderTarget() const override
{ {
assert(m_renderTarget != nullptr);
return m_renderTarget.get(); return m_renderTarget.get();
} }
void resize(int width, int height) override void resize(int width, int height) override
{ {
TestingWindow::resize(width, height); TestingWindow::resize(width, height);
if (m_renderContext == nullptr)
{
makeDeviceAndRenderContext();
}
m_renderTarget = m_renderTarget =
impl()->makeRenderTarget(m_width, impl()->makeRenderTarget(m_width,
m_height, m_height,
@@ -252,6 +184,11 @@ public:
std::unique_ptr<rive::Renderer> beginFrame( std::unique_ptr<rive::Renderer> beginFrame(
const FrameOptions& options) override const FrameOptions& options) override
{ {
if (m_renderContext == nullptr)
{
makeDeviceAndRenderContext();
}
m_renderContext->beginFrame(RenderContext::FrameDescriptor{ m_renderContext->beginFrame(RenderContext::FrameDescriptor{
.renderTargetWidth = m_width, .renderTargetWidth = m_width,
.renderTargetHeight = m_height, .renderTargetHeight = m_height,
@@ -381,7 +318,129 @@ public:
} }
} }
void onceAfterGM() override
{
// Mali devices with a driver version before 50 have been known to run
// out of memory, so for these devices, tear down the device between
// GMs.
if (m_device != nullptr &&
strstr(m_device->name().c_str(), "Mali") != nullptr &&
m_device->driverVersion().major < 50)
{
destroyRenderContext();
}
}
private: private:
void makeDeviceAndRenderContext()
{
using namespace rive_vkb;
m_device = std::make_unique<VulkanDevice>(
*m_instance,
VulkanDevice::Options{
.coreFeaturesOnly = m_backendParams.core,
.printInitializationMessage =
m_printDeviceInitializationMessage,
});
// Only want to print the device initialization message the first time
m_printDeviceInitializationMessage = false;
m_renderContext = RenderContextVulkanImpl::MakeContext(
m_instance->vkInstance(),
m_device->vkPhysicalDevice(),
m_device->vkDevice(),
m_device->vulkanFeatures(),
m_instance->getVkGetInstanceProcAddrPtr(),
{.forceAtomicMode = m_backendParams.atomic});
auto windowCapabilities =
m_device->getSurfaceCapabilities(m_windowSurface);
auto swapOpts = VulkanSwapchain::Options{
.formatPreferences =
{
{
.format = m_backendParams.srgb
? VK_FORMAT_R8G8B8A8_SRGB
: VK_FORMAT_R8G8B8A8_UNORM,
.colorSpace = VK_COLOR_SPACE_SRGB_NONLINEAR_KHR,
},
// Fall back to either ordering of ARGB
{
.format = VK_FORMAT_R8G8B8A8_UNORM,
.colorSpace = VK_COLOR_SPACE_SRGB_NONLINEAR_KHR,
},
{
.format = VK_FORMAT_B8G8R8A8_UNORM,
.colorSpace = VK_COLOR_SPACE_SRGB_NONLINEAR_KHR,
},
},
.presentModePreferences =
{
VK_PRESENT_MODE_IMMEDIATE_KHR,
VK_PRESENT_MODE_FIFO_KHR,
},
.imageUsageFlags = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT |
VK_IMAGE_USAGE_TRANSFER_SRC_BIT |
VK_IMAGE_USAGE_TRANSFER_DST_BIT,
};
if ((windowCapabilities.supportedUsageFlags &
VK_IMAGE_USAGE_INPUT_ATTACHMENT_BIT) != 0)
{
swapOpts.imageUsageFlags |= VK_IMAGE_USAGE_INPUT_ATTACHMENT_BIT;
}
m_swapchain =
std::make_unique<rive_vkb::VulkanSwapchain>(*m_instance,
*m_device,
ref_rcp(vk()),
m_windowSurface,
swapOpts);
m_androidWindowWidth = m_swapchain->width();
m_androidWindowHeight = m_swapchain->height();
if (m_width == 0)
{
m_width = m_androidWindowWidth;
}
if (m_height == 0)
{
m_height = m_androidWindowHeight;
}
if (m_device->name() == "Mali-G76" || m_device->name() == "Mali-G72")
{
// These devices (like the Huawei P30 or Galaxy S10) will end up
// with a DEVICE_LOST error if we blit to the screen, so don't.
m_allowBlitOffscreenToScreen = false;
}
if (m_device->name() == "PowerVR Rogue GM9446")
{
// These devices (like the Oppo Reno 3 Pro) only give correct
// results when rendering to an off-screen texture.
m_alwaysUseOffscreenTexture = true;
}
}
void destroyRenderContext()
{
if (m_device != nullptr)
{
m_device->waitUntilIdle();
m_swapchain = nullptr;
m_renderTarget = nullptr;
m_overflowTexture = nullptr;
m_renderContext = nullptr;
m_device = nullptr;
}
}
RenderContextVulkanImpl* impl() const RenderContextVulkanImpl* impl() const
{ {
return m_renderContext->static_impl_cast<RenderContextVulkanImpl>(); return m_renderContext->static_impl_cast<RenderContextVulkanImpl>();
@@ -389,6 +448,7 @@ private:
VulkanContext* vk() const { return impl()->vulkanContext(); } VulkanContext* vk() const { return impl()->vulkanContext(); }
ANativeWindow* m_window = nullptr;
BackendParams m_backendParams; BackendParams m_backendParams;
uint32_t m_androidWindowWidth; uint32_t m_androidWindowWidth;
uint32_t m_androidWindowHeight; uint32_t m_androidWindowHeight;
@@ -402,6 +462,7 @@ private:
// size doesn't fit in the window. // size doesn't fit in the window.
PFN_vkDestroySurfaceKHR m_vkDestroySurfaceKHR = nullptr; PFN_vkDestroySurfaceKHR m_vkDestroySurfaceKHR = nullptr;
bool m_printDeviceInitializationMessage = true;
bool m_allowBlitOffscreenToScreen = true; bool m_allowBlitOffscreenToScreen = true;
bool m_alwaysUseOffscreenTexture = false; bool m_alwaysUseOffscreenTexture = false;
}; };

View File

@@ -205,23 +205,33 @@ static void dumpGMs(const std::string& match, bool interactive)
for (const auto& [make_gm, name] : gmRegistry) for (const auto& [make_gm, name] : gmRegistry)
{ {
std::unique_ptr<GM> gm(make_gm()); // Scope the GM so that it destructs (and releases its resources) before
// we call `onceAfterGM` which potentially tears down the entire display
// devices (see: TestingWindowAndroidVulkan)
{
std::unique_ptr<GM> gm(make_gm());
if (!gm) if (!gm)
{ {
continue; continue;
} }
if (match.size() && !contains(name, match)) if (match.size() && !contains(name, match))
{ {
continue; // This gm got filtered out by the '--match' argument. continue; // This gm got filtered out by the '--match' argument.
} }
if (!TestHarness::Instance().claimGMTest(name)) if (!TestHarness::Instance().claimGMTest(name))
{ {
continue; // A different process already drew this gm. continue; // A different process already drew this gm.
} }
gm->onceBeforeDraw(); gm->onceBeforeDraw();
dump_gm(gm.get(), name);
}
// Allow the testing window to do any cleanup it might want to do
// between GMs
TestingWindow::Get()->onceAfterGM();
dump_gm(gm.get(), name);
if (interactive) if (interactive)
{ {
// Wait for any key if in interactive mode. // Wait for any key if in interactive mode.