[Fix] (P14): publish a cached sampler CSO into each share group that names it

This commit is contained in:
2026-10-02 10:35:44 -04:00
parent 82d96a63c9
commit 15f9b27a42
5 changed files with 316 additions and 3 deletions
+19 -2
View File
@@ -82,6 +82,10 @@ namespace MobileGL::MG_Impl::EGLImpl {
// may have moved it since this one last bound. The caller holds EGLOperationMutex.
thread_local Uint64 t_threadContextToken = 0;
Uint64 g_boundContextToken = 0; // guarded by EGLOperationMutex
// P14: the share groups of the two tokens above, kept beside them so the client can say
// which group's object records a record written now lands in (StreamBoundShareGroupToken).
thread_local Uint64 t_threadShareGroupToken = 0;
Uint64 g_boundShareGroupToken = 0; // guarded by EGLOperationMutex
void EmitContextBinding(EGLStateContext* state, EGLContext switched, Uint64 previousToken) {
(void)previousToken;
@@ -91,6 +95,7 @@ namespace MobileGL::MG_Impl::EGLImpl {
// not a slow path.
if (MG_Remote::Client::RunsAsTheServerRole()) return;
t_threadContextToken = switched == EGL_NO_CONTEXT ? 0 : state->GetContextClientToken(switched);
t_threadShareGroupToken = switched == EGL_NO_CONTEXT ? 0 : state->GetContextShareGroupToken(switched);
StreamBindCallingThreadLocked();
}
#endif
@@ -113,7 +118,16 @@ namespace MobileGL::MG_Impl::EGLImpl {
void StreamBindCallingThreadLocked() {
const Uint64 token = t_threadContextToken;
if (token == g_boundContextToken) return;
if (MG_Remote::Client::EmitBindContextRecord(token)) g_boundContextToken = token;
if (MG_Remote::Client::EmitBindContextRecord(token)) {
g_boundContextToken = token;
g_boundShareGroupToken = t_threadShareGroupToken;
}
}
Bool StreamBoundShareGroupToken(Uint64* outToken) {
if (!StreamGateActive()) return false;
*outToken = g_boundShareGroupToken;
return true;
}
namespace {
@@ -593,7 +607,10 @@ namespace MobileGL::MG_Impl::EGLImpl {
// The server drops its binding to 0 when the current context is destroyed
// (ServerSession::DestroyContext's compare-and-swap); the gate mirrors it so the next
// write re-binds.
if (g_boundContextToken == contextToken) g_boundContextToken = 0;
if (g_boundContextToken == contextToken) {
g_boundContextToken = 0;
g_boundShareGroupToken = 0;
}
const auto emitted = MG_Remote::Client::SendDestroyContextFrame(contextToken);
if (emitted == MG_Remote::Client::ContextFrameEmit::Refused) {
MGLOG_E_ONCE("eglDestroyContext: the server holds no context token %llu; the "
+5
View File
@@ -91,6 +91,11 @@ namespace MobileGL::MG_Impl::EGLImpl {
Bool StreamGateActive();
std::recursive_mutex& StreamMutex();
void StreamBindCallingThreadLocked();
// P14: the share-group token of the context the stream is bound to - the group whose object
// records anything written right now lands in on the server. False while the gate is inactive
// (a monolith, the server's own apply thread), where no binding crosses. Read under
// StreamMutex(), like the binding itself.
Bool StreamBoundShareGroupToken(Uint64* outToken);
class GLStreamScope {
public:
+92 -1
View File
@@ -38,6 +38,9 @@
//
// HEADER-ONLY, for the ownership reason Tracker.h and ResourceTracker.h both state.
#if MOBILEGL_PIPE_PUSH
#if MOBILEGL_BUILD_DISAGGREGATED
#include <MG_Impl/EGLImpl/EGLImpl.h>
#endif
#include <MG_Impl/Pipe/SetHashSuppressor.h>
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Impl/Pipe/Tracker.h>
@@ -209,6 +212,30 @@ namespace MobileGL::MG_Pipe {
"a cache that cannot hold one whole emission pass would mint over capacity on "
"every pass; correctness against eviction is the reference count, not this bound");
// P14 (docs/Disaggregated/design/11-state-ownership.md): THE SHARE GROUP A RECORD EMITTED NOW
// LANDS IN. The server files object records - create_sampler_state's among them - per
// {session, share group}, under the context the stream is bound to. That is the stream's own
// answer and not the calling thread's: a GL entry point rebinds to the caller first
// (GLStreamScope), but an EGL operation does not, and eglCreateContext's birth hooks mint.
// Without a stream (a monolith, a unit case) the caller's GLContext is the answer. Group
// tokens are minted once and never reused (EGLState::CreateContext), which is what makes them
// usable as an identity; 0 is "no EGL context", the server's "no context" applier.
inline Uint64 MGPipeCurrentShareGroupToken() {
#if MOBILEGL_BUILD_DISAGGREGATED
Uint64 bound = 0;
if (MG_Impl::EGLImpl::StreamBoundShareGroupToken(&bound)) return bound;
#endif
const auto& context = MG_State::pGLContext;
if (!context) return 0;
const auto& group = context->GetShareGroup();
return group ? group->GetToken() : 0;
}
// How many share groups one entry remembers having published into. Forgetting one is always
// safe - the next Acquire there re-issues the create on the same handle, which the applier
// takes as a serial bump - so this bounds memory, not correctness.
inline constexpr SizeT kMGPipeSamplerCsoPublishedGroups = 8;
class MGPipeSamplerCsoCache {
public:
struct Counters {
@@ -244,6 +271,9 @@ namespace MobileGL::MG_Pipe {
// choice structurally guarantees it stays 0; it is what would say so in a shipped
// build if a second caller of Evict ever appeared.
Uint64 ReferencedEvictions = 0;
// create_sampler_state RE-ISSUED on an existing handle because the bound share group
// had not been sent it - see AcquireCanonical.
Uint64 GroupPublishes = 0;
};
// The handle for `params`' value, AND A REFERENCE ON IT. Mints and emits
@@ -376,8 +406,49 @@ namespace MobileGL::MG_Pipe {
// THE CANONICAL BYTES, not the caller's. This is the memcmp's other operand and it
// has to have the same deterministic padding the probe's copy has.
SamplerParameters Params{};
// THE SHARE GROUPS whose object records hold this handle's create_sampler_state
// (MGPipeCurrentShareGroupToken). The entry is process-wide but the applier's record
// is per group, so a hit from a group not listed here re-issues the create before
// the caller names the handle there. Full -> the oldest is overwritten.
Uint64 PublishedGroups[kMGPipeSamplerCsoPublishedGroups] = {};
Uint32 PublishedGroupCount = 0;
Uint32 NextPublishedGroup = 0;
};
static Bool IsPublishedIn(const Entry& entry, Uint64 group) {
for (Uint32 i = 0; i < entry.PublishedGroupCount; ++i) {
if (entry.PublishedGroups[i] == group) return true;
}
return false;
}
static void NotePublishedIn(Entry& entry, Uint64 group) {
if (IsPublishedIn(entry, group)) return;
if (entry.PublishedGroupCount < kMGPipeSamplerCsoPublishedGroups) {
entry.PublishedGroups[entry.PublishedGroupCount++] = group;
return;
}
entry.PublishedGroups[entry.NextPublishedGroup] = group;
entry.NextPublishedGroup = (entry.NextPublishedGroup + 1) % kMGPipeSamplerCsoPublishedGroups;
}
// The create Mint sends, re-issued on an entry that already exists: same handle, same
// canonical bytes.
void EmitCreate(Entry& entry, Uint64& payloadBytes) {
MGPSamplerDesc desc{};
desc.Cso = entry.Cso;
desc.Parameters.Seg = kMGHostSpanSegNone;
desc.Parameters.Offset = 0;
desc.Parameters.Size = 0;
MGPipeRouteCreateSamplerState(desc, &entry.Params);
NotePublishedIn(entry, MGPipeCurrentShareGroupToken());
payloadBytes += sizeof(MGPSamplerDesc) + sizeof(SamplerParameters);
if (MG_Util::PipeStats::Enabled()) {
MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::CsoBlobBytes,
sizeof(SamplerParameters));
}
}
MGPipeHandle AcquireCanonical(Uint64 hash, const SamplerParameters& canon, Uint64& payloadBytes) {
++m_counters.Acquisitions;
for (SizeT i = 0; i < m_entries.size(); ++i) {
@@ -397,6 +468,17 @@ namespace MobileGL::MG_Pipe {
m_entries[i].LastUsed = ++m_clock;
++m_entries[i].RefCount;
++m_counters.Hits;
// A HIT IS ONLY A HIT IN THE GROUPS THAT WERE SENT THE CREATE. Two non-shared
// contexts asking for one value get one handle, but the server holds the record
// in the first one's share group only; naming the handle from the second (a
// set_texture_params, a bind_sampler_states) resolved to nothing there and the
// texture sampled with default parameters. The re-issue lands the record in this
// group under the same handle, which is safe because the slot space is
// process-wide: another group's table holding it is wider, never aliased.
if (!IsPublishedIn(m_entries[i], MGPipeCurrentShareGroupToken())) {
EmitCreate(m_entries[i], payloadBytes);
++m_counters.GroupPublishes;
}
return m_entries[i].Cso;
}
return Mint(hash, canon, payloadBytes);
@@ -457,6 +539,7 @@ namespace MobileGL::MG_Pipe {
// call and the bytes it stores are provably the bytes the memcmp will confirm
// against later.
MGPipeRouteCreateSamplerState(desc, &m_entries.back().Params);
NotePublishedIn(m_entries.back(), MGPipeCurrentShareGroupToken());
// THE CREATE ACTUALLY WENT OUT, so the publication latch is taken here and nowhere
// else (contract-v2 §3.1). It is what the six death helpers read, and without it a
// delete_sampler_state can never go out for this kind.
@@ -491,7 +574,15 @@ namespace MobileGL::MG_Pipe {
// only then does the slot go back. There is no NotifyStateObjectDestroyed step
// here - a content-addressed CSO has no frontend object whose death is being
// announced, which is precisely why this eviction is the only death path it has.
MGPipeRouteDeleteSamplerState(handle);
//
// THE DELETE REACHES THE BOUND SHARE GROUP ONLY, so it is sent only when that group
// holds the record; a group that was never sent the create would count
// it as a refused object call. Other groups keep an unreferenced record at this slot
// (nothing may name it: the count is 0) until a create for the slot's next
// generation is published there and starts it over, or the group is torn down.
if (IsPublishedIn(m_entries[index], MGPipeCurrentShareGroupToken())) {
MGPipeRouteDeleteSamplerState(handle);
}
// AND THE LATCH GOES WITH THE DELETE. This is the "an emitter that drops a record
// for its own reasons calls MGPipeNoteHandleUnpublished" half of the publication
// protocol (contract-v2 §3.1): the record is gone, so a death helper reaching this
+113
View File
@@ -795,6 +795,119 @@ namespace {
EXPECT_EQ(Cache().GetCounters().Collisions, 0u);
}
// ============================ P14: one cache, many share groups ============================
// The calling thread's {session, context} for the applier's key resolver - the shape the
// server's own resolver (SessionRuntime.cpp) fills in from the session's bound context.
thread_local Uint64 t_groupSession = 0;
thread_local Uint64 t_groupContext = 0;
Bool TwoGroupKeyResolver(MGPipeApplierKey* outKey) {
if (t_groupSession == 0) return false;
outKey->SessionKey = t_groupSession;
outKey->ContextToken = t_groupContext;
return true;
}
// Two contexts of one session in two share groups (or one, with `shared`), made current in
// turn on BOTH halves a served client keeps in step: the frontend's pGLContext and the
// server's bound context (GLStreamScope rebinds the session before a GL entry point writes).
struct ShareGroupPair {
static constexpr Uint64 kSession = 0x5A3E5C50C0000000ull;
explicit ShareGroupPair(Bool shared) {
m_previous = MG_State::pGLContext;
for (Uint64 i = 0; i < 2; ++i) {
const Uint64 group = shared ? 0x51 : 0x51 + i;
m_contexts[i] = MakeShared<GLContext>(MakeShared<MG_State::GLState::ShareGroupState>(group));
MGPipeApplierRegisterContext(kSession, i + 1, group);
}
MGPipeSetApplierKeyResolver(&TwoGroupKeyResolver);
}
~ShareGroupPair() {
t_groupSession = 0;
t_groupContext = 0;
MGPipeSetApplierKeyResolver(nullptr);
MGPipeApplierReleaseSession(kSession);
MG_State::pGLContext = m_previous;
}
ShareGroupPair(const ShareGroupPair&) = delete;
ShareGroupPair& operator=(const ShareGroupPair&) = delete;
void MakeCurrent(Uint32 index) {
MG_State::pGLContext = m_contexts[index];
t_groupSession = kSession;
t_groupContext = index + 1;
}
SharedPtr<GLContext> m_previous;
SharedPtr<GLContext> m_contexts[2];
};
// The record the applier the calling thread is bound to holds for `cso`, or null.
const MGPipeSamplerCsoRecord* BoundGroupSamplerRecord(MGPipeHandle cso) {
const auto& table = MGPipeApplier().SamplerCsos;
if (MGPipeHandleIsNull(cso) || cso.Slot >= table.size()) return nullptr;
const MGPipeSamplerCsoRecord& record = table[cso.Slot];
return record.Live && record.Gen == cso.Gen ? &record : nullptr;
}
// THE CACHE IS PROCESS-WIDE AND THE RECORD IS PER SHARE GROUP. A value minted while one
// non-shared context was current is a cache hit for the next one, and before the fix that hit
// handed back a handle the second group's applier had never been sent: the texture naming it
// sampled with default parameters on Magma and with none at all on Espryt. Red without the
// re-issue in AcquireCanonical: the second group has no record and GroupPublishes stays 0.
TEST(SamplerEmit, AValueMintedInOneShareGroupIsPublishedIntoTheNextGroupThatAcquiresIt) {
EmitterScope scope;
ShareGroupPair groups(/*shared=*/false);
Cache().ResetCounters();
const SamplerParameters params = DistinctParameters();
Uint64 payload = 0;
groups.MakeCurrent(0);
const MGPipeHandle first = Cache().Acquire(params, payload);
ASSERT_NE(BoundGroupSamplerRecord(first), nullptr) << "the minting group was not sent the create";
groups.MakeCurrent(1);
const MGPipeHandle second = Cache().Acquire(params, payload);
EXPECT_EQ(second, first) << "one value is still one CSO across groups";
const MGPipeSamplerCsoRecord* record = BoundGroupSamplerRecord(second);
ASSERT_NE(record, nullptr) << "the second share group names a sampler CSO its applier never received";
EXPECT_EQ(record->Params.magFilter, params.magFilter);
EXPECT_EQ(record->Params.borderColorForm, params.borderColorForm);
EXPECT_EQ(Cache().GetCounters().Mints, 1u);
EXPECT_EQ(Cache().GetCounters().GroupPublishes, 1u);
// Once per group, not once per acquire: a second hit in either group sends nothing.
const MGPipeHandle again = Cache().Acquire(params, payload);
groups.MakeCurrent(0);
const MGPipeHandle back = Cache().Acquire(params, payload);
EXPECT_EQ(Cache().GetCounters().GroupPublishes, 1u);
EXPECT_NE(BoundGroupSamplerRecord(back), nullptr);
for (const MGPipeHandle held : {first, second, again, back}) Cache().Release(held);
EXPECT_EQ(Cache().GetCounters().UnknownReleases + Cache().GetCounters().UnderflowedReleases, 0u);
}
// The control: contexts that SHARE a group share the record too, so a hit from the second
// re-issues nothing.
TEST(SamplerEmit, ASharingContextReusesTheRecordItsGroupAlreadyHolds) {
EmitterScope scope;
ShareGroupPair groups(/*shared=*/true);
Cache().ResetCounters();
const SamplerParameters params = DistinctParameters();
Uint64 payload = 0;
groups.MakeCurrent(0);
const MGPipeHandle first = Cache().Acquire(params, payload);
groups.MakeCurrent(1);
const MGPipeHandle second = Cache().Acquire(params, payload);
EXPECT_EQ(second, first);
EXPECT_NE(BoundGroupSamplerRecord(second), nullptr);
EXPECT_EQ(Cache().GetCounters().GroupPublishes, 0u);
Cache().Release(first);
Cache().Release(second);
}
// ============================ D-F2: the sampler view ============================
TEST(SamplerEmit, AnUnchangedTextureReIssuesNothing) {
+87
View File
@@ -1888,6 +1888,93 @@ TEST(TextureEmit, ATextureBornBeforeTheConsumerRegisteredGetsItsRecordFromItsFir
EXPECT_EQ(Textures().RefusedParamCount(), 0u);
}
// ============================ P14: textures of two share groups ============================
//
// THE SHAPE BEHIND A SERVED APPLICATION's BLACK TEXTURES. Two non-shared contexts each give a
// texture the same sampler parameters. The server files texture and sampler-CSO records per
// share group, while the client's sampler CSO cache is one per process - so the second texture's
// set_texture_params named a built-in sampler handle that only the FIRST group's applier had a
// record for. Magma sampled that texture with default parameters and Espryt declined the push,
// leaving the driver's incomplete-texture default. Red without the per-group re-issue in
// MGPipeSamplerCsoCache::AcquireCanonical.
namespace {
thread_local Uint64 t_pairSession = 0;
thread_local Uint64 t_pairContext = 0;
Bool PairKeyResolver(MGPipeApplierKey* outKey) {
if (t_pairSession == 0) return false;
outKey->SessionKey = t_pairSession;
outKey->ContextToken = t_pairContext;
return true;
}
// Two contexts of one served session in two share groups, made current in turn on both halves
// a served client keeps in step: the frontend's GL state and the server's bound context
// (GLStreamScope rebinds the session before a GL entry point writes).
struct ShareGroupPair {
static constexpr Uint64 kSession = 0x5A3E7E50C0000000ull;
ShareGroupPair() : m_previous(MG_State::pGLContext) {
for (Uint64 i = 0; i < 2; ++i) {
m_contexts[i] = MakeShared<GLContext>(MakeShared<MG_State::GLState::ShareGroupState>(0x61 + i));
MGPipeApplierRegisterContext(kSession, i + 1, 0x61 + i);
}
MGPipeSetApplierKeyResolver(&PairKeyResolver);
}
~ShareGroupPair() {
t_pairSession = 0;
t_pairContext = 0;
MGPipeSetApplierKeyResolver(nullptr);
MGPipeApplierReleaseSession(kSession);
MG_State::pGLContext = m_previous;
}
ShareGroupPair(const ShareGroupPair&) = delete;
ShareGroupPair& operator=(const ShareGroupPair&) = delete;
void MakeCurrent(Uint32 index) {
MG_State::pGLContext = m_contexts[index];
t_pairSession = kSession;
t_pairContext = index + 1;
}
SharedPtr<GLContext> m_previous;
SharedPtr<GLContext> m_contexts[2];
};
} // namespace
TEST(TextureEmit, ATextureInASecondShareGroupNamesABuiltinSamplerItsOwnGroupHolds) {
TextureScope scope;
ShareGroupPair groups;
const auto makeCurrent = [&](Uint32 index) { groups.MakeCurrent(index); };
makeCurrent(0);
auto first = MakeTexture2D(301, 8);
first->GetSamplerObject()->SetLodBias(1.25f);
MG_Pipe::MGPipeEmitTextureParams(*first);
makeCurrent(1);
auto second = MakeTexture2D(302, 8);
second->GetSamplerObject()->SetLodBias(1.25f);
MG_Pipe::MGPipeEmitTextureParams(*second);
const MGPipeResourceRecord* record = AppliedTexture(Textures().FindTexture(*second));
ASSERT_NE(record, nullptr) << "the second group holds no record for its own texture";
const MGPipeHandle sampler = record->Params.BuiltinSampler;
const auto& samplers = MGPipeApplier().SamplerCsos;
ASSERT_FALSE(MGPipeHandleIsNull(sampler));
ASSERT_LT(sampler.Slot, samplers.size())
<< "the texture names built-in sampler {" << sampler.Slot << ", " << sampler.Gen
<< "}, which its share group's applier was never sent";
EXPECT_TRUE(samplers[sampler.Slot].Live && samplers[sampler.Slot].Gen == sampler.Gen)
<< "the texture names built-in sampler {" << sampler.Slot << ", " << sampler.Gen
<< "}, which its share group's applier was never sent";
EXPECT_EQ(samplers[sampler.Slot].Params.lodBias, 1.25f);
// Each texture dies in its own group, where its record is.
second.reset();
makeCurrent(0);
first.reset();
}
#endif // MOBILEGL_PIPE_PUSH
// =========================================================================================