GV7-1d-ii-a: extract the front<->back channel from GSState

Move the record ring, wake semaphore, and both pool arenas/free rings into
GSBackQueue::Channel. Each GSState owns channel storage and works through a
m_chan pointer (defaulting to its own storage), so the upcoming two-object
pipelined split can aim a front parser object at the back object's channel
without touching any record or pool logic. DrainBackQueue keys on the
channel's consumer_running flag instead of the producer flag, making drains
work from either side; payload node-0 adoption becomes an explicit
AdoptTransferBuffer() run by the staging object. The destructor frees only
its own channel storage. No behavior change in any mode.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Brian Degenhardt
2026-07-19 13:42:51 -07:00
co-authored by Claude
parent 6d998af9ff
commit 94de4fd55c
3 changed files with 92 additions and 44 deletions
+34
View File
@@ -11,9 +11,12 @@
#include "GS/GSVertexKick.h"
#include "GS/Renderers/Common/GSVertex.h"
#include "common/Threading.h"
#include <atomic>
#include <memory>
#include <type_traits>
#include <vector>
// GV-7: self-contained records crossing the GS front (GIF parse / vertex kick /
// draw buffering) → back (local memory, texture cache, draw, present) boundary.
@@ -354,4 +357,35 @@ namespace GSBackQueue
static_assert(std::is_trivially_copyable_v<ReleasePayloadRecord>);
using RecordRing = SpscRing<RecordSlot, 512>;
// GV7-1d-ii: everything shared between the producing (front) and consuming
// (back) sides of the split. In single-object modes the GSState uses its own
// channel; under the two-object pipelined split the front parser object
// points at the back object's channel, so records, pool nodes, and drain
// waits all target one shared instance. The channel's storage owner (the
// back object) frees the pooled arrays in its destructor; the producer must
// be destroyed or drained first.
struct Channel
{
RecordRing ring;
Threading::WorkSema sema;
// Set while the back thread is running. Read/written only on the MTGS
// thread (start/stop/drain all happen there), so a plain bool is enough.
bool consumer_running = false;
// Draw-node pool: the producer acquires (free ring first, then arena
// growth up to the cap, then backpressure), the consumer releases after
// the draw executes. Free-ring capacity == arena cap, so Release can
// never fail.
static constexpr u32 kMaxDrawNodes = 64;
std::vector<DrawNode*> draw_arena;
SpscRing<DrawNode*, kMaxDrawNodes> draw_free;
// Transfer payload pool: the producer stages into the current node, the
// consumer releases rotated-out nodes via RELEASE_PAYLOAD records.
static constexpr u32 kMaxPayloadNodes = 8;
std::vector<PayloadNode*> payload_arena;
SpscRing<PayloadNode*, kMaxPayloadNodes> payload_free;
};
} // namespace GSBackQueue
+39 -27
View File
@@ -108,11 +108,7 @@ GSState::GSState()
{
Console.WriteLn("GS: back-thread mode %d (record path active).", static_cast<int>(GSConfig.BackThreadMode));
// Adopt m_tr's staging buffer as payload node 0 — from here on
// m_tr.buff always aliases the current node's buffer.
GSBackQueue::PayloadNode* node = new GSBackQueue::PayloadNode{m_tr.buff};
m_payload_arena.push_back(node);
m_tr_payload_node = node;
AdoptTransferBuffer();
if (GSConfig.BackThreadMode >= GSBackThreadMode::Lockstep)
{
@@ -176,8 +172,10 @@ GSState::~GSState()
_aligned_free(m_draw_index.buff);
// GV7-1c: every mode drains before teardown, so all pool nodes hold their
// own arrays here (records in flight would alias them otherwise).
for (GSBackQueue::DrawNode* node : m_draw_node_arena)
// own arrays here (records in flight would alias them otherwise). Only this
// object's own channel storage is freed — a front object pointing at the
// back's channel has empty arenas of its own.
for (GSBackQueue::DrawNode* node : m_chan_storage.draw_arena)
{
if (node->vb.buff)
_aligned_free(node->vb.buff);
@@ -192,7 +190,7 @@ GSState::~GSState()
// ~GSTransferBuffer must not free it a second time.
if (m_back_records)
m_tr.buff = nullptr;
for (GSBackQueue::PayloadNode* node : m_payload_arena)
for (GSBackQueue::PayloadNode* node : m_chan_storage.payload_arena)
{
_aligned_free(node->buff);
delete node;
@@ -462,14 +460,14 @@ GSBackQueue::DrawNode* GSState::AcquireDrawNode()
// the back thread.
for (;;)
{
if (GSBackQueue::DrawNode** slot = m_draw_node_free.Peek())
if (GSBackQueue::DrawNode** slot = m_chan->draw_free.Peek())
{
GSBackQueue::DrawNode* node = *slot;
m_draw_node_free.Pop();
m_chan->draw_free.Pop();
return node;
}
if (m_draw_node_arena.size() < MAX_DRAW_NODES)
if (m_chan->draw_arena.size() < GSBackQueue::Channel::kMaxDrawNodes)
break;
std::this_thread::yield();
@@ -486,31 +484,41 @@ GSBackQueue::DrawNode* GSState::AcquireDrawNode()
if (!node->vb.buff || !node->vb.buff_copy || !node->ib.buff)
pxFailRel("GS: draw-node pool allocation failed");
node->vb.maxcount = m_vertex->maxcount;
m_draw_node_arena.push_back(node);
m_chan->draw_arena.push_back(node);
return node;
}
void GSState::ReleaseDrawNode(GSBackQueue::DrawNode* node)
{
// Cannot fail: the free ring's capacity equals the arena cap.
GSBackQueue::DrawNode** slot = m_draw_node_free.BeginPush();
GSBackQueue::DrawNode** slot = m_chan->draw_free.BeginPush();
pxAssert(slot);
*slot = node;
m_draw_node_free.CommitPush();
m_chan->draw_free.CommitPush();
}
void GSState::AdoptTransferBuffer()
{
// Run by the staging object at construction: m_tr's original heap buffer
// becomes payload node 0, and from here on m_tr.buff always aliases the
// current node's buffer. The channel's storage owner frees it.
GSBackQueue::PayloadNode* node = new GSBackQueue::PayloadNode{m_tr.buff};
m_chan->payload_arena.push_back(node);
m_tr_payload_node = node;
}
GSBackQueue::PayloadNode* GSState::AcquirePayloadNode()
{
for (;;)
{
if (GSBackQueue::PayloadNode** slot = m_payload_free.Peek())
if (GSBackQueue::PayloadNode** slot = m_chan->payload_free.Peek())
{
GSBackQueue::PayloadNode* node = *slot;
m_payload_free.Pop();
m_chan->payload_free.Pop();
return node;
}
if (m_payload_arena.size() < MAX_PAYLOAD_NODES)
if (m_chan->payload_arena.size() < GSBackQueue::Channel::kMaxPayloadNodes)
break;
std::this_thread::yield();
@@ -521,7 +529,7 @@ GSBackQueue::PayloadNode* GSState::AcquirePayloadNode()
static_cast<u8*>(_aligned_malloc(alloc_size, 32))};
if (!node->buff)
pxFailRel("GS: payload pool allocation failed");
m_payload_arena.push_back(node);
m_chan->payload_arena.push_back(node);
return node;
}
@@ -548,15 +556,16 @@ void GSState::RotateTransferPayload()
void GSState::ExecReleasePayloadRecord(const GSBackQueue::ReleasePayloadRecord& rec)
{
// Cannot fail: the free ring's capacity equals the arena cap.
GSBackQueue::PayloadNode** slot = m_payload_free.BeginPush();
GSBackQueue::PayloadNode** slot = m_chan->payload_free.BeginPush();
pxAssert(slot);
*slot = rec.node;
m_payload_free.CommitPush();
m_chan->payload_free.CommitPush();
}
void GSState::StartBackThread()
{
m_back_thread_exit.store(false, std::memory_order_release);
m_chan->consumer_running = true;
m_back_thread = std::thread(&GSState::BackThreadLoop, this);
Console.WriteLn("GS: back thread started (%s).", m_back_lockstep ? "lockstep" : "pipelined");
}
@@ -566,17 +575,20 @@ void GSState::StopBackThread()
if (!m_back_thread.joinable())
return;
m_back_sema.WaitForEmpty();
m_chan->sema.WaitForEmpty();
m_back_thread_exit.store(true, std::memory_order_release);
m_back_sema.NotifyOfWork();
m_chan->sema.NotifyOfWork();
m_back_thread.join();
m_chan->consumer_running = false;
m_back_queued = false;
}
void GSState::DrainBackQueue()
{
if (m_back_queued)
m_back_sema.WaitForEmpty();
// Keyed on the channel, not this object's producer flag, so drains work
// from either side of the two-object split.
if (m_chan->consumer_running)
m_chan->sema.WaitForEmpty();
}
void GSState::BackThreadLoop()
@@ -585,15 +597,15 @@ void GSState::BackThreadLoop()
for (;;)
{
m_back_sema.WaitForWorkWithSpin();
m_chan->sema.WaitForWorkWithSpin();
if (m_back_thread_exit.load(std::memory_order_acquire))
break;
while (GSBackQueue::RecordSlot* slot = m_back_ring.Peek())
while (GSBackQueue::RecordSlot* slot = m_chan->ring.Peek())
{
ExecRecordSlot(*slot);
m_back_ring.Pop();
m_chan->ring.Pop();
}
}
}
+19 -17
View File
@@ -553,28 +553,32 @@ public:
// executor tails against live state; any other mode builds records.
bool m_back_records = false;
// GV7-1d-ii: the front<->back channel (record ring + wake semaphore + pool
// arenas/free rings, GSBackQueue.h). Single-object modes use this object's
// own storage; the two-object pipelined split points the front parser
// object's m_chan at the back object's channel. The destructor frees
// m_chan_storage's pooled arrays — only ever this object's own storage, so
// a front pointing elsewhere frees nothing it doesn't own.
GSBackQueue::Channel m_chan_storage;
GSBackQueue::Channel* m_chan = &m_chan_storage;
// GV7-1c: draw-node pool. Acquire is front-side (free ring first, then arena
// growth up to the ring capacity, then backpressure); Release is the consume
// site (inline modes: FlushPrim right after the executor returns; pipelined:
// the back thread after DrawRecordTail). Arena entries are front-owned and
// freed in the destructor — safe because every mode drains before teardown.
static constexpr u32 MAX_DRAW_NODES = 64;
std::vector<GSBackQueue::DrawNode*> m_draw_node_arena;
GSBackQueue::SpscRing<GSBackQueue::DrawNode*, MAX_DRAW_NODES> m_draw_node_free;
// the back thread after DrawRecordTail).
GSBackQueue::DrawNode* AcquireDrawNode();
void ReleaseDrawNode(GSBackQueue::DrawNode* node);
// GV7-1c: transfer payload pool (record modes only; mode 0 keeps
// GSTransferBuffer's own allocation untouched). m_tr.buff aliases the
// current node's 4MB buffer; RotateTransferPayload runs at transfer Init and
// swaps to a fresh node once records reference the current one. The ctor
// adopts m_tr's original buffer as node 0 (the dtor nulls m_tr.buff before
// the arena walk so it isn't freed twice).
static constexpr u32 MAX_PAYLOAD_NODES = 8;
std::vector<GSBackQueue::PayloadNode*> m_payload_arena;
GSBackQueue::SpscRing<GSBackQueue::PayloadNode*, MAX_PAYLOAD_NODES> m_payload_free;
// swaps to a fresh node once records reference the current one.
// AdoptTransferBuffer (run by the staging object at construction) hands
// m_tr's original buffer to the channel as node 0 (the dtor nulls m_tr.buff
// before the arena walk so it isn't freed twice).
GSBackQueue::PayloadNode* m_tr_payload_node = nullptr;
bool m_tr_payload_referenced = false;
void AdoptTransferBuffer();
GSBackQueue::PayloadNode* AcquirePayloadNode();
void RotateTransferPayload();
void ExecReleasePayloadRecord(const GSBackQueue::ReleasePayloadRecord& rec);
@@ -589,8 +593,6 @@ public:
// the MTGS thread and HW draws would issue GL calls from the wrong thread.
bool m_back_queued = false;
bool m_back_lockstep = false;
GSBackQueue::RecordRing m_back_ring;
Threading::WorkSema m_back_sema;
std::thread m_back_thread;
std::atomic<bool> m_back_thread_exit{false};
@@ -606,13 +608,13 @@ public:
{
for (;;)
{
GSBackQueue::RecordSlot* slot = m_back_ring.BeginPush();
GSBackQueue::RecordSlot* slot = m_chan->ring.BeginPush();
if (slot)
{
slot->type = type;
std::memcpy(slot->As<T>(), &rec, sizeof(T));
m_back_ring.CommitPush();
m_back_sema.NotifyOfWork();
m_chan->ring.CommitPush();
m_chan->sema.NotifyOfWork();
break;
}
std::this_thread::yield(); // ring full — backpressure
@@ -624,7 +626,7 @@ public:
// fps on MQ65 with plain WaitForEmpty) — it's the bisect rung, not a
// shipping mode.
if (m_back_lockstep)
m_back_sema.WaitForEmptyWithSpin();
m_chan->sema.WaitForEmptyWithSpin();
}
GSVector4i GetTEX0Rect(GSDrawingContext prev_ctx);