[Feat] (MG_Remote): fence polls are answered from the server's reports - the apply thread posts kEventFenceSignaled as the GPU finishes each client fence (and submits and re-checks while idle), glClientWaitSync(0) / glGetSynciv answer locally, and only every MOBILEGL_IPC_POLL_ESCALATE-th unanswered poll crosses

This commit is contained in:
2026-09-29 03:59:44 -04:00
parent de33d40173
commit bcff9b00cc
18 changed files with 538 additions and 17 deletions
@@ -87,6 +87,8 @@ add_executable(MobileGLIntegrationTest
# P9 (W1): reads into a pixel pack buffer answered by nothing. Ordinary GL cases too, plus
# one wire case that skips where there is no wire.
Scenarios/PackBufferReadbackScenario.cpp
# P10 (A): fence polls answered from the server's reports.
Scenarios/FencePollScenario.cpp
Scenarios/PrimitiveRestartScenario.cpp
Scenarios/FragCoordOriginScenario.cpp
Scenarios/ClientVertexArrayScenario.cpp
@@ -2458,6 +2460,11 @@ if (MOBILEGL_BUILD_DISAGGREGATED)
# reply-slot record, one mark per read). The Magma arms are the `Pbo.` block after the Band
# block below.
mgl_itest_register_split_arms("PackBufferReadbackScenario.*" "")
# P10 (A, MG_Remote/CONTRACT-P10.md §1): fence polls answered from kEventFenceSignaled - the
# idiom's poll loops end (with and without the flush bit), a signaled fence's polls cross
# nothing, and an idle server still reports a fence that finishes. The Magma arms are the
# `Fence.` block beside the `Pbo.` block below.
mgl_itest_register_split_arms("FencePollScenario.*" "")
# DepthStencilReadbackMatrixScenario GETS ITS OWN BLOCK, because it needs a variable the
# registration macro cannot carry: MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION=1.
@@ -3644,6 +3651,19 @@ if (MOBILEGL_BUILD_DISAGGREGATED)
${mglItestArmProperties}
ENVIRONMENT "${mglItestMagmaBandEnvironment}\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/p9-pbo${mglItestArmLogTag}-${pboCase}.log")
endforeach()
# P10 (A): the fence polls on Magma. The server half is backend-neutral (the backend
# table's FenceSync / GetSyncStatus / ClientWaitSync), so the same three cases.
foreach(fenceCase ZeroTimeoutPollLoopsEndInBoundedTime
PollsOfASignaledFenceCrossNothing
AFenceThatFinishesWhileTheClientOnlyPollsIsReported)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.${mglItestArm}.Fence."
TEST_FILTER "FencePollScenario.${fenceCase}"
DISCOVERY_TIMEOUT 30
PROPERTIES LABELS "${mglItestMagmaLabels}" TIMEOUT ${MGL_ITEST_TIMEOUT}
${mglItestArmProperties}
ENVIRONMENT "${mglItestMagmaBandEnvironment}\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/p10-fence${mglItestArmLogTag}-${fenceCase}.log")
endforeach()
endforeach()
endif()
@@ -168,6 +168,11 @@ namespace MGITest {
state.replyPostings = session->ReplyPostings();
state.packBufferReadbackMarks = MobileGL::MG_Remote::Client::ProducerMarkCount(
MobileGL::MG_Remote::Client::GpuWriteProducer::ReadPixelsPackBuffer);
const auto fences = MobileGL::MG_Remote::Client::ReadFencePollCounters();
state.fenceLocalAnswers = fences.LocalAnswers;
state.fenceEscalations = fences.Escalations;
state.fenceRoundTrips = fences.RoundTrips;
state.fenceServerReports = fences.ServerReports;
}
#endif
return state;
@@ -130,6 +130,15 @@ namespace MGITest {
// apart from both sides.
unsigned long long replyPostings = 0;
unsigned long long packBufferReadbackMarks = 0;
// ---- P10 (CONTRACT-P10.md §1): what the fence polls cost ----------------------------
// EmitTables.h FencePollCounters, verbatim: polls answered with no record, zero-timeout
// polls escalated to a round trip, every fence answer that crossed, and the server's
// kEventFenceSignaled reports consumed.
unsigned long long fenceLocalAnswers = 0;
unsigned long long fenceEscalations = 0;
unsigned long long fenceRoundTrips = 0;
unsigned long long fenceServerReports = 0;
};
SplitRuntimeState PeekSplitRuntime();
@@ -0,0 +1,149 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/FencePollScenario.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// P10 (A, MG_Remote/CONTRACT-P10.md §1): FENCE POLLS ANSWERED FROM THE REVERSE CHANNEL.
//
// A glClientWaitSync / glGetSynciv(GL_SYNC_STATUS) used to be a kWaitReply record: its answer
// came back only after the server had applied every record before it, so a poll was a full
// pipeline drain and, over TCP, a round trip - once per frame in Minecraft's fence use. Now the
// server reports each fence the GPU finishes (kEventFenceSignaled) and the client answers polls
// from those reports; the Nth unanswered zero-timeout poll in a row (MOBILEGL_IPC_POLL_ESCALATE)
// still crosses, which is what bounds a loop whose report is late.
//
// Three cases. The first is ordinary GL - the idiom's two poll loops end - and its flush-bit arm
// runs on the monolith lanes too. The other two are about the wire and skip where there is none.
#include <chrono>
#include <thread>
#include "../Harness/ScenarioFixture.h"
#include "../Harness/SplitRuntimePeek.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
using Clock = std::chrono::steady_clock;
constexpr auto kLoopBound = std::chrono::seconds(10);
class FencePollScenario : public ScenarioTest {
protected:
// Some GPU work for a fence to wait behind.
static void QueueWork() {
glClearColor(0.25f, 0.5f, 0.75f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
}
};
// The GL idiom: `while (glClientWaitSync(s, flags, 0) == GL_TIMEOUT_EXPIRED) {}` and the
// glGetSynciv twin. With nothing else in the loop, no record would ever be published if
// the poll were not a doorbell point, and a local "not yet" would be repeated for ever if
// nothing made it true - so the loop ending, bounded, is the property.
//
// WITHOUT GL_SYNC_FLUSH_COMMANDS_BIT GL itself does not promise the loop ends (the fence
// may sit unflushed in the driver), so that arm is split-only: there the transport
// promises it, because the poll flushes the link and an idle server submits.
TEST_F(FencePollScenario, ZeroTimeoutPollLoopsEndInBoundedTime) {
if (!Ready()) return;
const bool split = SplitRuntimeSkipReason().empty();
for (const GLbitfield flags : {GLbitfield(GL_SYNC_FLUSH_COMMANDS_BIT), GLbitfield(0)}) {
if (flags == 0 && !split) continue;
QueueWork();
GLsync sync = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
ASSERT_NE(sync, nullptr);
const auto start = Clock::now();
GLenum status = GL_TIMEOUT_EXPIRED;
unsigned long long polls = 0;
while ((status = glClientWaitSync(sync, flags, 0)) == GL_TIMEOUT_EXPIRED &&
Clock::now() - start < kLoopBound) {
++polls;
}
EXPECT_TRUE(status == GL_ALREADY_SIGNALED || status == GL_CONDITION_SATISFIED)
<< "glClientWaitSync(flags 0x" << std::hex << flags << std::dec << ", 0) still "
<< status << " after " << polls << " polls";
glDeleteSync(sync);
}
QueueWork();
GLsync sync = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
ASSERT_NE(sync, nullptr);
const auto start = Clock::now();
GLint value = GL_UNSIGNALED;
while (value != GL_SIGNALED && Clock::now() - start < kLoopBound) {
glGetSynciv(sync, GL_SYNC_STATUS, 1, nullptr, &value);
}
EXPECT_EQ(value, GL_SIGNALED) << "glGetSynciv(GL_SYNC_STATUS) never read signaled";
glDeleteSync(sync);
EXPECT_EQ(FirstGLError(), static_cast<GLenum>(GL_NO_ERROR));
}
// THE WIRE HALF: once a fence is known signaled, polling it costs nothing - no reply-slot
// record, no round trip - however often it is asked. MOBILEGL_IPC_POLL_ESCALATE=0 puts the
// round trip back and must fail both counters (its red-once).
TEST_F(FencePollScenario, PollsOfASignaledFenceCrossNothing) {
if (!Ready()) return;
const std::string why = SplitRuntimeSkipReason();
if (!why.empty()) GTEST_SKIP() << why;
QueueWork();
GLsync sync = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
ASSERT_NE(sync, nullptr);
const GLenum waited = glClientWaitSync(sync, GL_SYNC_FLUSH_COMMANDS_BIT, 5000000000ull);
ASSERT_TRUE(waited == GL_ALREADY_SIGNALED || waited == GL_CONDITION_SATISFIED) << waited;
constexpr unsigned kPolls = 100;
const SplitRuntimeState before = PeekSplitRuntime();
for (unsigned i = 0; i < kPolls; ++i) {
EXPECT_EQ(glClientWaitSync(sync, 0, 0), static_cast<GLenum>(GL_ALREADY_SIGNALED));
GLint value = GL_UNSIGNALED;
glGetSynciv(sync, GL_SYNC_STATUS, 1, nullptr, &value);
EXPECT_EQ(value, GL_SIGNALED);
}
const SplitRuntimeState after = PeekSplitRuntime();
glDeleteSync(sync);
EXPECT_EQ(after.replyPostings - before.replyPostings, 0u)
<< "polling a signaled fence published reply-slot records (MOBILEGL_IPC_POLL_ESCALATE=0 "
"does this on purpose)";
EXPECT_EQ(after.fenceRoundTrips - before.fenceRoundTrips, 0u);
EXPECT_EQ(after.fenceLocalAnswers - before.fenceLocalAnswers, 2ull * kPolls);
}
// THE SERVER'S HALF: a fence the GPU finishes while the client sends nothing - it only
// polls - is still reported. The apply thread is idle then; it must submit what the driver
// holds and keep re-checking rather than park for ever, or the only way out of the loop is
// the escalation. The polls are paced (10 ms apart) so the escalation cannot come first by
// rate: 64 of them is 640 ms, far more than a clear takes. Red-once: take the idle
// re-check out of ServerLoop and this case escalates.
TEST_F(FencePollScenario, AFenceThatFinishesWhileTheClientOnlyPollsIsReported) {
if (!Ready()) return;
const std::string why = SplitRuntimeSkipReason();
if (!why.empty()) GTEST_SKIP() << why;
QueueWork();
GLsync sync = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
ASSERT_NE(sync, nullptr);
const SplitRuntimeState before = PeekSplitRuntime();
const auto start = Clock::now();
GLenum status = GL_TIMEOUT_EXPIRED;
while ((status = glClientWaitSync(sync, 0, 0)) == GL_TIMEOUT_EXPIRED &&
Clock::now() - start < kLoopBound) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
const SplitRuntimeState after = PeekSplitRuntime();
glDeleteSync(sync);
EXPECT_TRUE(status == GL_ALREADY_SIGNALED || status == GL_CONDITION_SATISFIED) << status;
EXPECT_EQ(after.fenceEscalations - before.fenceEscalations, 0u)
<< "the loop needed an escalated round trip: the server did not report the fence on its "
"own while the client only polled";
EXPECT_GE(after.fenceServerReports - before.fenceServerReports, 1u);
}
} // namespace
} // namespace MGITest
@@ -30,7 +30,9 @@ TEST_F(SyncWireScenario, FenceWaitStatusAndDeletionCrossAndPreserveRenderedPixel
glWaitSync(sync, 0, GL_TIMEOUT_IGNORED);
glDeleteSync(sync);
EXPECT_FALSE(glIsSync(sync));
EXPECT_GE(PeekSplitRuntime().emitSeq, before + 5);
// Create, the server-side wait and the delete always cross; since P10 the two polls may be
// answered from the server's fence report instead (FencePollScenario), so they are not counted.
EXPECT_GE(PeekSplitRuntime().emitSeq, before + 3);
GLubyte pixel[4]{};
glReadPixels(1, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel);
const int expected[4] = {64, 128, 191, 255};