[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
+7
View File
@@ -603,6 +603,13 @@ namespace MobileGL::MG_Config {
// red-once of the wait-count gate (PackBufferReadbackScenario): it must show a reply
// wait per read.
Uint32 PboReadbackSync = 0;
// MOBILEGL_IPC_POLL_ESCALATE (P10, MG_Remote/CONTRACT-P10.md §1): a glClientWaitSync with a
// zero timeout or a glGetSynciv(GL_SYNC_STATUS) is answered by the client from what the
// server reported (kEventFenceSignaled) - "signaled" when it has been, "not yet" otherwise
// - and only the Nth "not yet" in a row on one fence crosses as a real round trip, so a
// poll loop cannot outlive a late report. 0 = every poll crosses, as before P10: the A/B
// arm and the red-once of the no-reply gate (FencePollScenario).
Uint32 PollEscalate = 64;
// MOBILEGL_IPC_ADOPT_TIER: 2 = emulate (client keeps the shadow and pushes), which
// is the only tier P5 implements and the reason persistent-map-push can be non-zero
// at all (R-6). 0 and 1 parse and are Fatal at use with "P11"; they exist now so the
+4 -2
View File
@@ -423,6 +423,8 @@ namespace MobileGL::MG_ConfigLoader {
ipc.CreateWindow = QueryEnvUint32("MOBILEGL_IPC_CREATE_WINDOW", 10, 1, 12);
// P9 W1: the pack-buffer readback's A/B and red-once control (Config.h says both).
ipc.PboReadbackSync = QueryEnvUint32("MOBILEGL_IPC_PBO_READBACK_SYNC", 0, 0, 1);
// P10: fence polls answered locally; 0 is the A/B arm (Config.h).
ipc.PollEscalate = QueryEnvUint32("MOBILEGL_IPC_POLL_ESCALATE", 64, 0, 1000000);
// The verify harness compares the pushed block against the applier per verb; a
// batched queue lets the comparer read a supplied field mid-apply, which is a
// torn read rather than a divergence. The batch is therefore off whenever the
@@ -524,11 +526,11 @@ namespace MobileGL::MG_ConfigLoader {
// changes the wall clock by seconds (the P12 sweep: 28.3 s at 1 down to 10.8 s at 6) a
// log that does not name it makes every run ambiguous about its own configuration.
MGLOG_I("Config: IPC ring=%uMiB stage=%uMiB wire-deferred=%uMiB spin=%uus event-wait=%ums "
"persistent-block=%uKiB create-window=%u pbo-readback-sync=%u "
"persistent-block=%uKiB create-window=%u pbo-readback-sync=%u poll-escalate=%u "
"adopt-tier=%u verb-barrier=%u run-ahead=%u present-credit=%u control-timeout=%ums "
"cold-start=%ums strict=%d audit=%d role-split-state=%d affinity='%s' surface=%s",
ipc.RingMb, ipc.StageMb, ipc.WireDeferredMb, ipc.SpinUs, ipc.EventWaitMs,
ipc.PersistentBlockKb, ipc.CreateWindow, ipc.PboReadbackSync, ipc.AdoptTier,
ipc.PersistentBlockKb, ipc.CreateWindow, ipc.PboReadbackSync, ipc.PollEscalate, ipc.AdoptTier,
ipc.VerbBarrier, ipc.RunAhead, ipc.PresentCredit, ipc.ControlTimeoutMs, ipc.ColdStartMs,
static_cast<int>(ipc.StrictErrors), static_cast<int>(ipc.Audit),
static_cast<int>(ipc.RoleSplitState), ipc.ServerAffinity.c_str(),
@@ -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};
+22 -2
View File
@@ -22,6 +22,7 @@
#include "../Transport/FdPassing.h"
#include "../Transport/InProcessTransport.h"
#include "WireTables.h"
#include "EmitTables.h"
#include <MGGitHash.h>
#include <MG_Backend/MGPipe/PipeInputs.h>
@@ -482,9 +483,22 @@ namespace MobileGL::MG_Remote::Client {
++delivered;
break;
}
case Transport::kEventFenceSignaled: {
// P10 (CONTRACT-P10.md §1): remembered for every later poll of that fence.
if (view.payloadSize < sizeof(Transport::EventFenceSignaledHead)) {
SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"kEventFenceSignaled\"} - "
"a %llu-byte record is shorter than the fence handle it carries",
static_cast<unsigned long long>(view.payloadSize));
}
const auto* head =
static_cast<const Transport::EventFenceSignaledHead*>(view.payload);
NoteFenceSignaledByServer(MG_Pipe::MGPipeHandle{head->Fence.Slot, head->Fence.Gen});
++delivered;
break;
}
default:
MGLOG_W("MG_Remote client: reverse-channel record kind %u is not consumed in "
"P5c (four of the ten callbacks are armed; the rest are P9's)",
MGLOG_W("MG_Remote client: reverse-channel record kind %u is not one this "
"build consumes",
static_cast<unsigned>(view.kind));
break;
}
@@ -2235,6 +2249,12 @@ namespace MobileGL::MG_Remote::Client {
}
}
void ClientSession::PollEntry() {
if (!m_started || m_events == nullptr || DeviceLost()) return;
Flush();
DrainEventRing(*m_events);
}
void ClientSession::Finish() {
// glFinish. Nothing goes on the wire - Flush and Finish have no record (ARCHITECTURE
// §11) - and under lockstep there was nothing to do, because the client had already
@@ -281,6 +281,12 @@ namespace MobileGL::MG_Remote::Client {
// true while a run-ahead queue still holds them, so under run-ahead glFinish becomes
// exactly WaitForApplyToCatchUp + the drain that comes with it.
void Finish();
// P10 (CONTRACT-P10.md §1): a polling entry point (glClientWaitSync, glGetSynciv(
// GL_SYNC_STATUS)) is a doorbell point. Everything published so far - the polled fence's
// own create record above all - is flushed to the server, and the reverse channel is
// drained, so a fence the server has since reported is known before the poll answers.
// Publishes nothing and waits for nothing.
void PollEntry();
// Submit published commands without waiting for application. This is
// glFlush's contract and the frame-submit boundary for Present.
void Flush();
+110 -6
View File
@@ -1592,15 +1592,87 @@ namespace MobileGL::MG_Remote::Client {
// =============================================================================
// The frontend sees a local opaque token; neither this address nor the driver's
// BackendSyncHandle is serialized. The Fence-kind slot allocator supplies wire identity.
struct RemoteFenceProxy { MG_Pipe::MGPipeHandle Handle; };
struct RemoteFenceProxy {
MG_Pipe::MGPipeHandle Handle;
// P10: zero-timeout polls answered "not yet" in a row since the last escalation.
Uint32 Unanswered = 0;
};
std::mutex g_fenceMutex;
UnorderedMap<MG_Backend::BackendSyncHandle, UniquePtr<RemoteFenceProxy>> g_fenceProxies;
MG_Pipe::MGPipeHandle FenceHandle(MG_Backend::BackendSyncHandle proxy) {
RemoteFenceProxy& FenceProxy(MG_Backend::BackendSyncHandle proxy) {
const auto it = g_fenceProxies.find(proxy);
if (it == g_fenceProxies.end())
Wire::WireProtocolFatal("Fence.proxy", "unknown client fence proxy");
return it->second->Handle;
return *it->second;
}
MG_Pipe::MGPipeHandle FenceHandle(MG_Backend::BackendSyncHandle proxy) { return FenceProxy(proxy).Handle; }
// ---- P10 (CONTRACT-P10.md §1): WHAT THE SERVER HAS SAID IS SIGNALED -----------------
//
// {slot -> gen} of every fence a kEventFenceSignaled named, or a round trip answered
// signaled. ITS OWN MUTEX, NOT g_fenceMutex: the report arrives in the event drain, and
// the drain runs INSIDE the emitters below while they hold g_fenceMutex (EmitAndWait and
// PollEntry both drain), so taking g_fenceMutex there would deadlock the GL thread on
// itself. A report for a fence already deleted leaves a {slot, old gen} entry that no live
// handle matches; the slot's next occupant overwrites it.
std::mutex g_signaledFenceMutex;
UnorderedMap<Uint32, Uint32> g_signaledFences;
std::atomic<Uint64> g_fenceLocalAnswers{0};
std::atomic<Uint64> g_fenceEscalations{0};
std::atomic<Uint64> g_fenceRoundTrips{0};
std::atomic<Uint64> g_fenceServerReports{0};
Bool FenceKnownSignaled(MG_Pipe::MGPipeHandle handle) {
const std::lock_guard<std::mutex> lock(g_signaledFenceMutex);
const auto it = g_signaledFences.find(handle.Slot);
return it != g_signaledFences.end() && it->second == handle.Gen;
}
void RememberFenceSignaled(MG_Pipe::MGPipeHandle handle) {
const std::lock_guard<std::mutex> lock(g_signaledFenceMutex);
g_signaledFences[handle.Slot] = handle.Gen;
}
void ForgetFence(MG_Pipe::MGPipeHandle handle) {
const std::lock_guard<std::mutex> lock(g_signaledFenceMutex);
const auto it = g_signaledFences.find(handle.Slot);
if (it != g_signaledFences.end() && it->second == handle.Gen) g_signaledFences.erase(it);
}
// MOBILEGL_IPC_POLL_ESCALATE=0 is the A/B control: every poll crosses, as before P10. A lost
// device takes the round-trip arm too, whose DECLINED answer is the no-op a dead fence gets.
Bool FencePollsAnswerLocally() {
return MG_Config::Ipc.PollEscalate != 0 && !ClientSession::DeviceLost();
}
// THE LOCAL ANSWER, OR "CROSS". A poll is a doorbell point first (PollEntry: flush what is
// published, drain the reverse channel), so the fence's own create record has reached the
// server and every report it has posted is in. Known signaled -> answered here. Not known
// and the call may not wait (a zero timeout, glGetSynciv) -> answered "not yet" here,
// unless this fence has had POLL_ESCALATE such answers in a row, in which case the poll
// takes one real round trip: the server's own reports are what make a local "not yet"
// eventually true, and the escalation is the bound that holds even if one is late. A call
// that may wait (a non-zero timeout) is never answered "not yet" here - it crosses and
// waits on the server, as the GL call asks.
enum class FencePollAnswer { Signaled, NotYet, Cross };
FencePollAnswer AnswerFencePollLocally(ClientSession& session, RemoteFenceProxy& fence, Bool mayWait) {
if (!FencePollsAnswerLocally()) return FencePollAnswer::Cross;
session.PollEntry();
if (FenceKnownSignaled(fence.Handle)) {
fence.Unanswered = 0;
g_fenceLocalAnswers.fetch_add(1, std::memory_order_relaxed);
return FencePollAnswer::Signaled;
}
if (mayWait) return FencePollAnswer::Cross;
if (++fence.Unanswered < MG_Config::Ipc.PollEscalate) {
g_fenceLocalAnswers.fetch_add(1, std::memory_order_relaxed);
return FencePollAnswer::NotYet;
}
fence.Unanswered = 0;
g_fenceEscalations.fetch_add(1, std::memory_order_relaxed);
return FencePollAnswer::Cross;
}
MG_Backend::BackendSyncHandle EmitFenceSync() {
@@ -1669,16 +1741,33 @@ namespace MobileGL::MG_Remote::Client {
GLenum EmitClientWaitSync(MG_Backend::BackendSyncHandle proxy, GLbitfield flags, GLuint64 timeout) {
ClientSession& session = RequireSession("ClientWaitSync");
const std::lock_guard<std::mutex> lock(g_fenceMutex);
const MG_Pipe::MGPFenceWait request{FenceHandle(proxy), timeout, flags, 0};
return ReadFenceReply(session, MG_Pipe::MGPWireOp::FenceWait, &request, sizeof(request));
RemoteFenceProxy& fence = FenceProxy(proxy);
switch (AnswerFencePollLocally(session, fence, /*mayWait=*/timeout != 0)) {
case FencePollAnswer::Signaled: return GL_ALREADY_SIGNALED;
case FencePollAnswer::NotYet: return GL_TIMEOUT_EXPIRED;
case FencePollAnswer::Cross: break;
}
g_fenceRoundTrips.fetch_add(1, std::memory_order_relaxed);
const MG_Pipe::MGPFenceWait request{fence.Handle, timeout, flags, 0};
const Uint32 result = ReadFenceReply(session, MG_Pipe::MGPWireOp::FenceWait, &request, sizeof(request));
if (result == GL_ALREADY_SIGNALED || result == GL_CONDITION_SATISFIED) RememberFenceSignaled(fence.Handle);
return result;
}
Bool EmitGetSyncStatus(MG_Backend::BackendSyncHandle proxy) {
ClientSession& session = RequireSession("GetSyncStatus");
const std::lock_guard<std::mutex> lock(g_fenceMutex);
const MG_Pipe::MGPHandleOnly desc{FenceHandle(proxy), static_cast<Uint32>(MG_Pipe::MGPipeKind::Fence), 0};
RemoteFenceProxy& fence = FenceProxy(proxy);
switch (AnswerFencePollLocally(session, fence, /*mayWait=*/false)) {
case FencePollAnswer::Signaled: return true;
case FencePollAnswer::NotYet: return false;
case FencePollAnswer::Cross: break;
}
g_fenceRoundTrips.fetch_add(1, std::memory_order_relaxed);
const MG_Pipe::MGPHandleOnly desc{fence.Handle, static_cast<Uint32>(MG_Pipe::MGPipeKind::Fence), 0};
const Uint32 result = ReadFenceReply(session, MG_Pipe::MGPWireOp::FenceStatus, &desc, sizeof(desc));
if (result > 1) Wire::WireProtocolFatal("FenceStatus.reply", "status must be boolean");
if (result != 0) RememberFenceSignaled(fence.Handle);
return result != 0;
}
@@ -1701,6 +1790,7 @@ namespace MobileGL::MG_Remote::Client {
nullptr, 0, nullptr, 0, nullptr);
}
MG_Pipe::MGPipeSlots().Free(MG_Pipe::MGPipeKind::Fence, handle);
ForgetFence(handle);
g_fenceProxies.erase(proxy);
}
@@ -1946,6 +2036,20 @@ namespace MobileGL::MG_Remote::Client {
} // namespace
void NoteFenceSignaledByServer(MG_Pipe::MGPipeHandle fence) {
RememberFenceSignaled(fence);
g_fenceServerReports.fetch_add(1, std::memory_order_relaxed);
}
FencePollCounters ReadFencePollCounters() {
FencePollCounters counters;
counters.LocalAnswers = g_fenceLocalAnswers.load(std::memory_order_relaxed);
counters.Escalations = g_fenceEscalations.load(std::memory_order_relaxed);
counters.RoundTrips = g_fenceRoundTrips.load(std::memory_order_relaxed);
counters.ServerReports = g_fenceServerReports.load(std::memory_order_relaxed);
return counters;
}
const MG_Backend::GlobalBackendFunctionsTable& RemoteEmitTable() {
// Leaked at exit like every other MG_Remote singleton (ID-8): MG_Backend::Init()
// copies it into gBackendFunctionsTable and MobileGL::Destroy() clears that copy from
+19
View File
@@ -284,4 +284,23 @@ namespace MobileGL::MG_Remote::Client {
GLsizei drawCount, GLsizei stride,
GLintptr parameterOffset, Bool hasParameterBuffer);
// ---- P10 (CONTRACT-P10.md §1): fence polls answered from the reverse channel ----------
//
// The event drain's half: the server reported `fence` signaled (kEventFenceSignaled). Every
// later glClientWaitSync / glGetSynciv(GL_SYNC_STATUS) on it answers without a round trip.
void NoteFenceSignaledByServer(MG_Pipe::MGPipeHandle fence);
// What the fence polls cost, for the lanes. `LocalAnswers` = polls answered with no record
// (signaled or not); `Escalations` = zero-timeout polls that had gone unanswered
// MOBILEGL_IPC_POLL_ESCALATE times in a row and took a round trip; `RoundTrips` = every
// fence answer that crossed (escalations, non-zero timeouts, POLL_ESCALATE=0);
// `ServerReports` = kEventFenceSignaled records consumed.
struct FencePollCounters {
Uint64 LocalAnswers = 0;
Uint64 Escalations = 0;
Uint64 RoundTrips = 0;
Uint64 ServerReports = 0;
};
FencePollCounters ReadFencePollCounters();
} // namespace MobileGL::MG_Remote::Client
+54
View File
@@ -155,6 +155,8 @@ namespace MobileGL::MG_Remote::Server {
entry.Live = true;
// GL_Sync.cpp treats an absent slot or a null creation result as always signaled.
entry.Native = table->GL.FenceSync == nullptr ? nullptr : table->GL.FenceSync();
// P10: in creation order, for ReportSignaledFences - the order the GPU finishes them in.
m_unreportedFences.push_back(handle);
return true;
}
@@ -216,6 +218,58 @@ namespace MobileGL::MG_Remote::Server {
}
}
m_fences.clear();
m_unreportedFences.clear();
}
// P10 (CONTRACT-P10.md §1): WHICH OF THE CLIENT'S FENCES HAS THE GPU FINISHED.
//
// The client answers a poll of a fence it knows is signaled without asking - that is the
// whole of package A - and it learns what is signaled only from here. Walked in CREATION
// order and stopped at the first one still pending: one context's GPU work completes in
// the order it was submitted, so a later fence cannot be done while an earlier one is not,
// and the steady-state cost is one status query per drain batch. (Were a backend ever to
// finish them out of order, the later fence would only be reported late - an answer the
// client then gets by its own round trip - never early.)
//
// `flush`: the apply thread is about to go idle. A fence whose commands the driver still
// holds never signals on its own, and an idle server submits nothing more, so the first
// pending fence is asked through ClientWaitSync(GL_SYNC_FLUSH_COMMANDS_BIT, 0) - the one
// backend-neutral way both backends take to submit what they hold - instead of the plain
// status query. Only when idle: mid-stream the next records flush soon enough, and a
// submit per batch would cost Magma its command-buffer batching.
Uint32 ServerVerbSink::ReportSignaledFences(Bool flush) {
if (m_unreportedFences.empty()) return 0;
ServerSession* session = ServerSession::Active();
if (session == nullptr || m_backend == nullptr) return 0;
const MG_Backend::GLFunctionsTable& gl = m_backend->GetBackendFunctions().GL;
Uint32 reported = 0;
while (!m_unreportedFences.empty()) {
const MG_Pipe::MGPipeHandle handle = m_unreportedFences.front();
const auto it = m_fences.find(handle.Slot);
if (it == m_fences.end() || !it->second.Live || it->second.Gen != handle.Gen) {
// Deleted before it was seen signaled: nobody can poll it any more.
m_unreportedFences.pop_front();
continue;
}
const FenceEntry& entry = it->second;
Bool signaled = entry.Native == nullptr;
if (!signaled) {
if (flush && gl.ClientWaitSync != nullptr) {
const GLenum status = gl.ClientWaitSync(entry.Native, GL_SYNC_FLUSH_COMMANDS_BIT, 0);
signaled = status == GL_ALREADY_SIGNALED || status == GL_CONDITION_SATISFIED;
flush = false; // one submit is all an idle server owes
++m_fenceIdleFlushes;
} else {
signaled = gl.GetSyncStatus == nullptr || gl.GetSyncStatus(entry.Native);
}
}
if (!signaled) break;
m_unreportedFences.pop_front();
session->PostFenceSignaled(handle);
++reported;
}
m_fencesReported += reported;
return reported;
}
#include "QueryServer.inc"
+15
View File
@@ -46,6 +46,8 @@
#include "../Transport/Ring.h"
#include "../Wire/PipeWireCodec.h"
#include <deque>
namespace MobileGL::MG_Remote::Server {
// P5e (gl, ID-111): DOES THIS SERVER PUBLISH kCapRunAheadApply? Asked of the server's OWN
@@ -157,6 +159,15 @@ namespace MobileGL::MG_Remote::Server {
Bool OnFenceWait(const MG_Pipe::MGPFenceWait&, Uint32&) override;
Bool OnFenceWaitServer(const MG_Pipe::MGPFenceWait&) override;
void ReleaseFences();
// P10 (CONTRACT-P10.md §1): post kEventFenceSignaled for each client fence, in creation
// order, that the GPU has finished, up to the first one still pending. `flush` = the
// apply thread is going idle: submit what the driver holds first. Apply thread only.
Uint32 ReportSignaledFences(Bool flush);
// True while a created fence has not been reported: the loop then parks with a deadline
// instead of for ever, so a fence that finishes while no record arrives is still seen.
Bool HasUnreportedFences() const { return !m_unreportedFences.empty(); }
Uint64 FencesReported() const { return m_fencesReported; }
Uint64 FenceIdleFlushes() const { return m_fenceIdleFlushes; }
Bool OnQueryCreate(const MG_Pipe::MGPQueryDesc&) override;
Bool OnQueryBegin(const MG_Pipe::MGPQueryDesc&) override;
Bool OnQueryEnd(const MG_Pipe::MGPQueryDesc&) override;
@@ -321,6 +332,10 @@ namespace MobileGL::MG_Remote::Server {
};
FenceEntry& FindFence(MG_Pipe::MGPipeHandle handle);
UnorderedMap<Uint32, FenceEntry> m_fences;
// P10: created and not yet reported signaled, oldest first.
std::deque<MG_Pipe::MGPipeHandle> m_unreportedFences;
Uint64 m_fencesReported = 0;
Uint64 m_fenceIdleFlushes = 0;
struct QueryEntry {
Uint32 Gen = 0;
Uint32 Kind = 0;
+28 -6
View File
@@ -476,11 +476,24 @@ namespace MobileGL::MG_Remote::Server {
return signals.CmdHead->load(std::memory_order_acquire) != ring.LocalTail();
};
// P10 (CONTRACT-P10.md §1): the client answers a fence poll from what this thread reports,
// so a fence the GPU finishes must be reported whether or not another record ever arrives.
// After every drain batch the pending fences are asked (a status query, no submit); a
// thread about to go idle submits first and, while any fence is still pending, parks for
// at most kFenceIdlePollMs rather than for ever. Never on a full SEG_EVENT: posting there
// would park this thread inside the post, and the ring test below already says "not now".
ServerVerbSink& verbs = session.Applier().Verbs();
constexpr Uint32 kFenceIdlePollMs = 1;
const auto eventRingHasRoom = [signals] {
return signals.EventRingFull->load(std::memory_order_acquire) == 0;
};
for (;;) {
PumpControlRequest();
if (stopOrForfeit()) break;
if (signals.EventRingFull->load(std::memory_order_acquire) == 0) DrainRing();
if (eventRingHasRoom()) DrainRing();
if (stopOrForfeit()) break;
if (eventRingHasRoom() && !SessionLatched()) verbs.ReportSignaledFences(/*flush=*/false);
// PH-1 (3): a latched session applies nothing more, so this thread leaves the loop
// (and does not spin on a ring whose head is still ahead of a tail DrainRing will no
// longer move). The exit path below tears the backend down as for a Stop; the
@@ -493,6 +506,12 @@ namespace MobileGL::MG_Remote::Server {
break;
}
if (ready()) continue;
// P10: going idle with a client fence still pending - submit what the driver holds and
// ask again, once, before the park.
if (verbs.HasUnreportedFences() && eventRingHasRoom()) {
verbs.ReportSignaledFences(/*flush=*/true);
if (ready()) continue;
}
const Uint64 waits = m_parks.fetch_add(1, std::memory_order_acq_rel) + 1;
// THE WAIT LEDGER'S SERVER HALF (P5d round 3, package T item 4). The client may not
@@ -519,18 +538,21 @@ namespace MobileGL::MG_Remote::Server {
}
// Flush-on-idle publishes the last reply record even below the batch threshold.
session.FlushDataProgress();
const Bool fencePending = verbs.HasUnreportedFences();
const bool woke = bell.Wait(*signals.ConsumerParked, ready, spinUs,
Transport::kWaitForever, &m_parkBlocks);
fencePending ? kFenceIdlePollMs : Transport::kWaitForever,
&m_parkBlocks);
if (!woke) {
// Wait returns false only on a dead bell or an expired deadline, and this park
// has no deadline. A dead bell IS the shutdown signal (table 3's teardown step
// 2); treating it as anything else would spin at full clock for ever, since
// parking on a dead bell no longer blocks.
// Wait returns false only on a dead bell or an expired deadline. A dead bell IS the
// shutdown signal (table 3's teardown step 2); treating it as anything else would
// spin at full clock for ever, since parking on a dead bell no longer blocks.
if (bell.Dead()) {
MGLOG_D("MG_Remote server: the consumer doorbell is dead; mgl-srv-apply is "
"shutting down");
break;
}
// P10: the deadline is the fence re-check, and the only park that has one.
if (fencePending) continue;
MGLOG_E("MG_Remote server: Doorbell::Wait(kWaitForever) returned false on a live "
"bell - that is impossible by Doorbell::Wait's park loop (the only "
"false returns are the Dead() arm and an expired deadline) and means "
@@ -1128,6 +1128,22 @@ namespace MobileGL::MG_Remote::Server {
PublishEvents();
}
void ServerSession::PostFenceSignaled(MG_Pipe::MGPipeHandle fence) {
if (!m_accepted) {
MGLOG_E_ONCE("MG_Remote server: PostFenceSignaled {%u, %u} before Accept - dropped, "
"there is no SEG_EVENT to carry it on",
fence.Slot, fence.Gen);
return;
}
void* slot = ReserveEventOrBlock(*this, Transport::kEventFenceSignaled, "kEventFenceSignaled",
sizeof(Transport::EventFenceSignaledHead));
Transport::EventFenceSignaledHead head{};
head.Fence.Slot = fence.Slot;
head.Fence.Gen = fence.Gen;
std::memcpy(slot, &head, sizeof(head));
PublishEvents();
}
// PH-6 (ID-P7-2). THE FIRST DROP, AND THE LATCH IT RAISES.
//
// Logged ONCE, by name, with everything a triage needs: which event, how big, what the
@@ -210,6 +210,10 @@ namespace MobileGL::MG_Remote::Server {
// ruling (§1), not a check. A session that has not accepted drops the error with a
// loud line, exactly as RecordError's own no-live-context arm does.
void PostGlError(Uint32 code, const char* message);
// P10 (CONTRACT-P10.md §1): one kEventFenceSignaled for a client fence the apply thread
// found signaled. Posted and published at once - a client may be polling on it right now.
// Dropped with the same loud line as PostGlError before Accept.
void PostFenceSignaled(MG_Pipe::MGPipeHandle fence);
Transport::ITransport* Control_Plane();
// ---- PH-6 (ID-P7-2): THE REVERSE CHANNEL IS FORFEITED, NOT FATAL -------------------
+9
View File
@@ -50,6 +50,7 @@ namespace MobileGL::MG_Remote::Transport {
kEventGpuWritten = 2, // MGPipeCallbacks::OnGpuWritten
kEventSurfaceChanged = 3, // MGPipeCallbacks::OnSurfaceChanged
kEventGlError = 4, // PipeInputs::RecordError, posted by the server session
kEventFenceSignaled = 5, // P10: a client fence the server saw signaled (CONTRACT-P10 §1)
};
// The 8-byte {slot, gen} pair, mirrored (MGPipeHandles.h:54-65).
@@ -110,6 +111,14 @@ namespace MobileGL::MG_Remote::Transport {
std::uint32_t MessageBytes; // strlen + 1, the NUL included
};
static_assert(sizeof(EventGlErrorHead) == 8, "wire shape");
// P10 (CONTRACT-P10.md §1): the fence the server's apply thread found signaled, once per
// fence. The client keeps the answer and gives it to every later glClientWaitSync /
// glGetSynciv(GL_SYNC_STATUS) on that fence without a round trip.
struct EventFenceSignaledHead {
EventHandle Fence;
};
static_assert(sizeof(EventFenceSignaledHead) == 8, "wire shape");
inline constexpr std::uint32_t kEventGlErrorMaxMessageBytes = 1024;
static_assert(kEventGlErrorMaxMessageBytes == 1024,
"CONTRACT-P5C §1 caps an inline GL-error message at 1024 bytes, NUL included");
@@ -789,6 +789,9 @@ struct DecliningSyncPeer : Codec::WireVerbSink {
TEST(RemoteClientControls, DeclinedFenceReplyAnswersSignaledInsteadOfAborting) {
const auto child = RunInChild([] {
// The ROUND-TRIP arm is the subject: since P10 a zero-timeout poll of a fence the server
// has not reported is answered "not yet" locally, and would never reach the reply.
MG_Config::Ipc.PollEscalate = 0;
StartControlSession();
DecliningSyncPeer peer;
peer.Install();
@@ -831,6 +834,7 @@ TEST(RemoteClientControls, ErrorFenceReplyStillRefusesByName) {
// THE TOLERANCE IS NOT BLANKET. An ERROR is a transport fault or a server-side refusal,
// never "the server said no" (R-5/M4), and it keeps the named Fatal it always had.
const auto child = RunInChild([] {
MG_Config::Ipc.PollEscalate = 0; // the round-trip arm, as above
StartControlSession();
// The rows still decline (so the fence is never handed to a backend that cannot
// create one) and EVERY answer that is posted says ERROR instead.
@@ -845,6 +849,60 @@ TEST(RemoteClientControls, ErrorFenceReplyStillRefusesByName) {
ExpectNamedAbort(child, "Fatal{ProtocolCorruption, \"Fence.reply\"}");
}
// =====================================================================================
// P10 A (CONTRACT-P10.md §1): the escalation bound, counted at the record
// =====================================================================================
//
// A peer that never reports the fence and answers every FenceWait record TIMEOUT_EXPIRED: with
// MOBILEGL_IPC_POLL_ESCALATE = 4 the first three zero-timeout polls are answered locally and
// cross nothing, the fourth crosses, and the count starts again. A peer that DOES report (here:
// the reply of a wait that saw it signaled) is answered locally from then on, with no record.
struct CountingFencePeer : Codec::WireVerbSink {
std::atomic<Uint32> waits{0};
std::atomic<Bool> signaled{false};
Bool OnFenceCreate(const MG_Pipe::MGPHandleOnly&) override { return true; }
Bool OnFenceWait(const MGPFenceWait&, Uint32& result) override {
waits.fetch_add(1);
result = signaled.load() ? GL_ALREADY_SIGNALED : GL_TIMEOUT_EXPIRED;
return true;
}
void Install() {
const auto rc = Srv::ServerLoopInstance().RunProbeOnApplyThreadForTesting([](void* self) {
auto& decoder = Srv::ServerSessionInstance().Applier().*PeerMember(DecoderTag{});
decoder.SetVerbSink(static_cast<CountingFencePeer*>(self));
return MOBILEGL_OK;
}, this);
if (rc != MOBILEGL_OK) ::_exit(160);
}
};
TEST(RemoteClientControls, AnUnreportedFencePollCrossesOnlyEveryPollEscalateTimes) {
const auto child = RunInChild([] {
MG_Config::Ipc.PollEscalate = 4;
StartControlSession();
CountingFencePeer peer;
peer.Install();
auto* sync = RemoteEmitTable().GL.FenceSync();
if (sync == nullptr) ::_exit(161);
for (int i = 0; i < 3; ++i)
if (RemoteEmitTable().GL.ClientWaitSync(sync, 0, 0) != GL_TIMEOUT_EXPIRED) ::_exit(162);
ClientSessionInstance().WaitForApplyToCatchUp("P10 control");
if (peer.waits.load() != 0) ::_exit(163); // three local answers, no record
if (RemoteEmitTable().GL.ClientWaitSync(sync, 0, 0) != GL_TIMEOUT_EXPIRED) ::_exit(164);
if (peer.waits.load() != 1) ::_exit(165); // the fourth crossed
peer.signaled.store(true);
for (int i = 0; i < 3; ++i) (void)RemoteEmitTable().GL.ClientWaitSync(sync, 0, 0);
if (RemoteEmitTable().GL.ClientWaitSync(sync, 0, 0) != GL_ALREADY_SIGNALED) ::_exit(166);
if (peer.waits.load() != 2) ::_exit(167); // the next escalation learned it
for (int i = 0; i < 10; ++i)
if (RemoteEmitTable().GL.ClientWaitSync(sync, 0, 0) != GL_ALREADY_SIGNALED) ::_exit(168);
if (peer.waits.load() != 2) ::_exit(169); // known signaled: answered locally for good
RemoteEmitTable().GL.DeleteSync(sync);
ClientSessionInstance().Stop();
});
ExpectChildSuccess(child);
}
TEST(RemoteClientControls, DeviceLostRespecifyDoesNotDieOfTheUncarriedBytesCheck) {
// P12 exit gate (a): killing the server must give a CLEAN device-lost latch. With the
// latch set every verb is DECLINED, so the resource_subdata that R-13.3 owes emits