mirror of
https://github.com/ARMSX2/ARMSX3.git
synced 2026-08-24 16:58:52 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4797ad8a9a |
@@ -29,8 +29,8 @@ android {
|
||||
applicationId = "com.armsx3"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
versionCode = 11
|
||||
versionName = "0.7"
|
||||
versionCode = 12
|
||||
versionName = "0.7.1"
|
||||
|
||||
// ARMSX2's UI reads these. STORAGE_ALL_FILES gates the all-files storage path in
|
||||
// onboarding; IN_APP_UPDATER gates the in-app GitHub-release updater.
|
||||
|
||||
@@ -122,30 +122,13 @@ namespace rsx
|
||||
texture_cache_predictor_entry_history_queue<max_write_history_size> write_history;
|
||||
|
||||
static const u32 max_confidence = 8; // Cannot be more "confident" than this value
|
||||
#ifdef __ANDROID__
|
||||
// Mobile-tiler tuning. Here the GPU is usually idle while the CPU stalls on synchronous
|
||||
// colour-buffer readbacks (Write Color Buffers titles such as Demon's Souls). With no GPU
|
||||
// contention a speculative pre-flush is close to free, so engage the predictor sooner:
|
||||
// cross the threshold on the first repeat of a stable readback region, so the pre-flush
|
||||
// fires at framebuffer setup and the later CPU read finds an already-signalled fence
|
||||
// instead of spinning on a fresh GPU copy.
|
||||
static const u32 confident_threshold = 4;
|
||||
static const u32 starting_confidence = 4;
|
||||
#else
|
||||
static const u32 confident_threshold = 6; // We are confident if confidence >= confidence_threshold
|
||||
static const u32 starting_confidence = 3;
|
||||
#endif
|
||||
|
||||
static const u32 confidence_guessed_flush = 2; // Confidence granted when we correctly guess there will be a flush
|
||||
static const u32 confidence_guessed_no_flush = 1; // Confidence granted when we correctly guess there won't be a flush
|
||||
static const u32 confidence_incorrect_guess = -2; // Confidence granted when our guess is incorrect
|
||||
#ifdef __ANDROID__
|
||||
// A wrong speculative flush costs little on an idle GPU, so do not punish it into a
|
||||
// multi-frame confidence rebuild.
|
||||
static const u32 confidence_mispredict = -2;
|
||||
#else
|
||||
static const u32 confidence_mispredict = -4; // Confidence granted when a speculative flush is incorrect
|
||||
#endif
|
||||
|
||||
u32 confidence;
|
||||
|
||||
|
||||
@@ -744,15 +744,10 @@ namespace rsx
|
||||
// why it is game- and timing-dependent rather than reliable.
|
||||
fifo_ctrl->sync_get_force();
|
||||
|
||||
// Spin budget for the idle wait below. Reset whenever a fresh idle period starts, so
|
||||
// each drain gets its own short hot window before parking.
|
||||
static thread_local u32 s_fifo_idle_spins = 0;
|
||||
|
||||
if (performance_counters.state == FIFO::state::running)
|
||||
{
|
||||
performance_counters.FIFO_idle_timestamp = get_system_time();
|
||||
performance_counters.state = FIFO::state::empty;
|
||||
s_fifo_idle_spins = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -781,36 +776,8 @@ namespace rsx
|
||||
// It has now caused two wrong conclusions in one session: once reading a
|
||||
// starving RSX as CPU-bound decode, and once reading a thread stuck in an
|
||||
// occlusion query wait as the same thing.
|
||||
// UPDATE: the yield is now itself the problem, and it is measured. A native
|
||||
// profile of Arkham City gameplay put ~11% of TOTAL process CPU in sched_yield
|
||||
// reached from here -- 93% of the RSX thread's kernel time, and its single largest
|
||||
// cost. sched_yield is close to the worst available wait on this device: it is a
|
||||
// syscall, it forces a scheduler pass, and with ~14 hot threads over 8 cores it is
|
||||
// usually rescheduled immediately -- so it burns a core one of the five SPU threads
|
||||
// actually wants, while doing nothing to notice the guest sooner.
|
||||
//
|
||||
// A short hot spin first, so a PUT that lands within microseconds is still caught
|
||||
// without paying any wake latency; only sustained idle parks. WFE costs no syscall
|
||||
// and the architected event stream bounds the park to tens of microseconds, so the
|
||||
// RSX still sits on the frame's dependency chain rather than sleeping through work.
|
||||
//
|
||||
// The pre-spin is not optional: ouroboros420/rpcsx parked bare here (e31ef44ef) and
|
||||
// had to walk it back (832c23078) when the wake latency cost frametime smoothness.
|
||||
RSX_PROF_SCOPE(idle);
|
||||
|
||||
#if defined(ARCH_ARM64)
|
||||
if (s_fifo_idle_spins < 8)
|
||||
{
|
||||
s_fifo_idle_spins++;
|
||||
utils::pause();
|
||||
}
|
||||
else
|
||||
{
|
||||
utils::wait_for_event();
|
||||
}
|
||||
#else
|
||||
std::this_thread::yield();
|
||||
#endif
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
@@ -15,12 +15,7 @@
|
||||
#include "gcm_printing.h"
|
||||
#include "RSXDisAsm.h"
|
||||
|
||||
#ifdef __ANDROID__
|
||||
#include <unistd.h> // ::gettid() for the ADPF feed
|
||||
#endif
|
||||
|
||||
#include "Emu/System.h"
|
||||
#include "Emu/system_utils.hpp"
|
||||
#include "Emu/Cell/PPUThread.h"
|
||||
#include "Emu/Cell/SPUThread.h"
|
||||
#include "Emu/Cell/timers.hpp"
|
||||
@@ -2783,55 +2778,6 @@ namespace rsx
|
||||
{
|
||||
m_eng_interrupt_mask.clear(rsx::display_interrupt);
|
||||
|
||||
#ifdef __ANDROID__
|
||||
// ADPF feed: publish this frame's real CPU cost and the presenting thread's OS tid so
|
||||
// the app can drive PerformanceHintManager. Measured at a fixed point each iteration, so
|
||||
// the previous iteration's frame-limiter sleep lands in the idle delta and is excluded.
|
||||
// Advisory only: these go to atomics nothing in the core reads back.
|
||||
// Ported from ouroboros420/rpcsx (3d4ba6060).
|
||||
{
|
||||
static thread_local u64 s_last_now = 0;
|
||||
static thread_local u64 s_last_idle = 0;
|
||||
static thread_local s32 s_tid = 0;
|
||||
|
||||
if (s_tid == 0)
|
||||
{
|
||||
s_tid = static_cast<s32>(::gettid());
|
||||
}
|
||||
|
||||
// Republished every flip so a recreated RSX thread overwrites a stale tid, rather
|
||||
// than leaving the app's hint session pointed at a dead thread after a restart.
|
||||
rpcs3::utils::set_rsx_thread_tid(s_tid);
|
||||
|
||||
const u64 now_us = get_system_time();
|
||||
const u64 idle_us = performance_counters.idle_time.load();
|
||||
|
||||
if (s_last_now != 0 && now_us > s_last_now)
|
||||
{
|
||||
const u64 wall = now_us - s_last_now;
|
||||
|
||||
// The flip-to-flip deadline. Without it the hint judges a 30fps game against a
|
||||
// 60fps target and over-boosts, which is pure heat.
|
||||
rpcs3::utils::report_frame_period_ns(wall * 1000);
|
||||
|
||||
// idle_time is reset periodically by get_load(), so a delta that went backwards is
|
||||
// a reset, not a real frame. Idle can also exceed the wall window (it accrues from
|
||||
// FIFO/semaphore paths). Reporting work == wall in either case would feed a bogus
|
||||
// fully-busy sample and over-boost; skipping leaves the last good one in place.
|
||||
if (idle_us >= s_last_idle)
|
||||
{
|
||||
if (const u64 idle_delta = idle_us - s_last_idle; idle_delta < wall)
|
||||
{
|
||||
rpcs3::utils::report_frame_work_ns((wall - idle_delta) * 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s_last_now = now_us;
|
||||
s_last_idle = idle_us;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (async_flip_requested & flip_request::any)
|
||||
{
|
||||
// Deferred flip
|
||||
|
||||
@@ -445,33 +445,6 @@ namespace rsx
|
||||
u32 processed = 0;
|
||||
const bool has_unclaimed = (m_pending_writes.back().sink == 0);
|
||||
|
||||
// Batch-prefetch every GPU occlusion result this drain is about to read, in one round
|
||||
// trip. The VK backend collapses N blocking reads into a single copy + fence and primes
|
||||
// its per-query cache; other backends no-op and the loop below reads per-query as before.
|
||||
// The filter is implemented && num_draws, a superset of what the loop actually reads --
|
||||
// the dynamic have_result early-out only skips some, costing at most a wasted copy.
|
||||
{
|
||||
std::vector<occlusion_query_info*> prefetch_set;
|
||||
prefetch_set.reserve(m_pending_writes.size());
|
||||
|
||||
for (auto& writer : m_pending_writes)
|
||||
{
|
||||
if (!writer.sink)
|
||||
break;
|
||||
|
||||
auto query = writer.query;
|
||||
if (!query || !query->num_draws)
|
||||
continue;
|
||||
|
||||
if (writer.type == CELL_GCM_ZPASS_PIXEL_CNT || writer.type == CELL_GCM_ZCULL_STATS3)
|
||||
prefetch_set.push_back(query);
|
||||
}
|
||||
|
||||
if (prefetch_set.size() > 1)
|
||||
prefetch_occlusion_query_results(prefetch_set);
|
||||
}
|
||||
|
||||
|
||||
// Write all claimed reports unconditionally
|
||||
for (auto& writer : m_pending_writes)
|
||||
{
|
||||
@@ -606,33 +579,6 @@ namespace rsx
|
||||
}
|
||||
|
||||
u32 processed = 0;
|
||||
|
||||
// Batch-prefetch every GPU occlusion result this drain is about to read, in one round
|
||||
// trip. The VK backend collapses N blocking reads into a single copy + fence and primes
|
||||
// its per-query cache; other backends no-op and the loop below reads per-query as before.
|
||||
// The filter is implemented && num_draws, a superset of what the loop actually reads --
|
||||
// the dynamic have_result early-out only skips some, costing at most a wasted copy.
|
||||
{
|
||||
std::vector<occlusion_query_info*> prefetch_set;
|
||||
prefetch_set.reserve(m_pending_writes.size());
|
||||
|
||||
for (auto& writer : m_pending_writes)
|
||||
{
|
||||
if (!writer.sink)
|
||||
break;
|
||||
|
||||
auto query = writer.query;
|
||||
if (!query || !query->num_draws)
|
||||
continue;
|
||||
|
||||
if (writer.type == CELL_GCM_ZPASS_PIXEL_CNT || writer.type == CELL_GCM_ZCULL_STATS3)
|
||||
prefetch_set.push_back(query);
|
||||
}
|
||||
|
||||
if (prefetch_set.size() > 1)
|
||||
prefetch_occlusion_query_results(prefetch_set);
|
||||
}
|
||||
|
||||
for (auto& writer : m_pending_writes)
|
||||
{
|
||||
if (!writer.sink)
|
||||
|
||||
@@ -87,19 +87,8 @@ namespace rsx
|
||||
|
||||
enum constants
|
||||
{
|
||||
#ifdef __ANDROID__
|
||||
// Mobile-tiler tuning. The GPU finishes occlusion queries in microseconds but is
|
||||
// otherwise idle (we are sync-bound, not GPU-bound), so the desktop cadence leaves
|
||||
// completed results unharvested for up to 300us and the guest's Reports-area read
|
||||
// then force-drains them one at a time -- the per-frame ZCULL hitch under Accurate
|
||||
// ZCULL stats. Harvest 4x more often so ready results are picked up without blocking
|
||||
// before the guest asks for them.
|
||||
max_zcull_delay_us = 100, // Delay before a report update operation is forced to retire
|
||||
min_zcull_tick_us = 25, // Default tick duration. To avoid hardware spam, we schedule peeks in multiples of this.
|
||||
#else
|
||||
max_zcull_delay_us = 300, // Delay before a report update operation is forced to retire
|
||||
min_zcull_tick_us = 100, // Default tick duration. To avoid hardware spam, we schedule peeks in multiples of this.
|
||||
#endif
|
||||
occlusion_query_count = 2048, // Number of occlusion query slots available. Real hardware actually has far fewer units before choking
|
||||
max_safe_queue_depth = 1792, // Number of in-flight queries before we start forcefully flushing data from the GPU device.
|
||||
max_stat_registers = 8192 // Size of the statistics cache
|
||||
@@ -214,11 +203,6 @@ namespace rsx
|
||||
virtual void end_occlusion_query(occlusion_query_info* /*query*/) {}
|
||||
virtual bool check_occlusion_query_status(occlusion_query_info* /*query*/) { return true; }
|
||||
virtual void get_occlusion_query_result(occlusion_query_info* query) { query->result = -1; }
|
||||
|
||||
// Optional batch hint: the backend may fetch every result this drain is about to read in
|
||||
// one round trip and prime its per-query cache, so the reads below are then free. Purely
|
||||
// an optimization -- the default does nothing and the per-query path still works.
|
||||
virtual void prefetch_occlusion_query_results(const std::vector<occlusion_query_info*>&) {}
|
||||
virtual void discard_occlusion_query(occlusion_query_info* /*query*/) {}
|
||||
};
|
||||
|
||||
|
||||
+10
-137
@@ -46,32 +46,6 @@ namespace vk
|
||||
}
|
||||
}
|
||||
|
||||
// The render target this sampled image is, if it is one and if the sample park applies to it.
|
||||
// Null on every other target, on every non-RTT, and whenever the tunable is off, so all the
|
||||
// gating lives in one place and the switch below reads as a plain "park or do what we did".
|
||||
static vk::render_target* sample_park_candidate(vk::image* raw, const rsx::sampled_image_descriptor_base* sampler_state)
|
||||
{
|
||||
#ifdef __ANDROID__
|
||||
if constexpr (vk::s_sample_park_frames == 0)
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (sampler_state->upload_context != rsx::texture_upload_context::framebuffer_storage)
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// dynamic_cast rather than vk::as_rtt: as_rtt asserts, and a framebuffer_storage
|
||||
// descriptor can still hand over a temporary subresource that is a plain viewable_image.
|
||||
return dynamic_cast<vk::render_target*>(raw);
|
||||
#else
|
||||
static_cast<void>(raw);
|
||||
static_cast<void>(sampler_state);
|
||||
return nullptr;
|
||||
#endif
|
||||
}
|
||||
|
||||
void validate_image_layout_for_read_access(
|
||||
vk::command_buffer& cmd,
|
||||
vk::image_view* view,
|
||||
@@ -100,33 +74,6 @@ namespace vk
|
||||
break;
|
||||
}
|
||||
|
||||
// A sample-parked surface arrives here on every sample after the first, because it
|
||||
// was left in GENERAL instead of being moved to SHADER_READ_ONLY_OPTIMAL.
|
||||
//
|
||||
// It still needs a write -> read barrier whenever it has been written since the last
|
||||
// one, and that barrier still has to end the open pass: an unbound RTT is not an
|
||||
// attachment of the pass in flight, so VUID-vkCmdPipelineBarrier-image-04073 rules
|
||||
// out keeping it. What it must NOT do is move the layout, or the surface un-parks and
|
||||
// pays the return trip after all.
|
||||
//
|
||||
// The "has been written since" test is the load-bearing part. Without parking the
|
||||
// layout itself answered it - already in SHADER_READ_ONLY_OPTIMAL meant already
|
||||
// synchronized, so the second through five-hundredth draw sampling a shadow map fell
|
||||
// through the default case and issued nothing. Leaving the surface in GENERAL erases
|
||||
// that signal, and issuing a barrier per sampling draw instead of per write would be
|
||||
// a catastrophic regression rather than a win. render_target carries the answer
|
||||
// explicitly now; see sample_park_needs_read_barrier.
|
||||
if (auto parked = sample_park_candidate(raw, sampler_state);
|
||||
parked && raw->current_layout == vk::render_target::get_sample_park_layout())
|
||||
{
|
||||
if (!parked->sample_park_needs_read_barrier())
|
||||
{
|
||||
// Already synchronized for reading, and already in a layout that is legal to
|
||||
// sample from. Nothing at all to record.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// This was used in a cyclic ref before, but is missing a barrier
|
||||
// No need for a full stall, use a custom barrier instead
|
||||
VkPipelineStageFlags src_stage;
|
||||
@@ -146,74 +93,22 @@ namespace vk
|
||||
dst_stage |= VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT | VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT;
|
||||
}
|
||||
|
||||
{
|
||||
// Stay in the park layout if this surface is parked, otherwise the historical
|
||||
// move to SHADER_READ_ONLY_OPTIMAL. Note this keeps oldLayout == newLayout in
|
||||
// the parked case, which is the only form a barrier is allowed to take inside a
|
||||
// render pass - not that it can stay inside one here, but it means the barrier
|
||||
// carries no transition cost of its own either.
|
||||
auto parked = sample_park_candidate(raw, sampler_state);
|
||||
const bool stay_parked = parked && parked->try_arm_sample_park() &&
|
||||
raw->current_layout == vk::render_target::get_sample_park_layout();
|
||||
vk::insert_image_memory_barrier(
|
||||
cmd,
|
||||
raw->value,
|
||||
raw->current_layout, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
|
||||
src_stage, dst_stage,
|
||||
src_access, dst_access,
|
||||
{ raw->aspect(), 0, 1, 0, 1 });
|
||||
|
||||
const VkImageLayout target_layout = stay_parked
|
||||
? vk::render_target::get_sample_park_layout()
|
||||
: VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
|
||||
|
||||
vk::insert_image_memory_barrier(
|
||||
cmd,
|
||||
raw->value,
|
||||
raw->current_layout, target_layout,
|
||||
src_stage, dst_stage,
|
||||
src_access, dst_access,
|
||||
{ raw->aspect(), 0, 1, 0, 1 });
|
||||
|
||||
raw->current_layout = target_layout;
|
||||
|
||||
if (stay_parked)
|
||||
{
|
||||
parked->on_sample_park_synced();
|
||||
}
|
||||
else if (parked)
|
||||
{
|
||||
parked->clear_sample_park();
|
||||
}
|
||||
}
|
||||
raw->current_layout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
|
||||
break;
|
||||
case VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL:
|
||||
case VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL:
|
||||
ensure(sampler_state->upload_context == rsx::texture_upload_context::framebuffer_storage);
|
||||
if (!sampler_state->is_cyclic_reference) [[ likely ]]
|
||||
{
|
||||
// Standard pre-read barrier, and the first half of the sample/rebind cycle.
|
||||
//
|
||||
// This transition is the floor. The surface was just rendered to, so the read
|
||||
// needs a write -> read barrier, and a barrier cannot stay inside a pass for an
|
||||
// image the pass does not have attached. What the park changes is the
|
||||
// destination: GENERAL is legal to sample from and legal to attach, so the next
|
||||
// bind has nothing to undo, whereas SHADER_READ_ONLY_OPTIMAL is not a legal
|
||||
// attachment layout and forces a second teardown to leave it.
|
||||
//
|
||||
// The transition itself is issued through the same change_layout as before, so
|
||||
// the access/stage scopes image_helpers derives for it are unchanged apart from
|
||||
// the destination, and it is still charged to ImgHelper:43.
|
||||
if (auto parked = sample_park_candidate(raw, sampler_state))
|
||||
{
|
||||
if (parked->try_arm_sample_park())
|
||||
{
|
||||
raw->change_layout(cmd, vk::render_target::get_sample_park_layout());
|
||||
parked->on_sample_park_synced();
|
||||
break;
|
||||
}
|
||||
|
||||
// Declined - bound, multisampled, or no renderer. Drop any window left over
|
||||
// from an earlier park so the deadline can never outlive the layout it
|
||||
// describes. Both readers already re-check current_layout, so this is
|
||||
// tidiness rather than a fix, but it keeps the state machine to one rule:
|
||||
// a live deadline always means the surface is in the park layout.
|
||||
parked->clear_sample_park();
|
||||
}
|
||||
|
||||
// Standard pre-read barrier.
|
||||
raw->change_layout(cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
|
||||
break;
|
||||
}
|
||||
@@ -235,11 +130,6 @@ void VKGSRender::begin_render_pass()
|
||||
get_render_pass(),
|
||||
m_draw_fbo->value,
|
||||
{ positionu{0u, 0u}, sizeu{m_draw_fbo->width(), m_draw_fbo->height()} });
|
||||
|
||||
// Publish what this pass actually has attached, so a barrier that wants to stay inside it can
|
||||
// check VUID-vkCmdPipelineBarrier-image-04073 rather than trust the caller. m_fbo_images is
|
||||
// the exact list the framebuffer was built from a few lines up the call chain in prepare_rtts.
|
||||
vk::set_renderpass_attachments(*m_current_command_buffer, m_fbo_images);
|
||||
}
|
||||
|
||||
void VKGSRender::close_render_pass()
|
||||
@@ -1208,24 +1098,7 @@ void VKGSRender::emit_geometry(u32 sub_index)
|
||||
if (pass)
|
||||
{
|
||||
// Subpass mismatch, end it before proceeding
|
||||
if (rsx::prof::enabled()) [[unlikely]]
|
||||
{
|
||||
rsx::prof::g_rp_sites[1]++;
|
||||
|
||||
// Which of the two mismatches fired. The framebuffer changing is the game
|
||||
// switching render target and nothing here can avoid it; the render pass handle
|
||||
// changing under an unchanged framebuffer can only be the attachment layouts,
|
||||
// since format and sample count cannot move without the framebuffer moving too.
|
||||
//
|
||||
// This site only became loud once parking stopped the bind-time layout change
|
||||
// from ending the pass earlier in the draw, which left the previous draw's pass
|
||||
// open to be torn down here instead. Whether that is the same teardown relocated
|
||||
// or an extra one caused by layout churn is exactly what these two counters
|
||||
// separate, and it decides whether parking longer is worth anything.
|
||||
rsx::prof::g_rp_sites[(m_draw_fbo->value != fbo) ? 19 : 18]++;
|
||||
}
|
||||
|
||||
vk::end_renderpass(cmd);
|
||||
if (rsx::prof::enabled()) [[unlikely]] rsx::prof::g_rp_sites[1]++; vk::end_renderpass(cmd);
|
||||
}
|
||||
|
||||
// Starting a new renderpass should clobber dynamic state
|
||||
|
||||
@@ -483,13 +483,6 @@ VKGSRender::VKGSRender(utils::serial* ar) noexcept : GSRender(ar)
|
||||
if (!m_swapchain->init(m_swapchain_dims.width, m_swapchain_dims.height))
|
||||
{
|
||||
swapchain_unavailable = true;
|
||||
#ifdef ANDROID
|
||||
// The VkSurfaceKHR is bound to the ANativeWindow captured at create time, so a surface
|
||||
// lost during boot-time init stays lost however often we re-query it. Flag it so the first
|
||||
// reinitialize_swapchain() takes the recreate branch with the new window instead of
|
||||
// soft-looping forever against a dead surface.
|
||||
m_surface_lost = true;
|
||||
#endif
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -522,37 +515,9 @@ VKGSRender::VKGSRender(utils::serial* ar) noexcept : GSRender(ar)
|
||||
m_occlusion_query_manager->set_control_flags(VK_QUERY_CONTROL_PRECISE_BIT, 0);
|
||||
}
|
||||
|
||||
// The vertex cache can only retain an entry while the ring memory naming it survives, so
|
||||
// retention engages only once the ring holds the in-flight headroom plus another frame of
|
||||
// geometry -- four frames at the current headroom. Arkham City peaks near 31MB of geometry a
|
||||
// frame, needing ~124MB, so the 64MB default leaves retention permanently disengaged in
|
||||
// exactly the heavy scenes that stand to gain from it. The heap is growable, but it grows on
|
||||
// allocation pressure and wrapping relieves that pressure, so it never reaches a size that
|
||||
// would let entries live: the initial size is the only lever. Scale it against the memory
|
||||
// budget rather than taking a fixed 192MB, because that is a fifth of the floor budget we
|
||||
// hand a 4GB phone.
|
||||
u32 attrib_ring_size_m = VK_ATTRIB_RING_BUFFER_SIZE_M;
|
||||
|
||||
#ifdef __ANDROID__
|
||||
{
|
||||
// Flat, deliberately. This was first scaled off get_budgetable_device_memory, which was
|
||||
// wrong twice over: that figure is a texture-cache quota the ring does not draw from, and
|
||||
// it is derived from free memory *after* the emulator has taken its RAM, so it floors at
|
||||
// 1024M on an 8GB device and every tier collapsed back to 64M.
|
||||
//
|
||||
// 192M covers a peak frame up to 48M against the 4x rule; measured peaks here run 11-32M.
|
||||
// 128M would only just clear the observed 31.8M peak and would flap on anything heavier.
|
||||
attrib_ring_size_m = 192u;
|
||||
|
||||
// Named because the retention log reports the ring size it had to work with, and a reader
|
||||
// otherwise cannot tell a ring that was sized down from one that was never sized up.
|
||||
rsx_log.notice("Attribute ring sized to %uM.", attrib_ring_size_m);
|
||||
}
|
||||
#endif
|
||||
|
||||
// VRAM allocation
|
||||
// This first set is bound persistently, so grow notifications are enabled.
|
||||
m_attrib_ring_info.create(VK_BUFFER_USAGE_UNIFORM_TEXEL_BUFFER_BIT, attrib_ring_size_m * 0x100000, vk::heap_pool_default, "attrib buffer", 0x400000, VK_TRUE);
|
||||
m_attrib_ring_info.create(VK_BUFFER_USAGE_UNIFORM_TEXEL_BUFFER_BIT, VK_ATTRIB_RING_BUFFER_SIZE_M * 0x100000, vk::heap_pool_default, "attrib buffer", 0x400000, VK_TRUE);
|
||||
m_fragment_env_ring_info.create(VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, VK_UBO_RING_BUFFER_SIZE_M * 0x100000, vk::heap_pool_low_latency, "fragment env buffer", 0x10000, VK_TRUE);
|
||||
m_vertex_env_ring_info.create(VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, VK_UBO_RING_BUFFER_SIZE_M * 0x100000, vk::heap_pool_default, "vertex env buffer", 0x10000, VK_TRUE);
|
||||
m_fragment_texture_params_ring_info.create(VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, VK_UBO_RING_BUFFER_SIZE_M * 0x100000, vk::heap_pool_low_latency, "fragment texture params buffer", 0x10000, VK_TRUE);
|
||||
@@ -606,9 +571,7 @@ VKGSRender::VKGSRender(utils::serial* ar) noexcept : GSRender(ar)
|
||||
m_fragment_constants_buffer_info = { *m_fragment_constants_ring_info.heap, 0, VK_WHOLE_SIZE };
|
||||
|
||||
const auto& limits = m_device->gpu().get_limits();
|
||||
// Clamped to the device limit, not the ring size: views window across the ring (see
|
||||
// upload_vertex_data), so a ring larger than one view is fine and is the normal case now.
|
||||
m_texbuffer_view_size = std::min(limits.maxTexelBufferElements, attrib_ring_size_m * 0x100000u);
|
||||
m_texbuffer_view_size = std::min(limits.maxTexelBufferElements, VK_ATTRIB_RING_BUFFER_SIZE_M * 0x100000u);
|
||||
|
||||
// Initialize bulk allocators
|
||||
m_vertex_env_allocator = std::make_unique<rsx::data_heap::bulk_allocator<256, 96>>(
|
||||
@@ -2926,85 +2889,6 @@ bool VKGSRender::check_occlusion_query_status(rsx::reports::occlusion_query_info
|
||||
return m_occlusion_query_manager->check_query_status(oldest);
|
||||
}
|
||||
|
||||
// Collapse the drain's N blocking per-query reads into one GPU copy plus a single fence wait,
|
||||
// then prime the per-query cache so the reads that follow cost nothing. On a tiler each
|
||||
// individual read is a full round trip, so N of them back to back is most of the ZCULL cost.
|
||||
// Best-effort: on any bail the caller's per-query path still runs unchanged.
|
||||
// Ported from ouroboros420/rpcsx (7ed3365bc).
|
||||
void VKGSRender::prefetch_occlusion_query_results(const std::vector<rsx::reports::occlusion_query_info*>& queries)
|
||||
{
|
||||
if (queries.size() < 2)
|
||||
{
|
||||
// Not worth a batch round trip; the per-query loop handles it with no extra hard sync.
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<u32> indices;
|
||||
indices.reserve(queries.size() * 4);
|
||||
|
||||
bool needs_hard_sync = false;
|
||||
|
||||
for (auto* query : queries)
|
||||
{
|
||||
if (!query) continue;
|
||||
|
||||
auto& data = m_occlusion_map[query->driver_handle];
|
||||
if (data.indices.empty()) continue;
|
||||
|
||||
// A query begun in the current command buffer has not been ENDED yet, so copying it
|
||||
// would need a hard sync -- exactly what the per-query path deliberately avoids. Skip
|
||||
// the whole batch rather than force one.
|
||||
if (data.is_current(m_current_command_buffer))
|
||||
{
|
||||
needs_hard_sync = true;
|
||||
break;
|
||||
}
|
||||
|
||||
for (const auto id : data.indices)
|
||||
{
|
||||
indices.push_back(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (needs_hard_sync || indices.size() < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Sorted so the pool-aware coalescing in copy_query_results actually finds runs.
|
||||
std::sort(indices.begin(), indices.end());
|
||||
indices.erase(std::unique(indices.begin(), indices.end()), indices.end());
|
||||
|
||||
const u64 required = indices.size() * 4ull;
|
||||
|
||||
if (!m_occlusion_readback_buffer || m_occlusion_readback_buffer->size() < required)
|
||||
{
|
||||
const u64 alloc_size = std::max<u64>(required, 4096);
|
||||
m_occlusion_readback_buffer = std::make_unique<vk::buffer>(*m_device,
|
||||
alloc_size,
|
||||
m_device->get_memory_mapping().host_visible_coherent, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT,
|
||||
VK_BUFFER_USAGE_TRANSFER_DST_BIT, 0,
|
||||
VMM_ALLOCATION_POOL_SYSTEM);
|
||||
}
|
||||
|
||||
m_occlusion_query_manager->copy_query_results(*m_current_command_buffer, indices, m_occlusion_readback_buffer->value);
|
||||
|
||||
// One fence wait drains the whole batch. flush_command_queue(true) submits and waits, which
|
||||
// is the single round trip this exists to pay instead of N.
|
||||
if (rsx::prof::enabled()) [[unlikely]] rsx::prof::g_flush_sites[11]++;
|
||||
flush_command_queue(true);
|
||||
|
||||
if (auto* mapped = static_cast<u32*>(m_occlusion_readback_buffer->map(0, required)))
|
||||
{
|
||||
for (usz i = 0; i < indices.size(); ++i)
|
||||
{
|
||||
m_occlusion_query_manager->prime_query_result(indices[i], mapped[i]);
|
||||
}
|
||||
|
||||
m_occlusion_readback_buffer->unmap();
|
||||
}
|
||||
}
|
||||
|
||||
void VKGSRender::get_occlusion_query_result(rsx::reports::occlusion_query_info* query)
|
||||
{
|
||||
auto &data = m_occlusion_map[query->driver_handle];
|
||||
@@ -3029,82 +2913,9 @@ void VKGSRender::get_occlusion_query_result(rsx::reports::occlusion_query_info*
|
||||
|
||||
data.sync();
|
||||
|
||||
// On a tile-based renderer the result is usually a whole tiling pass away, so this wait
|
||||
// is long -- and it must stay interruptible. get_query_result() blocks without ever
|
||||
// checking external_interrupt_lock, and a PPU thread that faults on RSX-guarded memory
|
||||
// during that window spins in on_access_violation() waiting for an ack this thread can no
|
||||
// longer give: PPU pinned in sched_yield, RSX parked in the query wait, presenting as a
|
||||
// freeze. Wait here instead, servicing external interrupts the way the FIFO
|
||||
// semaphore_acquire loop does, and only call get_query_result() once the value is ready.
|
||||
// Ported from rfandango/rpcsx (a560768ce).
|
||||
static const bool needs_interruptible_wait = vk::is_tile_based_renderer(vk::get_driver_vendor());
|
||||
|
||||
bool aborted = false;
|
||||
|
||||
// Gather data
|
||||
for (const auto occlusion_id : data.indices)
|
||||
{
|
||||
if (needs_interruptible_wait)
|
||||
{
|
||||
u32 wait_iterations = 0;
|
||||
bool rescued = false;
|
||||
|
||||
while (!m_occlusion_query_manager->check_query_status(occlusion_id))
|
||||
{
|
||||
// Rescue path. A query begun in the current command buffer is not even ENDED
|
||||
// until close_and_submit_command_buffer(), so if the is_current() bookkeeping
|
||||
// above mis-reported, the value can never arrive without a flush. Rather than
|
||||
// pay a hard sync on every ZCULL read, give it a generous window then force the
|
||||
// flush once. If the query still never readies after this fires, the GPU is not
|
||||
// retiring work at all -- a driver hang, not a bookkeeping bug -- and this
|
||||
// warning is the breadcrumb that tells the two apart.
|
||||
if (!rescued && ++wait_iterations >= 4096)
|
||||
{
|
||||
rescued = true;
|
||||
rsx_log.warning("ZCULL result did not arrive; forcing command flush (query=%d)", occlusion_id);
|
||||
|
||||
std::lock_guard lock(m_flush_queue_mutex);
|
||||
if (rsx::prof::enabled()) [[unlikely]] rsx::prof::g_flush_sites[11]++; flush_command_queue();
|
||||
|
||||
if (m_flush_requests.pending())
|
||||
{
|
||||
m_flush_requests.clear_pending_flag();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not optional: a PPU thread that faults on RSX-guarded memory posts a flush
|
||||
// request in on_access_violation() and spins in producer_wait() until this
|
||||
// thread consumes it. Servicing only external_interrupt_lock is not enough --
|
||||
// that was a measured deadlock. The FIFO semaphore_acquire wait survives the
|
||||
// same situation precisely because cpu_wait() makes this call.
|
||||
on_semaphore_acquire_wait();
|
||||
|
||||
if (external_interrupt_lock)
|
||||
{
|
||||
wait_pause();
|
||||
}
|
||||
else if (state & cpu_flag::exit)
|
||||
{
|
||||
// The result may never arrive during shutdown, so do not read it.
|
||||
aborted = true;
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Park at near-zero power; the architected event stream bounds the poll
|
||||
// period to tens of microseconds.
|
||||
utils::wait_for_event();
|
||||
}
|
||||
}
|
||||
|
||||
if (aborted)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
query->result += m_occlusion_query_manager->get_query_result(occlusion_id);
|
||||
if (query->result && !g_cfg.video.precise_zpass_count)
|
||||
{
|
||||
|
||||
@@ -70,8 +70,6 @@ private:
|
||||
output_scaling_mode m_output_scaling{output_scaling_mode::bilinear};
|
||||
|
||||
std::unique_ptr<vk::buffer> m_cond_render_buffer;
|
||||
// Host-visible scratch for the batched ZCULL readback. Allocated on first use.
|
||||
std::unique_ptr<vk::buffer> m_occlusion_readback_buffer;
|
||||
u64 m_cond_render_sync_tag = 0;
|
||||
|
||||
shared_mutex m_sampler_mutex;
|
||||
@@ -104,8 +102,6 @@ private:
|
||||
bool m_surface_lost = false;
|
||||
vk::instance m_instance;
|
||||
vk::render_device *m_device;
|
||||
// Timestamp of the last periodic pipeline-cache serialize (see flip()).
|
||||
u64 m_last_pipeline_cache_save_time = 0;
|
||||
|
||||
//Vulkan internals
|
||||
std::unique_ptr<vk::query_pool_manager> m_occlusion_query_manager;
|
||||
@@ -148,23 +144,6 @@ private:
|
||||
|
||||
rsx::simple_array<vk::data_heap*> m_flushable_data_heaps; // List of heaps that can be 'dirty' and need manual flush
|
||||
|
||||
#ifdef __ANDROID__
|
||||
// Cross-frame vertex cache retention. m_attrib_ring_info is a ring, so a cached
|
||||
// offset_in_heap stays meaningful only until the ring laps back over it; the cache used to be
|
||||
// purged every frame because nothing tracked that. See vertex_cache_on_frame_end().
|
||||
u64 m_vtx_heap_cursor = 0; // Monotonic byte count ever handed out by m_attrib_ring_info
|
||||
usz m_vtx_heap_put = 0; // Ring PUT at the last cursor sample
|
||||
u64 m_vtx_retain_floor = 0; // Entries stamped below this cursor value are evicted
|
||||
u64 m_vtx_frame_base = 0; // Cursor as of the start of the current frame
|
||||
u64 m_vtx_frame_peak = 0; // Largest single-frame consumption in the recent window
|
||||
u32 m_vtx_peak_ttl = 0; // Frames left before the peak is allowed to decay
|
||||
u64 m_vtx_reserve_bytes = 0; // Ring bytes withheld from the allocator (0 = retention off)
|
||||
u64 m_vtx_frame_marks[8]{}; // Cursor at the end of each of the last 8 frames
|
||||
u32 m_vtx_frame_index = 0;
|
||||
bool m_vtx_retention_locked_out = false;
|
||||
VkBuffer m_vtx_heap_handle = VK_NULL_HANDLE; // Detects a grow(), which discards the whole heap
|
||||
#endif
|
||||
|
||||
VkDescriptorBufferInfoEx m_vertex_env_buffer_info {};
|
||||
VkDescriptorBufferInfoEx m_fragment_env_buffer_info {};
|
||||
VkDescriptorBufferInfoEx m_vertex_layout_stream_info {};
|
||||
@@ -268,12 +247,6 @@ private:
|
||||
vk::vertex_upload_info upload_vertex_data();
|
||||
rsx::simple_array<u8> m_scratch_mem;
|
||||
|
||||
#ifdef __ANDROID__
|
||||
void vertex_cache_sample_heap(); // Advance the ring cursor, and detect a heap reallocation
|
||||
void vertex_cache_on_heap_reset(); // Drop everything; the heap the offsets named is gone
|
||||
void vertex_cache_on_frame_end(); // Size the reservation and evict what it cannot cover
|
||||
#endif
|
||||
|
||||
bool load_program();
|
||||
void load_program_env();
|
||||
void update_vertex_env(u32 id, const vk::vertex_upload_info& vertex_info);
|
||||
@@ -298,7 +271,6 @@ public:
|
||||
void end_occlusion_query(rsx::reports::occlusion_query_info* query) override;
|
||||
bool check_occlusion_query_status(rsx::reports::occlusion_query_info* query) override;
|
||||
void get_occlusion_query_result(rsx::reports::occlusion_query_info* query) override;
|
||||
void prefetch_occlusion_query_results(const std::vector<rsx::reports::occlusion_query_info*>& queries) override;
|
||||
void discard_occlusion_query(rsx::reports::occlusion_query_info* query) override;
|
||||
|
||||
// External callback in case we need to suddenly submit a commandlist unexpectedly, e.g in a violation handler
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#include "stdafx.h"
|
||||
#include "VKGSRender.h"
|
||||
#include "Emu/Cell/timers.hpp"
|
||||
#include "vkutils/buffer_object.h"
|
||||
#include "vkutils/memory.h"
|
||||
#include "Emu/RSX/Overlays/overlay_manager.h"
|
||||
@@ -244,35 +243,9 @@ void VKGSRender::advance_queued_frames()
|
||||
|
||||
vk::remove_unused_framebuffers();
|
||||
|
||||
#ifdef __ANDROID__
|
||||
// Was an unconditional m_vertex_cache->purge(). Arkham City lands 10 cache hits in 1386
|
||||
// requests because of it: every entry is thrown away at the frame boundary, so the 10 are
|
||||
// within-frame duplicates and the other 1376 draws re-upload geometry that never changed,
|
||||
// 2499us of it a frame, and hand a tile-based GPU 1.35M vertices in one pass to re-bin.
|
||||
//
|
||||
// Entries can only survive if the ring memory they name survives with them, so this sizes a
|
||||
// reservation and evicts anything it cannot cover. See vertex_cache_on_frame_end().
|
||||
vertex_cache_on_frame_end();
|
||||
#else
|
||||
m_vertex_cache->purge();
|
||||
#endif
|
||||
|
||||
m_current_frame->tag_frame_end();
|
||||
|
||||
#ifdef __ANDROID__
|
||||
// tag_frame_end() records PUT-1 for every managed heap, which is what frame_context_cleanup
|
||||
// later publishes as that heap's GET once this frame retires - i.e. "the GPU is done with
|
||||
// everything before here, reuse it". For the attribute ring that is exactly the statement that
|
||||
// makes a retained entry unsafe, so publish the retention floor instead. It is always at or
|
||||
// behind the value this would otherwise carry, so the allocator ends up strictly more
|
||||
// conservative than before and in-flight frames stay protected as they already were.
|
||||
if (m_vtx_reserve_bytes && m_attrib_ring_info.size())
|
||||
{
|
||||
m_current_frame->heap_snapshot[&m_attrib_ring_info] =
|
||||
static_cast<s64>(m_vtx_retain_floor % m_attrib_ring_info.size());
|
||||
}
|
||||
#endif
|
||||
|
||||
// Throttle here rather than by accident.
|
||||
//
|
||||
// The queue used to stay at one entry because the poll above blocked until the oldest
|
||||
@@ -1247,19 +1220,4 @@ void VKGSRender::flip(const rsx::display_flip_info_t& info)
|
||||
if (rsx::prof::enabled()) [[unlikely]] rsx::prof::g_flush_sites[20]++; flush_command_queue(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Crash-resilient pipeline-cache persistence. The blob is otherwise only written on
|
||||
// clean teardown, so a crash or an Android LMK kill mid-session loses the entire
|
||||
// warmup -- and a cold-boot compile-burst crash then keeps every later boot cold too.
|
||||
// save_pipeline_cache() skips the write when the cache size has not moved, so steady
|
||||
// state costs one size query per interval.
|
||||
if (const u64 now = get_system_time(); now >= m_last_pipeline_cache_save_time + 120'000'000)
|
||||
{
|
||||
m_last_pipeline_cache_save_time = now;
|
||||
|
||||
if (m_device)
|
||||
{
|
||||
m_device->save_pipeline_cache();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,17 +269,13 @@ namespace vk
|
||||
{
|
||||
VkGraphicsPipelineCreateInfo create_info = *p_graphics_info;
|
||||
create_info.layout = m_pipeline_layout;
|
||||
// Shared driver cache, so the SPIR-V -> ISA compile is not redone every cold boot.
|
||||
// Null when unavailable, which is exactly what was passed here before.
|
||||
const VkPipelineCache pipe_cache = g_render_device ? g_render_device->get_pipeline_cache() : VK_NULL_HANDLE;
|
||||
CHECK_RESULT(vkCreateGraphicsPipelines(m_device, pipe_cache, 1, &create_info, nullptr, &m_pipeline));
|
||||
CHECK_RESULT(vkCreateGraphicsPipelines(m_device, nullptr, 1, &create_info, nullptr, &m_pipeline));
|
||||
}
|
||||
else
|
||||
{
|
||||
VkComputePipelineCreateInfo create_info = *p_compute_info;
|
||||
create_info.layout = m_pipeline_layout;
|
||||
const VkPipelineCache pipe_cache = g_render_device ? g_render_device->get_pipeline_cache() : VK_NULL_HANDLE;
|
||||
CHECK_RESULT(vkCreateComputePipelines(m_device, pipe_cache, 1, &create_info, nullptr, &m_pipeline));
|
||||
CHECK_RESULT(vkCreateComputePipelines(m_device, nullptr, 1, &create_info, nullptr, &m_pipeline));
|
||||
}
|
||||
|
||||
m_linked = true;
|
||||
|
||||
@@ -61,7 +61,6 @@ namespace vk
|
||||
owner = &dev;
|
||||
query_type = type;
|
||||
query_slot_status.resize(num_entries, {});
|
||||
tile_based_renderer = vk::is_tile_based_renderer(vk::get_driver_vendor());
|
||||
|
||||
for (unsigned i = 0; i < num_entries; ++i)
|
||||
{
|
||||
@@ -143,16 +142,6 @@ namespace vk
|
||||
{
|
||||
control_flags = control_;
|
||||
result_flags = result_;
|
||||
|
||||
if (tile_based_renderer)
|
||||
{
|
||||
// PARTIAL_BIT exists so the poll loop can finish early the moment any sample is known
|
||||
// to have passed. That can only happen on an immediate-mode renderer, where fragments
|
||||
// rasterize as they arrive. A TBDR has nothing to report until the tiling pass ends, so
|
||||
// the flag can never pay off here and only asks the driver for extra work.
|
||||
// Ported from rfandango/rpcsx (a560768ce).
|
||||
result_flags &= ~VK_QUERY_RESULT_PARTIAL_BIT;
|
||||
}
|
||||
}
|
||||
|
||||
void query_pool_manager::begin_query(vk::command_buffer& cmd, u32 index)
|
||||
@@ -244,28 +233,29 @@ namespace vk
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef __ANDROID__
|
||||
// Spin briefly, then hand the core back.
|
||||
//
|
||||
// A pure pause() spin is reasonable on a desktop, where the result lands in
|
||||
// microseconds and there are cores to spare. On a tiled renderer the result is
|
||||
// not available until the tile pass resolves, so the wait is far longer, and a
|
||||
// handheld runs eleven hot emulator threads across five usable cores. Burning
|
||||
// one of them on a spin costs an SPU thread that had real work to do.
|
||||
// microseconds and there are cores to spare. On a tiled mobile GPU the
|
||||
// result is not available until the tile pass resolves, so the wait is far
|
||||
// longer, and this device runs eleven hot emulator threads across five
|
||||
// usable cores. Burning one of them on a spin costs an SPU thread that had
|
||||
// real work to do.
|
||||
//
|
||||
// The short spin first keeps the fast case fast, since a result that is nearly
|
||||
// ready still returns without a scheduler round trip.
|
||||
//
|
||||
// Gated on the driver vendor rather than __ANDROID__: an immediate-mode mobile
|
||||
// GPU wants the desktop path, and a tiler on any other platform wants this one.
|
||||
if (tile_based_renderer && spins >= 64)
|
||||
{
|
||||
std::this_thread::yield();
|
||||
}
|
||||
else
|
||||
// The short spin first keeps the fast case fast, since a result that is
|
||||
// nearly ready still returns without a scheduler round trip.
|
||||
if (spins < 64)
|
||||
{
|
||||
utils::pause();
|
||||
}
|
||||
|
||||
else
|
||||
{
|
||||
std::this_thread::yield();
|
||||
}
|
||||
#else
|
||||
utils::pause();
|
||||
#endif
|
||||
poke_query(query_info, index, result_flags);
|
||||
}
|
||||
}
|
||||
@@ -273,31 +263,6 @@ namespace vk
|
||||
return query_info.data;
|
||||
}
|
||||
|
||||
// Shared by both readback paths. vkCmdCopyQueryPoolResults MUST be recorded outside a render
|
||||
// pass instance; inside one it is undefined behaviour, and a tiler does not tolerate what an
|
||||
// IMR desktop GPU does. See the detailed history on get_query_result_indirect below.
|
||||
void query_pool_manager::end_renderpass_for_readback(vk::command_buffer& cmd)
|
||||
{
|
||||
if (!vk::is_renderpass_open(cmd))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// A query that began inside a render pass instance has to end inside that same instance.
|
||||
// Ending the pass underneath an open one leaves it permanently unavailable: the driver
|
||||
// never marks it ready, get_query_result spins on poke_query forever, and the RSX thread
|
||||
// stops with audio and vblank still alive -- a hang, not a crash. The submit-time ensure()
|
||||
// does not catch it either, because end_occlusion_query closes the query a moment later so
|
||||
// the flag reads clear while the result never arrives.
|
||||
if (cmd.flags & vk::command_buffer::cb_has_open_query)
|
||||
{
|
||||
vk::do_query_cleanup(cmd);
|
||||
}
|
||||
|
||||
if (rsx::prof::enabled()) [[unlikely]] rsx::prof::g_rp_sites[2]++;
|
||||
vk::end_renderpass(cmd);
|
||||
}
|
||||
|
||||
void query_pool_manager::get_query_result_indirect(vk::command_buffer& cmd, u32 index, u32 count, VkBuffer dst, VkDeviceSize dst_offset)
|
||||
{
|
||||
// Not "technically supposed to" -- vkCmdCopyQueryPoolResults MUST be recorded outside a
|
||||
@@ -316,60 +281,31 @@ namespace vk
|
||||
//
|
||||
// The upstream comment feared the flush cost. It is paid only when a pass is actually
|
||||
// open, on a path that already stalls for a GPU result.
|
||||
end_renderpass_for_readback(cmd);
|
||||
|
||||
vkCmdCopyQueryPoolResults(cmd, *query_slot_status[index].pool, index, count, dst, dst_offset, 4, VK_QUERY_RESULT_WAIT_BIT);
|
||||
}
|
||||
|
||||
// Batched readback. Copies each result in `indices` order to `dst`, one 4-byte word per
|
||||
// entry at its position in the list, coalescing contiguous index runs into single copies.
|
||||
//
|
||||
// Pool-aware on purpose: a numerically contiguous run can still span a pool reallocation,
|
||||
// and copying across that boundary would read from the wrong VkQueryPool. Ported from
|
||||
// ouroboros420/rpcsx (7ed3365bc), reusing our renderpass handling rather than theirs --
|
||||
// theirs omits the open-query cleanup above.
|
||||
void query_pool_manager::copy_query_results(vk::command_buffer& cmd, const std::vector<u32>& indices, VkBuffer dst)
|
||||
{
|
||||
if (indices.empty())
|
||||
if (vk::is_renderpass_open(cmd))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
end_renderpass_for_readback(cmd);
|
||||
|
||||
const usz n = indices.size();
|
||||
|
||||
for (usz i = 0; i < n;)
|
||||
{
|
||||
const u32 base = indices[i];
|
||||
const auto* pool = query_slot_status[base].pool;
|
||||
|
||||
usz j = i + 1;
|
||||
while (j < n && indices[j] == indices[j - 1] + 1 && query_slot_status[indices[j]].pool == pool)
|
||||
// A query that began inside a render pass instance has to end inside that same
|
||||
// instance. Ending the pass underneath an open one leaves it permanently
|
||||
// unavailable: the driver never marks it ready, so get_query_result spins on
|
||||
// poke_query forever and the RSX thread stops with everything else still alive.
|
||||
//
|
||||
// Nothing catches it either. The submit-time ensure() in commands.cpp only checks
|
||||
// that the query was closed, and end_occlusion_query does close it a moment later,
|
||||
// so the flag is clear and the assert passes while the result never arrives. That
|
||||
// cost Web of Shadows a hang here after the device loss below was fixed.
|
||||
//
|
||||
// Closing it first keeps begin and end within one pass, which is the ordering the
|
||||
// spec asks for. The query is cut short, as it is anywhere do_query_cleanup is
|
||||
// used, and a truncated occlusion result beats a stalled thread.
|
||||
if (cmd.flags & vk::command_buffer::cb_has_open_query)
|
||||
{
|
||||
j++;
|
||||
vk::do_query_cleanup(cmd);
|
||||
}
|
||||
|
||||
const u32 count = static_cast<u32>(j - i);
|
||||
|
||||
// The run is contiguous in both hardware index and list position, so query[base + k]
|
||||
// lands at destination word (i + k).
|
||||
vkCmdCopyQueryPoolResults(cmd, *query_slot_status[base].pool, base, count, dst,
|
||||
static_cast<VkDeviceSize>(i) * 4, 4, VK_QUERY_RESULT_WAIT_BIT);
|
||||
|
||||
i = j;
|
||||
if (rsx::prof::enabled()) [[unlikely]] rsx::prof::g_rp_sites[2]++;
|
||||
vk::end_renderpass(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed a slot from a value already read back on the host, so the next get_query_result
|
||||
// returns it without issuing another blocking read. Mirrors poke_query's VK_SUCCESS path.
|
||||
// Only valid for a value that was waited on (the copy above uses WAIT_BIT).
|
||||
void query_pool_manager::prime_query_result(u32 index, u32 value)
|
||||
{
|
||||
auto& query = query_slot_status[index];
|
||||
query.ready = true;
|
||||
query.data = value;
|
||||
query.any_passed = (value != 0);
|
||||
vkCmdCopyQueryPoolResults(cmd, *query_slot_status[index].pool, index, count, dst, dst_offset, 4, VK_QUERY_RESULT_WAIT_BIT);
|
||||
}
|
||||
|
||||
void query_pool_manager::free_query(vk::command_buffer&/*cmd*/, u32 index)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
#include "VulkanAPI.h"
|
||||
#include <deque>
|
||||
|
||||
@@ -48,13 +46,7 @@ namespace vk
|
||||
vk::render_device* owner = nullptr;
|
||||
std::vector<query_slot_info> query_slot_status;
|
||||
|
||||
// A tile-based renderer cannot produce a query result before its tiling pass resolves.
|
||||
// Decided once, at construction. More precise than an __ANDROID__ check, which would
|
||||
// misclassify an immediate-mode mobile part and miss a tiler on any other platform.
|
||||
bool tile_based_renderer = false;
|
||||
|
||||
bool poke_query(query_slot_info& query, u32 index, VkQueryResultFlags flags);
|
||||
void end_renderpass_for_readback(vk::command_buffer& cmd);
|
||||
void allocate_new_pool(vk::command_buffer& cmd);
|
||||
void reallocate_pool(vk::command_buffer& cmd);
|
||||
void run_pool_cleanup();
|
||||
@@ -72,13 +64,6 @@ namespace vk
|
||||
u32 get_query_result(u32 index);
|
||||
void get_query_result_indirect(vk::command_buffer& cmd, u32 index, u32 count, VkBuffer dst, VkDeviceSize dst_offset);
|
||||
|
||||
// Batched readback: copy every result in `indices` to `dst` (4-byte word per entry, at its
|
||||
// position in the list), coalescing contiguous same-pool runs into single copies.
|
||||
void copy_query_results(vk::command_buffer& cmd, const std::vector<u32>& indices, VkBuffer dst);
|
||||
|
||||
// Seed a slot from a host-read value so the next get_query_result needs no GPU round trip.
|
||||
void prime_query_result(u32 index, u32 value);
|
||||
|
||||
u32 allocate_query(vk::command_buffer& cmd);
|
||||
void free_query(vk::command_buffer&/*cmd*/, u32 index);
|
||||
|
||||
|
||||
@@ -15,25 +15,11 @@ namespace vk
|
||||
{
|
||||
VkRenderPass pass = VK_NULL_HANDLE;
|
||||
VkFramebuffer fbo = VK_NULL_HANDLE;
|
||||
|
||||
// Images backing the attachments of this pass, published by whoever opened it.
|
||||
// Empty means "unknown", which is treated as "covers nothing".
|
||||
std::vector<VkImage> attachments;
|
||||
};
|
||||
|
||||
// The last pass closed on a command buffer, for the redundancy check in begin_renderpass.
|
||||
// Profiling only; never read for correctness.
|
||||
struct closed_renderpass_info_t
|
||||
{
|
||||
VkRenderPass pass = VK_NULL_HANDLE;
|
||||
VkFramebuffer fbo = VK_NULL_HANDLE;
|
||||
const void* caller = nullptr;
|
||||
};
|
||||
|
||||
atomic_t<u64> g_cached_renderpass_key = 0;
|
||||
VkRenderPass g_cached_renderpass = VK_NULL_HANDLE;
|
||||
rsx::unordered_map<VkCommandBuffer, active_renderpass_info_t> g_current_renderpass;
|
||||
rsx::unordered_map<VkCommandBuffer, closed_renderpass_info_t> g_last_closed_renderpass;
|
||||
|
||||
shared_mutex g_renderpass_cache_mutex;
|
||||
rsx::unordered_map<u64, VkRenderPass> g_renderpass_cache;
|
||||
@@ -363,23 +349,10 @@ namespace vk
|
||||
// Framebuffer-local stages only. VK_DEPENDENCY_BY_REGION_BIT is what makes the
|
||||
// dependency tile-local rather than a full pipeline flush, and it requires every stage
|
||||
// named here to be framebuffer-space, which rules out the vertex stage.
|
||||
//
|
||||
// Symmetric on purpose. The feedback case needs write -> read (draw writes the pixel, the
|
||||
// next draw samples it) and the fall-out case needs read -> write (the sampling draw is
|
||||
// done, the next draw writes the attachment again). Declaring only the first direction
|
||||
// left render_target::post_texture_barrier with no dependency it could match, so the only
|
||||
// legal thing it could do was end the pass, which is what it did.
|
||||
//
|
||||
// Every stage named is framebuffer-space (FRAGMENT_SHADER, EARLY/LATE_FRAGMENT_TESTS,
|
||||
// COLOR_ATTACHMENT_OUTPUT), which is what VK_DEPENDENCY_BY_REGION_BIT requires of a
|
||||
// self-dependency, and each access bit is one its stage supports. Widening a subpass
|
||||
// dependency only adds synchronization; it is not part of render pass compatibility, so
|
||||
// no cached pipeline is invalidated by this.
|
||||
VkSubpassDependency self_dependency = {};
|
||||
self_dependency.srcSubpass = 0;
|
||||
self_dependency.dstSubpass = 0;
|
||||
self_dependency.srcStageMask =
|
||||
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT |
|
||||
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT |
|
||||
VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT |
|
||||
VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT;
|
||||
@@ -389,100 +362,23 @@ namespace vk
|
||||
VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT |
|
||||
VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT;
|
||||
self_dependency.srcAccessMask =
|
||||
VK_ACCESS_SHADER_READ_BIT |
|
||||
VK_ACCESS_INPUT_ATTACHMENT_READ_BIT |
|
||||
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT |
|
||||
VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
|
||||
self_dependency.dstAccessMask =
|
||||
VK_ACCESS_SHADER_READ_BIT |
|
||||
VK_ACCESS_INPUT_ATTACHMENT_READ_BIT |
|
||||
VK_ACCESS_COLOR_ATTACHMENT_READ_BIT |
|
||||
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT |
|
||||
VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT |
|
||||
VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
|
||||
VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT;
|
||||
self_dependency.dependencyFlags = VK_DEPENDENCY_BY_REGION_BIT;
|
||||
|
||||
VkSubpassDependency dependencies[2] = { self_dependency, {} };
|
||||
u32 dependency_count = 1;
|
||||
|
||||
#ifdef __ANDROID__
|
||||
if constexpr (s_surface_parking_enabled)
|
||||
{
|
||||
// The dependency parking removed, put back where it costs nothing.
|
||||
//
|
||||
// Without parking, a render target that has been sampled sits in
|
||||
// SHADER_READ_ONLY_OPTIMAL and is dragged back to COLOR_/DEPTH_STENCIL_ATTACHMENT_-
|
||||
// OPTIMAL when it is next bound. That transition is what carried the read -> write
|
||||
// dependency: image_helpers.cpp gives it srcAccess = SHADER_READ | TRANSFER_READ at
|
||||
// FRAGMENT_SHADER | TRANSFER, dstAccess = COLOR_ATTACHMENT_WRITE at
|
||||
// COLOR_ATTACHMENT_OUTPUT. Parking declines the transition, so that barrier is gone
|
||||
// and nothing else orders the previous pass's sample against this pass's write.
|
||||
//
|
||||
// A render pass boundary is not a synchronization boundary in Vulkan, and the
|
||||
// implicit VK_SUBPASS_EXTERNAL dependency has srcStageMask = TOP_OF_PIPE, which is
|
||||
// no execution dependency on anything. So this has to be declared, or parking is
|
||||
// shipping a write-after-read hazard on every RTT it touches. That class of omission
|
||||
// is exactly what produced visible corruption in the first attempt at this work.
|
||||
//
|
||||
// Declared on the pass rather than issued per draw because it is free here: it is
|
||||
// baked into the render pass object, applies once at pass start, and a tiler already
|
||||
// orders passes on the same attachment. A per-draw barrier would cost a teardown and
|
||||
// give back everything parking wins.
|
||||
//
|
||||
// Deliberately NOT VK_DEPENDENCY_BY_REGION_BIT. By-region is for tile-local
|
||||
// self-dependencies; this crosses a pass boundary, where the producer's fragment may
|
||||
// be anywhere in the framebuffer relative to the consumer's.
|
||||
//
|
||||
// Covers write-after-write too (srcAccess names the attachment writes), which the
|
||||
// same removed transition used to cover for a surface written, sampled, and written
|
||||
// again without changing layout.
|
||||
//
|
||||
// A dependency is not part of render pass compatibility (Vulkan 8.2: compatibility is
|
||||
// attachment references, formats and sample counts), so adding one does not
|
||||
// invalidate a single cached pipeline.
|
||||
VkSubpassDependency& entry_dependency = dependencies[dependency_count++];
|
||||
entry_dependency.srcSubpass = VK_SUBPASS_EXTERNAL;
|
||||
entry_dependency.dstSubpass = 0;
|
||||
//
|
||||
// The source scope mirrors what image_helpers.cpp put on the GENERAL ->
|
||||
// ATTACHMENT_OPTIMAL transition that parking removes, plus the vertex stage: vertex
|
||||
// textures reach validate_image_layout_for_read_access too (VKDraw.cpp passes
|
||||
// VK_PIPELINE_STAGE_VERTEX_SHADER_BIT there), so a vertex shader sampling a parked
|
||||
// RTT is a reader this has to order against. Naming a non-framebuffer-space stage is
|
||||
// fine here precisely because this is not a by-region self-dependency.
|
||||
entry_dependency.srcStageMask =
|
||||
VK_PIPELINE_STAGE_VERTEX_SHADER_BIT |
|
||||
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT |
|
||||
VK_PIPELINE_STAGE_TRANSFER_BIT |
|
||||
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT |
|
||||
VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT |
|
||||
VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT;
|
||||
entry_dependency.dstStageMask =
|
||||
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT |
|
||||
VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT |
|
||||
VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT;
|
||||
entry_dependency.srcAccessMask =
|
||||
VK_ACCESS_SHADER_READ_BIT |
|
||||
VK_ACCESS_TRANSFER_READ_BIT |
|
||||
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT |
|
||||
VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
|
||||
entry_dependency.dstAccessMask =
|
||||
VK_ACCESS_COLOR_ATTACHMENT_READ_BIT |
|
||||
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT |
|
||||
VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT |
|
||||
VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
|
||||
entry_dependency.dependencyFlags = 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
VkRenderPassCreateInfo rp_info = {};
|
||||
rp_info.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
|
||||
rp_info.attachmentCount = ::size32(attachments);
|
||||
rp_info.pAttachments = attachments.data();
|
||||
rp_info.subpassCount = 1;
|
||||
rp_info.pSubpasses = &subpass;
|
||||
rp_info.dependencyCount = dependency_count;
|
||||
rp_info.pDependencies = dependencies;
|
||||
rp_info.dependencyCount = 1;
|
||||
rp_info.pDependencies = &self_dependency;
|
||||
|
||||
VkRenderPass result;
|
||||
CHECK_RESULT(vkCreateRenderPass(dev, &rp_info, NULL, &result));
|
||||
@@ -497,7 +393,6 @@ namespace vk
|
||||
g_cached_renderpass_key = 0;
|
||||
g_cached_renderpass = VK_NULL_HANDLE;
|
||||
g_current_renderpass.clear();
|
||||
g_last_closed_renderpass.clear();
|
||||
|
||||
// Destroy cache
|
||||
for (const auto &renderpass : g_renderpass_cache)
|
||||
@@ -542,28 +437,6 @@ namespace vk
|
||||
rsx::prof::g_pass_width[rsx::prof::g_pass_ordinal] = static_cast<u16>(framebuffer_region.width);
|
||||
rsx::prof::g_pass_height[rsx::prof::g_pass_ordinal] = static_cast<u16>(framebuffer_region.height);
|
||||
}
|
||||
|
||||
// Reopening the identical (pass, framebuffer) that was just closed. The teardown in
|
||||
// between bought nothing: the tile was stored and reloaded to record a barrier, and
|
||||
// the pass came straight back.
|
||||
//
|
||||
// This is the number that says how much any barrier-suppression work can possibly
|
||||
// win, and it is the one the site counters cannot give. A site counter says a
|
||||
// teardown was charged there; it cannot say whether the pass was going to end anyway
|
||||
// because the next draw switches framebuffer. Removing a teardown that was going to
|
||||
// happen just relocates the charge - which is what happened to iteration 3, where
|
||||
// ImgHelper:43 fell and (Draw:1093 fbo) rose by the same amount.
|
||||
//
|
||||
// So: total passes minus redundant passes is the floor. If redundant is small, no
|
||||
// amount of barrier work moves the frame, and the honest answer is to stop.
|
||||
auto& closed = g_last_closed_renderpass[cmd];
|
||||
if (closed.pass == pass && closed.fbo == target)
|
||||
{
|
||||
rsx::prof::g_rp_reopened++;
|
||||
rsx::prof::note_rp_teardown(closed.caller, 2);
|
||||
}
|
||||
|
||||
closed = {};
|
||||
}
|
||||
|
||||
// The draw region was declared and never recorded anywhere, so the one figure that
|
||||
@@ -617,15 +490,6 @@ namespace vk
|
||||
// After the pass ends, so the tile store it triggers is charged to the region.
|
||||
vk::get_gpu_timer().end(cmd, vk::gpu_timer::region::draw);
|
||||
|
||||
// Remember what was closed and by whom, so the next begin can tell whether this teardown
|
||||
// was load-bearing or whether the same pass simply came back. __builtin_return_address
|
||||
// here is the teardown site itself - end_renderpass is called directly from all of them.
|
||||
if (rsx::prof::enabled()) [[unlikely]]
|
||||
{
|
||||
const auto& active = g_current_renderpass[cmd];
|
||||
g_last_closed_renderpass[cmd] = { active.pass, active.fbo, __builtin_return_address(0) };
|
||||
}
|
||||
|
||||
g_current_renderpass[cmd] = {};
|
||||
}
|
||||
|
||||
@@ -634,39 +498,6 @@ namespace vk
|
||||
return g_current_renderpass[cmd].pass != VK_NULL_HANDLE;
|
||||
}
|
||||
|
||||
void set_renderpass_attachments(const vk::command_buffer& cmd, const std::vector<vk::image*>& images)
|
||||
{
|
||||
auto& info = g_current_renderpass[cmd];
|
||||
if (info.pass == VK_NULL_HANDLE)
|
||||
{
|
||||
// Nothing open. Do not record a set that would outlive the pass it describes.
|
||||
return;
|
||||
}
|
||||
|
||||
info.attachments.clear();
|
||||
info.attachments.reserve(images.size());
|
||||
|
||||
for (const auto* img : images)
|
||||
{
|
||||
if (img)
|
||||
{
|
||||
info.attachments.push_back(img->value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool renderpass_covers_image(const vk::command_buffer& cmd, VkImage image)
|
||||
{
|
||||
const auto& info = g_current_renderpass[cmd];
|
||||
if (info.pass == VK_NULL_HANDLE || image == VK_NULL_HANDLE)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// At most 5 entries (4 MRTs + depth), so a scan beats anything with a hash in it.
|
||||
return std::find(info.attachments.begin(), info.attachments.end(), image) != info.attachments.end();
|
||||
}
|
||||
|
||||
void renderpass_op(const vk::command_buffer& cmd, const renderpass_op_callback_t& op)
|
||||
{
|
||||
const auto& active = g_current_renderpass[cmd];
|
||||
|
||||
@@ -8,50 +8,6 @@ namespace vk
|
||||
class image;
|
||||
class command_buffer;
|
||||
|
||||
// Surface layout parking policy.
|
||||
//
|
||||
// "Parking" is declining to move a render target out of a layout that is legal both for
|
||||
// sampling and for use as an attachment, so a sample-then-rebind cycle costs no layout
|
||||
// transition at all. A transition can never be recorded inside a render pass instance
|
||||
// (VUID-vkCmdPipelineBarrier-oldLayout-01181), so every transition avoided is a render pass
|
||||
// teardown avoided - on a tiler, a tile store plus a full reload.
|
||||
//
|
||||
// Both knobs live here rather than next to vk::render_target because the render pass builder
|
||||
// has to see them too. The barrier that parking removes is the one that used to carry the
|
||||
// read -> write dependency across the cycle, so the pass has to declare that dependency
|
||||
// itself; see the VK_SUBPASS_EXTERNAL dependency in get_renderpass. Setting these to 0 has to
|
||||
// take that declaration away with them, otherwise "0 reproduces current behaviour" is a lie.
|
||||
//
|
||||
// Both are counted in flips, not binds. A render graph repeats per frame, so "was this
|
||||
// surface used this way recently" is a per-frame question; the number of unrelated binds
|
||||
// collected in between is a property of the scene's draw count and is not a useful unit.
|
||||
// Two frames of slack rather than one so a surface used on alternate frames, or one that
|
||||
// misses a frame to a paused or duplicate flip, does not oscillate.
|
||||
|
||||
// Feedback loops: a surface written and sampled by the same draw, parked in
|
||||
// ATTACHMENT_FEEDBACK_LOOP_OPTIMAL_EXT where the device supports it and GENERAL otherwise.
|
||||
// Iteration 3. Measured effect on Arkham City: close to zero, because the teardown it
|
||||
// removes at the bind site relocates to the draw site as a framebuffer mismatch.
|
||||
// Held at 0 pending the baseline `redundant` reading: iteration 3 measured close to zero here,
|
||||
// and at 0 this also drops the extra pass dependency below, which is the only thing that was
|
||||
// covering the read->write hazard this park had been running without.
|
||||
inline constexpr u64 s_feedback_park_frames = 0;
|
||||
|
||||
// Non-cyclic sampling of a render target: the RTT is left in GENERAL for the read instead of
|
||||
// being dragged to SHADER_READ_ONLY_OPTIMAL and back. GENERAL, not the feedback-loop layout,
|
||||
// deliberately - see the note in render_target::get_sample_park_layout.
|
||||
//
|
||||
// This is the independent A/B knob. Set to 0 to get the previous behaviour exactly while
|
||||
// leaving feedback parking alone, and vice versa.
|
||||
// Held at 0 for the same reason. `redundant` in the RSXPROF line is the hard ceiling on what
|
||||
// suppressing these teardowns can win; read that first, because if it is small then this trade
|
||||
// (GENERAL costs UBWC while rendering, and HiZ on depth) cannot pay for itself and the answer
|
||||
// is to leave both at 0.
|
||||
inline constexpr u64 s_sample_park_frames = 0;
|
||||
|
||||
// Any parking at all. Gates the extra render pass dependency that makes parking safe.
|
||||
inline constexpr bool s_surface_parking_enabled = (s_feedback_park_frames != 0) || (s_sample_park_frames != 0);
|
||||
|
||||
u64 get_renderpass_key(const std::vector<vk::image*>& images, const std::vector<u8>& input_attachment_ids = {});
|
||||
u64 get_renderpass_key(const std::vector<vk::image*>& images, u64 previous_key);
|
||||
u64 get_renderpass_key(VkFormat surface_format, u8 sample_count = 1);
|
||||
@@ -67,24 +23,6 @@ namespace vk
|
||||
void end_renderpass(const vk::command_buffer& cmd);
|
||||
bool is_renderpass_open(const vk::command_buffer& cmd);
|
||||
|
||||
// Attachment set of the pass currently open on this command buffer.
|
||||
//
|
||||
// VUID-vkCmdPipelineBarrier-image-04073 only allows a barrier to be recorded inside a render
|
||||
// pass instance when the image is an attachment of the current subpass. "The caller only ever
|
||||
// does this for a bound render target" is not the same statement: the pass that is open is the
|
||||
// one from the previous draw, and it is not re-created until the draw call itself, so between
|
||||
// a framebuffer switch and the next draw the open pass belongs to the *previous* framebuffer.
|
||||
// A surface that needs no layout change (because it is parked) does not end the pass on the
|
||||
// way in, so it can reach a barrier site with a stale pass open and its own image absent from
|
||||
// it. That is the corruption an earlier attempt hit; this makes the rule checkable instead of
|
||||
// assumed.
|
||||
//
|
||||
// The set is cleared whenever a pass begins, so any pass opened by code that does not publish
|
||||
// its attachments (overlays, texture cache, present) reads as covering nothing and in-pass
|
||||
// barriers are refused for it. Fail-closed by construction.
|
||||
void set_renderpass_attachments(const vk::command_buffer& cmd, const std::vector<vk::image*>& images);
|
||||
bool renderpass_covers_image(const vk::command_buffer& cmd, VkImage image);
|
||||
|
||||
using renderpass_op_callback_t = std::function<void(const vk::command_buffer&, VkRenderPass, VkFramebuffer)>;
|
||||
void renderpass_op(const vk::command_buffer& cmd, const renderpass_op_callback_t& op);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#include "vkutils/data_heap.h"
|
||||
#include "VKRenderTargets.h"
|
||||
#include "VKRenderPass.h"
|
||||
#include "VKResourceManager.h"
|
||||
#include "Emu/RSX/rsx_methods.h"
|
||||
#include "Emu/RSX/RSXThread.h"
|
||||
@@ -652,14 +651,6 @@ namespace vk
|
||||
|
||||
void render_target::unspill(vk::command_buffer& cmd)
|
||||
{
|
||||
// The image below is a different VkImage with different contents, so any park describing
|
||||
// the old one is meaningless. Both readers already re-check current_layout and unspill
|
||||
// leaves it in an attachment or transfer layout rather than GENERAL, so this is belt and
|
||||
// braces - but the read-synchronization tag is keyed on last_use_tag, which the recreate
|
||||
// does not touch, and that is a hazard rather than a missed optimization if it ever
|
||||
// lines up.
|
||||
clear_sample_park();
|
||||
|
||||
// Recreate the image
|
||||
const auto pdev = vk::get_current_renderer();
|
||||
create_impl(*pdev, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, pdev->get_memory_mapping().device_local, VMM_ALLOCATION_POOL_SURFACE_CACHE);
|
||||
@@ -856,182 +847,12 @@ namespace vk
|
||||
return (scaled_w == width()) && (scaled_h == height());
|
||||
}
|
||||
|
||||
VkImageLayout render_target::get_feedback_loop_layout(const vk::command_buffer& cmd)
|
||||
{
|
||||
// The layout a surface is parked in while it is both written and sampled by the same
|
||||
// draw. Both of these are legal attachment layouts as well as legal sampling layouts,
|
||||
// which is the property try_park_in_feedback_loop relies on: an attachment left here is
|
||||
// still a valid attachment, and the render pass key encoder accepts both.
|
||||
return cmd.get_command_pool().get_owner().get_framebuffer_loops_support()
|
||||
? VK_IMAGE_LAYOUT_ATTACHMENT_FEEDBACK_LOOP_OPTIMAL_EXT
|
||||
: VK_IMAGE_LAYOUT_GENERAL;
|
||||
}
|
||||
|
||||
// The sample park.
|
||||
//
|
||||
// Iterations 1-3 attacked the feedback loop, which turned out to be a rounding error: the
|
||||
// measured split of Draw:1093 came back 100% framebuffer mismatch and 0% render pass key, so
|
||||
// layout churn was contributing nothing there. What did not move across any of the three was
|
||||
// ImgHelper:43, which is change_image_layout ending the open pass, and the largest single
|
||||
// contributor to it is the ordinary render-to-texture cycle:
|
||||
//
|
||||
// validate_image_layout_for_read_access drags an unbound RTT from
|
||||
// COLOR_/DEPTH_STENCIL_ATTACHMENT_OPTIMAL to SHADER_READ_ONLY_OPTIMAL so it can be sampled,
|
||||
// and prepare_surface_for_drawing drags it straight back the next time the game renders to
|
||||
// it. Two layout changes, therefore two teardowns, per sample-then-rebind cycle.
|
||||
//
|
||||
// Only the first is a floor. The read genuinely needs a write -> read barrier and a barrier
|
||||
// cannot be recorded inside a pass unless the image is an attachment of it, which a sampled
|
||||
// unbound RTT is not. The return trip is pure overhead: GENERAL is a legal sampling layout
|
||||
// AND a legal attachment layout, the descriptor path takes imageLayout from
|
||||
// image->current_layout at bind time (vkutils/ex.cpp), and the render pass key encoder
|
||||
// already accepts GENERAL. So the surface can simply be left where it is.
|
||||
//
|
||||
// What this does NOT do is make the first half free, and the honest read of the win is
|
||||
// "half of the sample/rebind pairs, if and only if those teardowns were not going to happen
|
||||
// anyway". That last clause is why g_rp_reopened exists: see rsx_profiler.h.
|
||||
bool render_target::try_arm_sample_park()
|
||||
{
|
||||
if constexpr (s_sample_park_frames == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// A bound attachment must never be parked from here. Its layout is baked into
|
||||
// m_current_renderpass_key, which prepare_rtts computed at the end of the bind and which
|
||||
// nothing recomputes between there and the draw. Moving a bound surface to GENERAL would
|
||||
// leave the pass declaring initialLayout = ATTACHMENT_OPTIMAL for an image that is not in
|
||||
// it, which is undefined contents rather than a validation error.
|
||||
//
|
||||
// Today the same mistake is caught loudly - renderpass_key_blob::set_layout throws on
|
||||
// SHADER_READ_ONLY_OPTIMAL - and GENERAL would encode silently, so this check is what
|
||||
// replaces the throw. The sampled-while-bound case is a feedback loop and belongs to
|
||||
// texture_barrier regardless.
|
||||
if (is_bound)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// MSAA surfaces sample through a resolve target and carry a second image whose contents
|
||||
// are produced by resolve/unresolve passes that move layouts on their own schedule.
|
||||
// Not worth the reasoning for the volume involved.
|
||||
if (samples() > 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto renderer = rsx::get_current_renderer();
|
||||
if (!renderer)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
m_sample_park_deadline = renderer->int_flip_index + s_sample_park_frames + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool render_target::try_park_for_sampling()
|
||||
{
|
||||
if (!m_sample_park_deadline)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Same honesty check the feedback park makes: parking never suppresses a transition, it
|
||||
// only declines to start one. If anything has moved the layout since - a blit, a spill,
|
||||
// an unspill that rebuilt the image as UNDEFINED, the zeta fall-out valve - the park is
|
||||
// over and the normal path runs.
|
||||
if (current_layout != get_sample_park_layout())
|
||||
{
|
||||
clear_sample_park();
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto renderer = rsx::get_current_renderer();
|
||||
if (renderer && renderer->int_flip_index >= m_sample_park_deadline)
|
||||
{
|
||||
// Expired. Let the surface go back to the attachment-optimal layout and get its
|
||||
// compression back. On Adreno that is UBWC on a full-size render target, which is
|
||||
// not something to hold hostage to a surface that has stopped being sampled.
|
||||
clear_sample_park();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool render_target::sample_park_needs_read_barrier() const
|
||||
{
|
||||
// last_use_tag advances on every write: on_write / on_write_fast at the end of each draw
|
||||
// that had this surface bound with writes enabled, on_write after a clear or a memory
|
||||
// initialize, on_write_copy after a blit. Equal tags therefore mean "nothing has written
|
||||
// this since the barrier that made the previous read safe".
|
||||
return !m_sample_park_sync_tag || m_sample_park_sync_tag != last_use_tag;
|
||||
}
|
||||
|
||||
void render_target::on_sample_park_synced()
|
||||
{
|
||||
m_sample_park_sync_tag = last_use_tag;
|
||||
}
|
||||
|
||||
void render_target::clear_sample_park()
|
||||
{
|
||||
m_sample_park_deadline = 0;
|
||||
m_sample_park_sync_tag = 0;
|
||||
}
|
||||
|
||||
void render_target::arm_feedback_park()
|
||||
{
|
||||
if constexpr (s_feedback_park_frames == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const auto renderer = rsx::get_current_renderer();
|
||||
if (!renderer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Deadline is exclusive, so the arming frame itself is always covered even when the
|
||||
// window is one flip. int_flip_index only moves on the RSX thread, which is also the
|
||||
// only thread that reaches this, so no synchronization is needed on it.
|
||||
m_feedback_park_deadline = renderer->int_flip_index + s_feedback_park_frames + 1;
|
||||
}
|
||||
|
||||
bool render_target::try_park_in_feedback_loop(const vk::command_buffer& cmd)
|
||||
{
|
||||
// The layout test is what keeps this honest: parking never suppresses a transition, it
|
||||
// only declines to start one. A surface that is not already sitting in the loop layout
|
||||
// takes the normal path, so nothing here can produce a barrier with old != new, and
|
||||
// nothing here can leave an attachment in a layout the render pass key does not encode.
|
||||
if (!m_feedback_park_deadline || current_layout != get_feedback_loop_layout(cmd))
|
||||
{
|
||||
// Either never armed, or something outside parking has already moved the layout -
|
||||
// a non-cyclic sample dragging it to SHADER_READ_ONLY_OPTIMAL, the zeta fall-out
|
||||
// valve in VKDraw restoring HiZ, a blit, a spill. The park is over either way, and
|
||||
// leaving the deadline set would let a later unrelated visit to the loop layout
|
||||
// re-park a surface that never asked for it.
|
||||
m_feedback_park_deadline = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto renderer = rsx::get_current_renderer();
|
||||
if (renderer && renderer->int_flip_index >= m_feedback_park_deadline)
|
||||
{
|
||||
// Expired. Fall through to the normal path, which puts the surface back in the
|
||||
// attachment-optimal layout and hands its compression back.
|
||||
m_feedback_park_deadline = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void render_target::texture_barrier(vk::command_buffer& cmd)
|
||||
{
|
||||
const auto is_framebuffer_read_only = is_depth_surface() && !rsx::method_registers.depth_write_enabled();
|
||||
const auto optimal_layout = get_feedback_loop_layout(cmd);
|
||||
const auto supports_fbo_loops = cmd.get_command_pool().get_owner().get_framebuffer_loops_support();
|
||||
const auto optimal_layout = supports_fbo_loops ? VK_IMAGE_LAYOUT_ATTACHMENT_FEEDBACK_LOOP_OPTIMAL_EXT
|
||||
: VK_IMAGE_LAYOUT_GENERAL;
|
||||
|
||||
if (m_cyclic_ref_tracker.can_skip() && current_layout == optimal_layout && is_framebuffer_read_only)
|
||||
{
|
||||
@@ -1058,35 +879,6 @@ namespace vk
|
||||
vk::insert_texture_barrier(cmd, this, optimal_layout, preserve_renderpass);
|
||||
m_cyclic_ref_tracker.on_insert_texture_barrier();
|
||||
|
||||
// Open (or re-open) the park window. This is the only writer.
|
||||
//
|
||||
// The first attempt keyed parking off m_cyclic_ref_tracker.is_enabled() and it could not
|
||||
// hold for more than a single bind, because the tracker is a within-loop signal that
|
||||
// three separate paths clear before the next bind ever runs:
|
||||
// - prepare_surface_for_drawing calls reset_surface_counters() at every bind;
|
||||
// - post_texture_barrier() resets it, and that is the *normal* end of an episode - the
|
||||
// first draw after the loop with the surface still bound trips
|
||||
// requires_post_loop_barrier() and resets before the next bind is reached;
|
||||
// - memory_barrier() resets it when the layout moved elsewhere.
|
||||
// So the predicate was true only on a bind directly preceded by a feedback draw, and the
|
||||
// surface was dragged out of the loop layout on the very next bind and back in on the one
|
||||
// after. Measured on Arkham City: the transitions moved from the bind site to the barrier
|
||||
// site and the pass count did not change.
|
||||
//
|
||||
// A window rather than a flag because the useful question is "has this surface been in a
|
||||
// loop recently", and an episode that recurs within a few binds should not pay to leave
|
||||
// and re-enter the layout in between. It decays so a surface that has genuinely stopped
|
||||
// looping goes back to the attachment-optimal layout and gets its compression back; the
|
||||
// depth case is the one that matters, HiZ is not free to give up indefinitely.
|
||||
//
|
||||
// The second attempt made that window four binds and that was still the wrong unit: see
|
||||
// s_feedback_park_frames. It is counted in flips now, so an episode that recurs every
|
||||
// frame - which is what a render graph does - keeps the surface parked continuously
|
||||
// instead of dropping it between episodes.
|
||||
#ifdef __ANDROID__
|
||||
arm_feedback_park();
|
||||
#endif
|
||||
|
||||
if (is_framebuffer_read_only)
|
||||
{
|
||||
m_cyclic_ref_tracker.allow_skip();
|
||||
@@ -1108,47 +900,23 @@ namespace vk
|
||||
VkPipelineStageFlags src_stage, dst_stage;
|
||||
VkAccessFlags src_access, dst_access;
|
||||
|
||||
// This barrier does not move the layout - it is the read-completes-before-write half of a
|
||||
// feedback loop on a surface that is still the bound attachment. Both of the conditions
|
||||
// that let a barrier stay inside the pass therefore hold, and it was still ending the
|
||||
// pass on every fall-out purely because it never asked not to. The render pass now
|
||||
// declares the read -> write direction of the self-dependency, so it can ask.
|
||||
//
|
||||
// The vertex stage has to come off the source scope when the barrier lands inside the
|
||||
// pass: VK_DEPENDENCY_BY_REGION_BIT admits framebuffer-space stages only, and a vertex
|
||||
// shader sampling a live attachment is not a tile-local dependency. That is the same
|
||||
// trade texture_barrier already makes on this path, and it is sound for what this
|
||||
// synchronizes - the fall-out is a fragment-shader read of the attachment the next draw
|
||||
// writes. Outside the pass the wider scope is kept.
|
||||
#ifdef __ANDROID__
|
||||
const bool preserve_renderpass = vk::renderpass_covers_image(cmd, value);
|
||||
#else
|
||||
constexpr bool preserve_renderpass = false;
|
||||
#endif
|
||||
|
||||
if (!is_depth_surface()) [[likely]]
|
||||
{
|
||||
src_stage = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
|
||||
src_stage = VK_PIPELINE_STAGE_VERTEX_SHADER_BIT | VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
|
||||
dst_stage = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
|
||||
src_access = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
|
||||
dst_access = VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
|
||||
}
|
||||
else
|
||||
{
|
||||
src_stage = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT;
|
||||
src_stage = VK_PIPELINE_STAGE_VERTEX_SHADER_BIT | VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT;
|
||||
dst_stage = VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT | VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT;
|
||||
src_access = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
|
||||
dst_access = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
|
||||
}
|
||||
|
||||
if (!preserve_renderpass)
|
||||
{
|
||||
src_stage |= VK_PIPELINE_STAGE_VERTEX_SHADER_BIT;
|
||||
}
|
||||
|
||||
vk::insert_image_memory_barrier(cmd, value, current_layout, current_layout,
|
||||
src_stage, dst_stage, src_access, dst_access, { aspect(), 0, 1, 0, 1 },
|
||||
preserve_renderpass);
|
||||
src_stage, dst_stage, src_access, dst_access, { aspect(), 0, 1, 0, 1 });
|
||||
|
||||
m_cyclic_ref_tracker.reset();
|
||||
}
|
||||
@@ -1157,18 +925,6 @@ namespace vk
|
||||
{
|
||||
frame_tag = 0;
|
||||
m_cyclic_ref_tracker.reset();
|
||||
|
||||
// Belt and braces for the sample park's read-synchronization tag. This is called from
|
||||
// prepare_surface_for_drawing and from the zeta fall-out valve, both of which mean "this
|
||||
// surface is or was just an attachment", so the next sample must re-establish a
|
||||
// write -> read barrier. last_use_tag alone would already say so for anything that
|
||||
// reaches on_write; this covers the paths that write pixels through some route that does
|
||||
// not. Costs at most one extra barrier on a surface that was bound and never written.
|
||||
//
|
||||
// Deliberately does NOT clear m_sample_park_deadline. The park has to outlive the bind
|
||||
// that resets the counters or it can never hold for more than one bind - the same
|
||||
// mistake the first feedback park made.
|
||||
m_sample_park_sync_tag = 0;
|
||||
}
|
||||
|
||||
image_view* render_target::get_view(const rsx::texture_channel_remap_t& remap, VkImageAspectFlags mask)
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
#include "VKFormats.h"
|
||||
#include "VKHelpers.h"
|
||||
#include "VKRenderPass.h"
|
||||
#include "vkutils/barriers.h"
|
||||
#include "vkutils/buffer_object.h"
|
||||
#include "vkutils/device.h"
|
||||
@@ -73,35 +72,6 @@ namespace vk
|
||||
// Cyclic reference hazard tracking
|
||||
image_reference_sync_barrier m_cyclic_ref_tracker;
|
||||
|
||||
// Flip index this surface stops being left in the feedback-loop layout at. 0 = not parked.
|
||||
//
|
||||
// Deliberately NOT part of m_cyclic_ref_tracker, and deliberately not cleared by
|
||||
// reset_surface_counters. The tracker answers "is a loop in progress right now", which is
|
||||
// a within-draw question, and every bind resets it. What parking needs is "was this
|
||||
// surface in a loop recently", which has to outlive the bind that resets the tracker or
|
||||
// it can never hold for more than one bind. See the comment on texture_barrier.
|
||||
u64 m_feedback_park_deadline = 0;
|
||||
|
||||
// Same idea, for the far more common case: a render target sampled while it is NOT bound.
|
||||
// Flip index the sample park expires at; 0 = not parked.
|
||||
u64 m_sample_park_deadline = 0;
|
||||
|
||||
// last_use_tag as of the last read barrier issued for this surface while parked. The
|
||||
// surface is safe to sample with no barrier at all exactly while this still matches
|
||||
// last_use_tag, because that tag advances on every write (surface_store::on_write and
|
||||
// on_write_fast at end of draw, on_write_copy after a blit).
|
||||
//
|
||||
// This is the piece the layout used to carry implicitly. Without parking, "already in
|
||||
// SHADER_READ_ONLY_OPTIMAL" meant "already synchronized for reading", so the second and
|
||||
// later draws sampling the same texture fell through validate_image_layout_for_read_access
|
||||
// with no barrier. Leave the surface in GENERAL and that signal is gone: every sampling
|
||||
// draw would look identical to the first and issue its own barrier, which for a shadow
|
||||
// map sampled by hundreds of draws would turn one teardown into hundreds. The tag makes
|
||||
// the same statement explicitly and does not depend on the layout.
|
||||
//
|
||||
// 0 = never synchronized, always barrier.
|
||||
u64 m_sample_park_sync_tag = 0;
|
||||
|
||||
// Memory spilling support
|
||||
std::unique_ptr<vk::buffer> m_spilled_mem;
|
||||
|
||||
@@ -152,44 +122,6 @@ namespace vk
|
||||
void memory_barrier(vk::command_buffer& cmd, rsx::surface_access access);
|
||||
void read_barrier(vk::command_buffer& cmd) { memory_barrier(cmd, rsx::surface_access::shader_read); }
|
||||
void write_barrier(vk::command_buffer& cmd) { memory_barrier(cmd, rsx::surface_access::shader_write); }
|
||||
|
||||
// Layout a surface is parked in while it is written and sampled by the same draw.
|
||||
// See vk::s_feedback_park_frames in VKRenderPass.h for the window and the A/B knob.
|
||||
static VkImageLayout get_feedback_loop_layout(const vk::command_buffer& cmd);
|
||||
// True when this bind may leave the surface in the feedback-loop layout instead of
|
||||
// dragging it back to the attachment-optimal one.
|
||||
bool try_park_in_feedback_loop(const vk::command_buffer& cmd);
|
||||
// Arms the park window. Called from the barrier sites that establish a feedback loop.
|
||||
void arm_feedback_park();
|
||||
|
||||
// Layout a surface is parked in while it is sampled without being bound.
|
||||
//
|
||||
// Plain GENERAL, never ATTACHMENT_FEEDBACK_LOOP_OPTIMAL_EXT, and that is a deliberate
|
||||
// narrowing rather than an oversight. The feedback-loop layout is only meaningful for an
|
||||
// image that is simultaneously an attachment and a sampled texture, and using it legally
|
||||
// requires the pipelines and framebuffer to be created with the matching
|
||||
// VK_PIPELINE_CREATE_COLOR_ATTACHMENT_FEEDBACK_LOOP_BIT_EXT /
|
||||
// VK_IMAGE_VIEW_CREATE_..._EXT flags, which nothing in this backend sets. The existing
|
||||
// feedback park already sits on that exposure; there is no reason to enlarge it for a
|
||||
// case that is not a feedback loop at all. GENERAL is unconditionally legal both as an
|
||||
// attachment layout and as a sampled layout, and the render pass key encoder accepts it
|
||||
// (VKRenderPass.cpp set_layout), which is everything this needs.
|
||||
static constexpr VkImageLayout get_sample_park_layout() { return VK_IMAGE_LAYOUT_GENERAL; }
|
||||
|
||||
// True when a non-cyclic sample of this surface should leave it in GENERAL rather than
|
||||
// dragging it to SHADER_READ_ONLY_OPTIMAL. Arms the park window as a side effect.
|
||||
// Takes no command buffer: unlike the feedback layout, the sample park layout is a
|
||||
// constant and needs no device query.
|
||||
bool try_arm_sample_park();
|
||||
// True when this bind may leave a sample-parked surface where it is.
|
||||
bool try_park_for_sampling();
|
||||
// True when a parked surface has been written since its last read barrier and therefore
|
||||
// needs another one. False means the sample can be issued with no barrier at all.
|
||||
bool sample_park_needs_read_barrier() const;
|
||||
// Records that a read barrier covering the current contents has just been issued.
|
||||
void on_sample_park_synced();
|
||||
// Drops the park. Any path that moves the layout out from under it lands here.
|
||||
void clear_sample_park();
|
||||
};
|
||||
|
||||
static inline vk::render_target* as_rtt(vk::image* t)
|
||||
@@ -507,72 +439,13 @@ namespace vk
|
||||
// Special case barrier
|
||||
surface->memory_barrier(cmd, rsx::surface_access::gpu_reference);
|
||||
|
||||
// A surface in a feedback loop is dragged back and forth for no reason. The loop
|
||||
// parks it in GENERAL (or ATTACHMENT_FEEDBACK_LOOP_OPTIMAL), this snapped it back to
|
||||
// COLOR_/DEPTH_STENCIL_ATTACHMENT_OPTIMAL on the next bind, and the next draw's
|
||||
// texture barrier moved it straight back. Both halves of that round trip are layout
|
||||
// changes, and a layout change cannot happen inside a render pass, so each one ends
|
||||
// the pass and forces a tile store plus a full reload.
|
||||
//
|
||||
// It shows up three times in the same profile of Arkham City, which draws 93 to 113
|
||||
// render passes a frame for 6 logical ones:
|
||||
// - change_image_layout from bind_surface_address<depth> ends 34.6 passes a frame,
|
||||
// and <color> another 11.0. This is that snap-back.
|
||||
// - the return trip in texture_barrier is a second layout change. Android asks it
|
||||
// to keep the pass open, which was recorded as an in-pass layout change and is
|
||||
// undefined; with the surface already parked the barrier no longer moves the
|
||||
// layout and becomes legal.
|
||||
// - the layout is part of the render pass key, so every flip also invalidates the
|
||||
// cached render pass and costs a restart at the draw site (24.3 a frame).
|
||||
//
|
||||
// Both parking layouts are legal for an attachment as well as for sampling, and
|
||||
// VKRenderPass.cpp's key encoder already accepts both, so the surface can simply be
|
||||
// left where it is. This is not suppressing a required pass break: there is no
|
||||
// transition left to require one.
|
||||
//
|
||||
// The park has to outlive reset_surface_counters() below, which is why it is a
|
||||
// separate window on the surface rather than a read of m_cyclic_ref_tracker. Keyed
|
||||
// off the tracker it survived exactly one bind, which bought nothing: the round trip
|
||||
// moved from this site to the barrier site and the frame's pass count did not move.
|
||||
//
|
||||
// Note what this site is and is not. Declining the change_layout here is what leaves
|
||||
// the previous draw's render pass open into the rest of prepare_rtts, so a teardown
|
||||
// that used to be charged to this site is now charged to whichever site first finds
|
||||
// a mismatch - usually the draw call. That relocation is not a cost; the win is only
|
||||
// real where the surface is still parked on the next bind and there is no round trip
|
||||
// to pay for at all, which is what the frames-not-binds window is for.
|
||||
//
|
||||
// Android only. This is where the tile traffic was measured, and a desktop GPU pays
|
||||
// for GENERAL attachments (lost depth/colour compression) without the tile store to
|
||||
// win back.
|
||||
//
|
||||
// Two independent parks meet here, and the second is the one this iteration adds.
|
||||
// The feedback park covers a surface written and sampled by the same draw, which is
|
||||
// rare; the sample park covers a surface sampled while unbound and then rendered to
|
||||
// again, which is the ordinary render-to-texture cycle and is where the volume is.
|
||||
// Either one may hold, and both leave the surface in a layout that is legal as an
|
||||
// attachment and encodable in the render pass key.
|
||||
//
|
||||
// Order matters only for the side effects: try_park_in_feedback_loop clears its own
|
||||
// deadline when the layout has moved, so it must still run even when the sample park
|
||||
// would answer first. || would short-circuit it, so both are evaluated.
|
||||
bool keep_current_layout = false;
|
||||
#ifdef __ANDROID__
|
||||
const bool feedback_parked = surface->try_park_in_feedback_loop(cmd);
|
||||
const bool sample_parked = surface->try_park_for_sampling();
|
||||
keep_current_layout = feedback_parked || sample_parked;
|
||||
#endif
|
||||
|
||||
if (!keep_current_layout)
|
||||
if (surface->aspect() == VK_IMAGE_ASPECT_COLOR_BIT)
|
||||
{
|
||||
if (surface->aspect() == VK_IMAGE_ASPECT_COLOR_BIT)
|
||||
{
|
||||
surface->change_layout(cmd, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL);
|
||||
}
|
||||
else
|
||||
{
|
||||
surface->change_layout(cmd, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL);
|
||||
}
|
||||
surface->change_layout(cmd, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL);
|
||||
}
|
||||
else
|
||||
{
|
||||
surface->change_layout(cmd, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL);
|
||||
}
|
||||
|
||||
surface->reset_surface_counters();
|
||||
|
||||
@@ -214,195 +214,8 @@ namespace
|
||||
};
|
||||
}
|
||||
|
||||
#ifdef __ANDROID__
|
||||
|
||||
namespace
|
||||
{
|
||||
// Never withhold more than this fraction of the attribute ring from the allocator.
|
||||
constexpr u32 VTX_RETAIN_MAX_SHIFT = 1; // <= half the ring
|
||||
|
||||
// Free space kept available, as a multiple of the recent per-frame peak. Two frames can be in
|
||||
// flight at once (see advance_queued_frames), so anything under 2 would force the ring to grow;
|
||||
// 3 leaves a frame of slack on top of that.
|
||||
constexpr u64 VTX_RETAIN_HEADROOM_FRAMES = 3;
|
||||
|
||||
// Hard ceiling on how long an entry may live regardless of how much room the ring has.
|
||||
// find_vertex_range only fingerprints the first 8 bytes of the guest source, so geometry that
|
||||
// mutates without touching its first 8 bytes reads as unchanged. That was near-harmless when
|
||||
// entries lived for one frame; capping the age bounds how long such an entry can be wrong.
|
||||
constexpr u32 VTX_RETAIN_MAX_FRAMES = 8;
|
||||
}
|
||||
|
||||
// Bring the monotonic ring cursor up to date, and notice if the heap was thrown away underneath us.
|
||||
//
|
||||
// m_attrib_ring_info is the only consumer of this ring (upload_vertex_data holds the sole alloc
|
||||
// sites), so sampling PUT here accounts for every byte it hands out. The cursor is what makes a
|
||||
// cached offset_in_heap checkable at all: the ring wraps, so an offset on its own cannot say
|
||||
// whether it still names the data that was written there or the data that lapped over it.
|
||||
void VKGSRender::vertex_cache_sample_heap()
|
||||
{
|
||||
const VkBuffer handle = m_attrib_ring_info.heap ? m_attrib_ring_info.heap->value : VK_NULL_HANDLE;
|
||||
|
||||
if (handle != m_vtx_heap_handle)
|
||||
{
|
||||
vertex_cache_on_heap_reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const usz heap_size = m_attrib_ring_info.size();
|
||||
if (!heap_size)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// get_current_put_pos_minus_one() reports PUT - 1, wrapping to size - 1 when PUT is zero.
|
||||
const usz put = (m_attrib_ring_info.get_current_put_pos_minus_one() + 1) % heap_size;
|
||||
|
||||
// Sampled at the head of every upload and once per frame, so at most one draw's worth of
|
||||
// allocation separates two samples and the delta can never hide a full lap.
|
||||
m_vtx_heap_cursor += (put >= m_vtx_heap_put)
|
||||
? (put - m_vtx_heap_put)
|
||||
: (heap_size - m_vtx_heap_put + put);
|
||||
|
||||
m_vtx_heap_put = put;
|
||||
}
|
||||
|
||||
// vk::data_heap::grow() disposes the old VkBuffer and creates a new one without copying anything
|
||||
// across, and re-inits the ring to PUT=0. Every offset the cache is holding names memory in a
|
||||
// buffer that no longer exists, so nothing survives this.
|
||||
void VKGSRender::vertex_cache_on_heap_reset()
|
||||
{
|
||||
m_vertex_cache->purge();
|
||||
|
||||
if (m_vtx_reserve_bytes)
|
||||
{
|
||||
// The ring grew while we were holding part of it back, which means the reservation was
|
||||
// sized too optimistically for this title. Growing the attribute ring on this platform is
|
||||
// expensive and has run away before (see the note in flush_command_queue), so stop
|
||||
// reserving for the rest of the session rather than risk a second one.
|
||||
rsx_log.notice("Vertex cache retention disabled: attribute ring was reallocated while %uK was reserved.",
|
||||
static_cast<u32>(m_vtx_reserve_bytes / 1024));
|
||||
m_vtx_retention_locked_out = true;
|
||||
}
|
||||
|
||||
m_vtx_heap_handle = m_attrib_ring_info.heap ? m_attrib_ring_info.heap->value : VK_NULL_HANDLE;
|
||||
m_vtx_heap_cursor = 0;
|
||||
m_vtx_heap_put = 0;
|
||||
m_vtx_retain_floor = 0;
|
||||
m_vtx_frame_base = 0;
|
||||
m_vtx_frame_peak = 0;
|
||||
m_vtx_peak_ttl = 0;
|
||||
m_vtx_reserve_bytes = 0;
|
||||
m_vtx_frame_index = 0;
|
||||
std::fill(std::begin(m_vtx_frame_marks), std::end(m_vtx_frame_marks), 0ull);
|
||||
|
||||
m_vertex_cache->set_epoch(0);
|
||||
}
|
||||
|
||||
// Decide how much of the ring to withhold from the allocator, then evict everything the withheld
|
||||
// window does not cover. Called where the unconditional purge() used to be, at frame end.
|
||||
//
|
||||
// Why any of this exists: Arkham City reports 10 vertex cache hits out of 1386 requests, so 2499us
|
||||
// of vertex upload and 1.35M vertices in a single pass are re-uploaded and re-binned every frame
|
||||
// even though the geometry is unchanged. The cache was purged every frame because offset_in_heap
|
||||
// points into a ring, and a ring recycles.
|
||||
//
|
||||
// The guarantee: the allocator already refuses to allocate across GET (rsx::data_heap::can_alloc),
|
||||
// which is how in-flight frames are protected from being overwritten. Publishing GET as the
|
||||
// retention floor rather than the frame's PUT puts retained entries inside that same protected
|
||||
// window, so the ring physically cannot lap onto them - it grows instead. The floor only ever moves
|
||||
// forward, and it moves at exactly the moment the matching entries are evicted, so a live entry is
|
||||
// never below GET.
|
||||
void VKGSRender::vertex_cache_on_frame_end()
|
||||
{
|
||||
vertex_cache_sample_heap();
|
||||
|
||||
const u64 heap_size = m_attrib_ring_info.size();
|
||||
const u64 consumed = m_vtx_heap_cursor - m_vtx_frame_base;
|
||||
|
||||
m_vtx_frame_base = m_vtx_heap_cursor;
|
||||
m_vtx_frame_marks[m_vtx_frame_index % VTX_RETAIN_MAX_FRAMES] = m_vtx_heap_cursor;
|
||||
m_vtx_frame_index++;
|
||||
|
||||
// Hold the peak for a short window so one quiet frame cannot shrink the margin right before a
|
||||
// busy one lands on it.
|
||||
if (consumed >= m_vtx_frame_peak || !m_vtx_peak_ttl)
|
||||
{
|
||||
m_vtx_frame_peak = consumed;
|
||||
m_vtx_peak_ttl = VTX_RETAIN_MAX_FRAMES;
|
||||
}
|
||||
else
|
||||
{
|
||||
m_vtx_peak_ttl--;
|
||||
}
|
||||
|
||||
u64 reserve = 0;
|
||||
|
||||
// Nothing to retain when the cache is a null_vertex_cache, and reserving part of the ring for
|
||||
// it would shrink the allocator's working set for no reason at all.
|
||||
const bool cache_active = !g_cfg.video.disable_vertex_cache;
|
||||
|
||||
if (cache_active && !m_vtx_retention_locked_out && m_vtx_frame_peak && heap_size)
|
||||
{
|
||||
const u64 headroom = VTX_RETAIN_HEADROOM_FRAMES * m_vtx_frame_peak;
|
||||
|
||||
// Retention is only worth anything if what is left over can hold at least one frame of
|
||||
// geometry; below that every entry dies before it can be hit and the ring would just be
|
||||
// smaller for nothing. That threshold is heap_size >= (headroom + 1 frame).
|
||||
if (heap_size >= headroom + m_vtx_frame_peak)
|
||||
{
|
||||
reserve = std::min<u64>(heap_size >> VTX_RETAIN_MAX_SHIFT, heap_size - headroom);
|
||||
}
|
||||
}
|
||||
|
||||
// Say when this engages or drops out, and on what measurement. Otherwise a hit rate that stays
|
||||
// at zero cannot be told apart from a reservation that was never sized above zero to begin
|
||||
// with, which is the whole question when reading the counters back off a device.
|
||||
if (!!reserve != !!m_vtx_reserve_bytes)
|
||||
{
|
||||
rsx_log.notice("Vertex cache retention %s (reserve=%uK, peak frame=%uK, ring=%uM).",
|
||||
reserve ? "engaged" : "disengaged",
|
||||
static_cast<u32>(reserve / 1024),
|
||||
static_cast<u32>(m_vtx_frame_peak / 1024),
|
||||
static_cast<u32>(heap_size / 0x100000));
|
||||
}
|
||||
|
||||
if (!reserve)
|
||||
{
|
||||
// No room to promise anything. Behave exactly as this call site did before.
|
||||
m_vtx_reserve_bytes = 0;
|
||||
m_vtx_retain_floor = m_vtx_heap_cursor;
|
||||
m_vertex_cache->purge();
|
||||
return;
|
||||
}
|
||||
|
||||
m_vtx_reserve_bytes = reserve;
|
||||
|
||||
u64 floor = (m_vtx_heap_cursor > reserve) ? (m_vtx_heap_cursor - reserve) : 0;
|
||||
|
||||
// Apply the age cap on top of the byte budget, taking whichever is stricter.
|
||||
if (m_vtx_frame_index > VTX_RETAIN_MAX_FRAMES)
|
||||
{
|
||||
floor = std::max(floor, m_vtx_frame_marks[m_vtx_frame_index % VTX_RETAIN_MAX_FRAMES]);
|
||||
}
|
||||
|
||||
m_vtx_retain_floor = floor;
|
||||
m_vertex_cache->evict_before(floor);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
vk::vertex_upload_info VKGSRender::upload_vertex_data()
|
||||
{
|
||||
#ifdef __ANDROID__
|
||||
// Before the lookup below: a heap reallocation since the last draw invalidates every entry.
|
||||
vertex_cache_sample_heap();
|
||||
|
||||
// Stamp entries with the cursor as of the start of this call, which is at or below where their
|
||||
// own bytes actually land. Erring low can only retire an entry early, never late.
|
||||
m_vertex_cache->set_epoch(m_vtx_heap_cursor);
|
||||
#endif
|
||||
|
||||
draw_command_visitor visitor(m_index_buffer_ring_info, m_vertex_layout);
|
||||
auto result = std::visit(visitor, m_draw_processor.get_draw_command(rsx::method_registers));
|
||||
|
||||
@@ -421,17 +234,12 @@ vk::vertex_upload_info VKGSRender::upload_vertex_data()
|
||||
u32 persistent_range_base = -1, volatile_range_base = -1;
|
||||
usz persistent_offset = -1, volatile_offset = -1;
|
||||
|
||||
// Hoisted out of the block below so the allocations that follow can retract a cache hit if they
|
||||
// end up reallocating the heap the hit points into.
|
||||
bool in_cache = false;
|
||||
|
||||
if (required.first > 0)
|
||||
{
|
||||
//Check if cacheable
|
||||
//Only data in the 'persistent' block may be cached
|
||||
//TODO: hook the notify command. Entries now outlive the frame they were written in (see
|
||||
//vertex_cache_on_frame_end), but find_vertex_range still detects guest-side edits only by
|
||||
//fingerprinting the first 8 bytes of the source, not by locking the memory range.
|
||||
//TODO: make vertex cache keep local data beyond frame boundaries and hook notify command
|
||||
bool in_cache = false;
|
||||
bool to_store = false;
|
||||
u32 storage_address = -1;
|
||||
|
||||
@@ -477,23 +285,6 @@ vk::vertex_upload_info VKGSRender::upload_vertex_data()
|
||||
volatile_range_base = static_cast<u32>(volatile_offset);
|
||||
}
|
||||
|
||||
#ifdef __ANDROID__
|
||||
// An allocation above may have grown the heap, which replaces the VkBuffer outright without
|
||||
// copying the old contents over. Offsets returned by alloc() are already relative to the new
|
||||
// buffer, but one taken from the cache before that point is not: it names a buffer that has
|
||||
// just been disposed. Retract the hit and upload the data normally.
|
||||
if (in_cache && m_attrib_ring_info.heap && m_attrib_ring_info.heap->value != m_vtx_heap_handle)
|
||||
{
|
||||
vertex_cache_on_heap_reset();
|
||||
|
||||
in_cache = false;
|
||||
m_frame_stats.vertex_cache_miss_count++;
|
||||
|
||||
persistent_offset = static_cast<u32>(m_attrib_ring_info.alloc<256>(required.first));
|
||||
persistent_range_base = static_cast<u32>(persistent_offset);
|
||||
}
|
||||
#endif
|
||||
|
||||
//Write all the data once if possible
|
||||
if (required.first && required.second && volatile_offset > persistent_offset)
|
||||
{
|
||||
|
||||
@@ -96,7 +96,6 @@ extern "C"
|
||||
PFN_vkDestroyInstance vkDestroyInstance = nullptr;
|
||||
PFN_vkDestroyPipeline vkDestroyPipeline = nullptr;
|
||||
PFN_vkDestroyPipelineCache vkDestroyPipelineCache = nullptr;
|
||||
PFN_vkGetPipelineCacheData vkGetPipelineCacheData = nullptr;
|
||||
PFN_vkDestroyPipelineLayout vkDestroyPipelineLayout = nullptr;
|
||||
PFN_vkDestroyQueryPool vkDestroyQueryPool = nullptr;
|
||||
PFN_vkDestroyRenderPass vkDestroyRenderPass = nullptr;
|
||||
@@ -340,8 +339,6 @@ namespace vk::android
|
||||
if (!vkDestroyPipeline) vkDestroyPipeline = reinterpret_cast<PFN_vkDestroyPipeline>(dlsym(handle, "vkDestroyPipeline"));
|
||||
vkDestroyPipelineCache = reinterpret_cast<PFN_vkDestroyPipelineCache>(vkGetInstanceProcAddr(nullptr, "vkDestroyPipelineCache"));
|
||||
if (!vkDestroyPipelineCache) vkDestroyPipelineCache = reinterpret_cast<PFN_vkDestroyPipelineCache>(dlsym(handle, "vkDestroyPipelineCache"));
|
||||
vkGetPipelineCacheData = reinterpret_cast<PFN_vkGetPipelineCacheData>(vkGetInstanceProcAddr(nullptr, "vkGetPipelineCacheData"));
|
||||
if (!vkGetPipelineCacheData) vkGetPipelineCacheData = reinterpret_cast<PFN_vkGetPipelineCacheData>(dlsym(handle, "vkGetPipelineCacheData"));
|
||||
vkDestroyPipelineLayout = reinterpret_cast<PFN_vkDestroyPipelineLayout>(vkGetInstanceProcAddr(nullptr, "vkDestroyPipelineLayout"));
|
||||
if (!vkDestroyPipelineLayout) vkDestroyPipelineLayout = reinterpret_cast<PFN_vkDestroyPipelineLayout>(dlsym(handle, "vkDestroyPipelineLayout"));
|
||||
vkDestroyQueryPool = reinterpret_cast<PFN_vkDestroyQueryPool>(vkGetInstanceProcAddr(nullptr, "vkDestroyQueryPool"));
|
||||
@@ -535,7 +532,6 @@ namespace vk::android
|
||||
if (auto p = vkGetInstanceProcAddr(instance, "vkDestroyInstance")) vkDestroyInstance = reinterpret_cast<PFN_vkDestroyInstance>(p);
|
||||
if (auto p = vkGetInstanceProcAddr(instance, "vkDestroyPipeline")) vkDestroyPipeline = reinterpret_cast<PFN_vkDestroyPipeline>(p);
|
||||
if (auto p = vkGetInstanceProcAddr(instance, "vkDestroyPipelineCache")) vkDestroyPipelineCache = reinterpret_cast<PFN_vkDestroyPipelineCache>(p);
|
||||
if (auto p = vkGetInstanceProcAddr(instance, "vkGetPipelineCacheData")) vkGetPipelineCacheData = reinterpret_cast<PFN_vkGetPipelineCacheData>(p);
|
||||
if (auto p = vkGetInstanceProcAddr(instance, "vkDestroyPipelineLayout")) vkDestroyPipelineLayout = reinterpret_cast<PFN_vkDestroyPipelineLayout>(p);
|
||||
if (auto p = vkGetInstanceProcAddr(instance, "vkDestroyQueryPool")) vkDestroyQueryPool = reinterpret_cast<PFN_vkDestroyQueryPool>(p);
|
||||
if (auto p = vkGetInstanceProcAddr(instance, "vkDestroyRenderPass")) vkDestroyRenderPass = reinterpret_cast<PFN_vkDestroyRenderPass>(p);
|
||||
|
||||
@@ -117,7 +117,6 @@ extern "C"
|
||||
extern PFN_vkDestroyInstance vkDestroyInstance;
|
||||
extern PFN_vkDestroyPipeline vkDestroyPipeline;
|
||||
extern PFN_vkDestroyPipelineCache vkDestroyPipelineCache;
|
||||
extern PFN_vkGetPipelineCacheData vkGetPipelineCacheData;
|
||||
extern PFN_vkDestroyPipelineLayout vkDestroyPipelineLayout;
|
||||
extern PFN_vkDestroyQueryPool vkDestroyQueryPool;
|
||||
extern PFN_vkDestroyRenderPass vkDestroyRenderPass;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user