Port pipeline cache repairs and precise late-frame recovery from elliotttate

This commit is contained in:
Jonathan Tate committed 2026-10-07 07:05:22 -04:00
1 parent 4b9479a086
commit d4e4e00192
10 files changed
+356 -17

No files matched your search

+7
View File
@@ -78,6 +78,12 @@ or SteamVR motion smoothing.
- Upstream now provides the SteamVR address-space reservation and runtime-specific idle
recovery behavior; those implementations are retained instead of duplicate fork patches.
### Selected elliotttate performance port (candidate)
This candidate adds pipeline-cache preload repairs and bounded late-frame recovery. See
[the port notes](docs/elliotttate-performance-port.md) for scope, attribution and limitations.
Set `env=SHADPS4_XR_LATE_WAIT_MS=0` in settings.txt to disable late-frame waiting.
### Precise VR presentation pacing (experimental)
VR early-flip presentation now uses a reusable Windows high-resolution waitable timer
@@ -838,3 +844,4 @@ bash tools/make-pc-vr.sh # cop
The OpenXR loader (Khronos OpenXR-SDK 1.1.63, `externals/openxr-sdk`) is built with the
emulator; `ENABLE_OPENXR` (on for Windows) switches the whole of it.
+25
View File
@@ -0,0 +1,25 @@
# Selected elliotttate performance port
Reviewed on 2026-10-07: [elliotttate/AstroQuest at b72db1b](https://github.com/elliotttate/AstroQuest/tree/b72db1b).
[Their measurements and limitations](https://github.com/elliotttate/AstroQuest/blob/b72db1b/docs/pc-performance.md).
Adapted pipeline-cache repairs and late-frame recovery from `6af3210`, preserving the original license headers. This candidate retains upstream 0.20, the soccer-enemy fix, Full framerate selector, expanded resolutions, separate headset queue and precise presentation timer.
## Included
Cached image/sampler lists now contain their storage, so a new process does not load pointers from the process that wrote the cache. Ordinary descriptor layouts avoid reading guest descriptors during preloading; dynamic-mip layouts requiring guest memory are deferred. Vertex-fetch data survives later shader stages, and shader permutations point at the persistent cached program's information.
Metadata and pipeline-key versions are incremented to reject the former layout. Upstream 0.20's shader-binary version 3 (geometry-input ABI fix) is preserved. Separate cold/warm processes test cache reuse before enabling it in the installed configuration.
When a recent stream's next frame is missing, the OpenXR thread waits briefly before repeating the last picture. The budget starts at xrWaitFrame wakeup, defaults to 3 ms, and is configurable with `SHADPS4_XR_LATE_WAIT_MS` (0 disables, maximum 6). Idle streams and pictures the runtime does not want are skipped. No Vulkan queue lock is held. The log counts recovered pictures and timeouts.
The initial condition-variable port's nominal 3-ms timeout took 15-16 ms on this PC. The Windows adaptation uses a producer event and a high-resolution timeout timer; measured timeout was about 3.5 ms. Arriving frames can wake it immediately. Unsupported precise timers disable waiting instead of using a coarse timeout. This is a scheduling budget, not a hard real-time guarantee.
## Kept separate
- Their native 90/120 engine mode is promising, but their 120 measurements use a simulator and game-1.00 offsets. The 90 mode is unmeasured and 1.04 offsets are unverified. It is not enabled here.
- Their guest HR-timer scheduler addresses the game's 3-ms tick, with broader cancellation/lifetime implications than the presentation timer already in this fork.
- Parallel texture copies, stream/shader memoization and lock-skipping are not imported wholesale. Their notes document unresolved memory corruption near the parallel-copy/shader-memo work; no causal fix is established.
- Their 32 presentation polls and high-resolution sleeps overlap with this fork. Their new delivery phase was measured at simulated 120 Hz; this fork retains its phase until a real-runtime comparison.
Startup checks do not establish improved headset gameplay. The existing regular release remains available for comparison.
@@ -13,6 +13,7 @@ public:
timer = CreateWaitableTimerExW(nullptr, nullptr,
high_resolution ? CREATE_WAITABLE_TIMER_HIGH_RESOLUTION : 0,
TIMER_MODIFY_STATE | SYNCHRONIZE);
precise = timer && high_resolution;
if (!timer && high_resolution) {
timer = CreateWaitableTimerExW(nullptr, nullptr, 0, TIMER_MODIFY_STATE | SYNCHRONIZE);
}
@@ -26,16 +27,27 @@ public:
DWORD Wait(std::chrono::nanoseconds duration, bool interruptible) {
if (duration.count() <= 0)
return WAIT_OBJECT_0;
if (!timer)
return WAIT_FAILED;
LARGE_INTEGER interval;
interval.QuadPart = -std::max<long long>(1, duration.count() / 100);
if (!SetWaitableTimer(timer, &interval, 0, nullptr, nullptr, FALSE))
if (!Arm(duration))
return WAIT_FAILED;
return WaitForSingleObjectEx(timer, INFINITE, interruptible);
}
bool Arm(std::chrono::nanoseconds duration) {
if (!timer)
return false;
LARGE_INTEGER interval;
interval.QuadPart = -std::max<long long>(1, duration.count() / 100);
return SetWaitableTimer(timer, &interval, 0, nullptr, nullptr, FALSE) != FALSE;
}
HANDLE Handle() const {
return timer;
}
bool IsHighResolution() const {
return precise;
}
private:
HANDLE timer{};
bool precise{};
};
} // namespace Common
@@ -0,0 +1,98 @@
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <chrono>
#include <condition_variable>
#include <mutex>
#ifdef _WIN32
#include "common/windows_sleep_timer.h"
#endif
namespace Core::Vr {
// Producer notification wakes immediately; the Windows timeout uses its own high-
// resolution timer instead of the coarse timeout of SleepConditionVariableSRW.
class LateFrameSignal {
public:
#ifdef _WIN32
LateFrameSignal() : event{CreateEventW(nullptr, FALSE, FALSE, nullptr)} {}
~LateFrameSignal() {
if (event)
CloseHandle(event);
}
void Notify() {
if (event)
SetEvent(event);
}
#else
void Notify() {
delivered.notify_one();
}
#endif
LateFrameSignal(const LateFrameSignal&) = delete;
LateFrameSignal& operator=(const LateFrameSignal&) = delete;
#ifndef _WIN32
LateFrameSignal() = default;
#endif
template <typename Ready>
bool WaitUntil(std::unique_lock<std::mutex>& lock, std::chrono::steady_clock::time_point until,
Ready ready) {
#ifdef _WIN32
while (!ready()) {
const auto remaining = until - std::chrono::steady_clock::now();
// Unsupported timers or allocation failure disable waiting, rather than use
// a coarse timeout that can consume a whole headset refresh.
if (remaining.count() <= 0 || !event || !timer.IsHighResolution())
return false;
// Producer updates readiness and notifies under the same mutex. Resetting
// before unlocking cannot lose a delivery between the predicate and wait.
ResetEvent(event);
if (!timer.Arm(std::chrono::duration_cast<std::chrono::nanoseconds>(remaining)))
return false;
const HANDLE handles[]{event, timer.Handle()};
lock.unlock();
const DWORD result = WaitForMultipleObjects(2, handles, FALSE, INFINITE);
lock.lock();
if (ready())
return true;
if (result != WAIT_OBJECT_0)
return false;
}
return true;
#else
return delivered.wait_until(lock, until, ready);
#endif
}
private:
#ifdef _WIN32
HANDLE event{};
Common::WindowsSleepTimer timer;
#else
std::condition_variable delivered;
#endif
};
enum class LateFrameResult { Skipped, Ready, TimedOut };
// Budget begins at the runtime wakeup. The predicate is always checked first so a
// delivery between the initial TakeFrame and taking the mutex is not lost.
template <typename Ready>
LateFrameResult WaitForLateFrame(LateFrameSignal& delivered, std::unique_lock<std::mutex>& lock,
std::chrono::steady_clock::time_point woke,
std::chrono::steady_clock::time_point last_delivery, float rate,
float wait_ms, Ready ready) {
using Clock = std::chrono::steady_clock;
if (ready())
return LateFrameResult::Ready;
if (!(wait_ms > 0.0f && rate > 30.0f) || last_delivery == Clock::time_point{} ||
woke - last_delivery >= std::chrono::duration<double>(2.0 / rate)) {
return LateFrameResult::Skipped;
}
const auto until = woke + std::chrono::duration_cast<Clock::duration>(
std::chrono::duration<double, std::milli>(wait_ms));
if (Clock::now() >= until)
return LateFrameResult::TimedOut;
return delivered.WaitUntil(lock, until, ready) ? LateFrameResult::Ready
: LateFrameResult::TimedOut;
}
} // namespace Core::Vr
+29 -1
View File
@@ -5,6 +5,7 @@
#include <array>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cmath>
#include <cstdio>
#include <cstdlib>
@@ -22,6 +23,7 @@
#include <SDL3/SDL_events.h>
#include "common/logging/log.h"
#include "core/vr/late_frame_wait.h"
#include "common/polyfill_thread.h"
#include "common/singleton.h"
#include "common/thread.h"
@@ -391,6 +393,7 @@ struct OpenXrHost::Impl {
// The title's frames on their way here.
std::mutex slot_mutex;
LateFrameSignal delivered_cv;
std::array<Slot, NumSlots> slots;
s32 latest{-1};
u32 next_slot{};
@@ -478,6 +481,8 @@ struct OpenXrHost::Impl {
u32 display_frames{};
u32 reported_delivered{};
u32 copied_frames{};
u32 late_frames{};
u32 late_wait_timeouts{};
u32 head_tracked_frames{};
u32 palm_samples{};
float palm_distance{};
@@ -1753,7 +1758,25 @@ struct OpenXrHost::Impl {
AskAudioDevicesIfDue();
UpdatePad(pose_time);
if (const s32 index = TakeFrame(); index >= 0) {
s32 taken = TakeFrame();
// From elliotttate/AstroQuest: a frame a little late need not become a repeat.
// Budget is measured from xrWaitFrame wakeup, not from the start of this wait,
// and no Vulkan queue lock is held while the producer is allowed to finish.
static const float late_wait_ms =
std::clamp(EnvFloat("SHADPS4_XR_LATE_WAIT_MS", 3.0f), 0.0f, 6.0f);
if (taken < 0 && late_wait_ms > 0.0f && have_frame &&
frame_state.shouldRender == XR_TRUE && refresh_rate > 30.0f) {
std::unique_lock lock{slot_mutex};
const auto result = WaitForLateFrame(delivered_cv, lock, woke, last_delivery,
refresh_rate, late_wait_ms, [this] {
return latest >= 0 && slots[latest].state == Slot::State::Ready;
});
lock.unlock();
taken = TakeFrame();
if (taken >= 0) ++late_frames;
else if (result == LateFrameResult::TimedOut) ++late_wait_timeouts;
}
if (const s32 index = taken; index >= 0) {
const auto started = Clock::now();
bool blank = false;
const bool copied = CopyFrame(static_cast<u32>(index), blank);
@@ -2466,6 +2489,10 @@ struct OpenXrHost::Impl {
"({:.2f} ms at worst)",
graphics.queue_index, end_lock_time / frames * 1e3,
end_call_time / frames * 1e3, end_call_worst * 1e3);
LOG_INFO(Core_Vr, "Late-frame recovery: {} pictures recovered, {} waits timed out",
late_frames, late_wait_timeouts);
late_frames = 0;
late_wait_timeouts = 0;
if ((has_hand_tracking || (use_grips && actions_ready)) && !controllers_used) {
const float held = pad_samples != 0 ? 1.0f / static_cast<float>(pad_samples) : 0.0f;
LOG_INFO(Core_Vr,
@@ -2841,6 +2868,7 @@ void OpenXrHost::EndFrame(u32 index, const PresentedFrame& info, vk::Semaphore r
}
}
impl->last_delivery = now;
impl->delivered_cv.Notify();
}
void OpenXrHost::DropFrame(u32 index) {
@@ -125,13 +125,20 @@ struct ImageResource {
}
u32 NumBindings(const auto& info) const {
// Only an image whose mips are indexed in the shader needs its sharp for this: a
// pipeline made from the cache as the emulator starts has no sharps to read (the
// title's memory holds nothing yet).
if (mip_fallback_mode != MipStorageFallbackMode::DynamicIndex) {
return 1;
}
const AmdGpu::Image tsharp = GetSharp(info);
return (mip_fallback_mode == MipStorageFallbackMode::DynamicIndex)
? (tsharp.last_level - tsharp.base_level + 1)
: 1;
return tsharp.last_level - tsharp.base_level + 1;
}
};
using ImageResourceList = boost::container::small_vector<ImageResource, NUM_IMAGES>;
// (Fixed capacity, like the other lists: the pipeline cache stores Shader::InfoPersistent as its
// bytes, and a small_vector's bytes hold a pointer to its elements, which pointed into the
// process that wrote the cache.)
using ImageResourceList = boost::container::static_vector<ImageResource, NUM_IMAGES>;
struct SamplerResource {
u32 sharp_idx;
@@ -145,7 +152,7 @@ struct SamplerResource {
: info.template ReadUdSharp<AmdGpu::Sampler>(sharp_idx);
}
};
using SamplerResourceList = boost::container::small_vector<SamplerResource, NUM_SAMPLERS>;
using SamplerResourceList = boost::container::static_vector<SamplerResource, NUM_SAMPLERS>;
struct FMaskResource {
u32 sharp_idx;
@@ -136,6 +136,8 @@ private:
std::array<const Shader::Info*, MaxShaderStages> infos{};
std::array<vk::ShaderModule, MaxShaderStages> modules{};
std::optional<Shader::Gcn::FetchShaderData> fetch_shader{};
// Preloading must retain vertex fetch data across later shader stages.
std::optional<Shader::Gcn::FetchShaderData> preload_fetch_shader{};
GraphicsPipelineKey graphics_key{};
u8 targets_left_out{};
ComputePipelineKey compute_key{};
@@ -14,10 +14,11 @@
namespace Serialization {
/* You should increment versions below once corresponding serialization scheme is changed. */
// Recompile cached shaders with the corrected geometry input ABI and invocation ID.
// Preserve upstream 0.20 geometry-input ABI and invocation-ID correction.
static constexpr u32 ShaderBinaryVersion = 3u;
static constexpr u32 ShaderMetaVersion = 2u;
static constexpr u32 PipelineKeyVersion = 2u;
// Inline image/sampler lists replace pointer-containing small vectors.
static constexpr u32 ShaderMetaVersion = 3u;
static constexpr u32 PipelineKeyVersion = 3u;
} // namespace Serialization
namespace Vulkan {
@@ -143,6 +144,16 @@ bool ComputePipeline::SerializationSupport::Deserialize(Serialization::Archive&
return true;
}
// A pipeline whose descriptor layout depends on the title's image sharps (an image whose mips
// the shader indexes: a binding for each) cannot be made before the title runs: it is not
// preloaded but compiled when the title first uses it. (Reading the sharps as the emulator
// starts read the title's memory before there was any.)
static bool UsesSharpsToLayOut(const Shader::Info& info) {
return std::ranges::any_of(info.images, [](const Shader::ImageResource& image) {
return image.mip_fallback_mode == Shader::MipStorageFallbackMode::DynamicIndex;
});
}
bool PipelineCache::LoadComputePipeline(Serialization::Archive& ar) {
compute_key.Deserialize(ar);
@@ -161,6 +172,11 @@ bool PipelineCache::LoadComputePipeline(Serialization::Archive& ar) {
if (!LoadPipelineStage(meta_ar, 0)) {
return false;
}
if (UsesSharpsToLayOut(*infos[0])) {
infos.fill(nullptr);
modules.fill(nullptr);
return false;
}
const auto [it, is_new] = compute_pipelines.try_emplace(compute_key);
ASSERT(is_new);
@@ -212,6 +228,7 @@ bool GraphicsPipeline::SerializationSupport::Deserialize(Serialization::Archive&
}
bool PipelineCache::LoadGraphicsPipeline(Serialization::Archive& ar) {
preload_fetch_shader.reset();
graphics_key.Deserialize(ar);
GraphicsPipeline::SerializationSupport sdata{};
@@ -233,20 +250,44 @@ bool PipelineCache::LoadGraphicsPipeline(Serialization::Archive& ar) {
Serialization::Archive meta_ar{std::move(meta_blob)};
if (!LoadPipelineStage(meta_ar, stage_idx)) {
infos.fill(nullptr);
modules.fill(nullptr);
preload_fetch_shader.reset();
return false;
}
}
if (const auto* vs_info = infos[u32(Shader::LogicalStage::Vertex)];
vs_info && vs_info->has_fetch_shader && !preload_fetch_shader) {
// A pipeline that would draw without its vertex input is not preloaded: it is compiled
// when the title first draws with it, from the fetch shader it uses then.
LOG_WARNING(Render_Vulkan,
"Not preloading a pipeline: vertex shader {:#x} has a fetch shader, but the "
"cache has no fetch data for it",
vs_info->pgm_hash);
infos.fill(nullptr);
modules.fill(nullptr);
return false;
}
if (std::ranges::any_of(infos, [](const Shader::Info* info) {
return info != nullptr && UsesSharpsToLayOut(*info);
})) {
infos.fill(nullptr);
modules.fill(nullptr);
preload_fetch_shader.reset();
return false;
}
const auto [it, is_new] = graphics_pipelines.try_emplace(graphics_key);
ASSERT(is_new);
it.value() = std::make_unique<GraphicsPipeline>(
instance, scheduler, desc_heap, profile, graphics_key, *pipeline_cache, infos,
runtime_infos, fetch_shader, modules, sdata, true);
runtime_infos, preload_fetch_shader, modules, sdata, true);
infos.fill(nullptr);
modules.fill(nullptr);
fetch_shader.reset();
preload_fetch_shader.reset();
return true;
}
@@ -256,9 +297,15 @@ bool PipelineCache::LoadPipelineStage(Serialization::Archive& ar, size_t stage)
Shader::StageSpecialization spec{};
spec.info = &program->info;
size_t perm_idx{};
if (!LoadShaderMeta(ar, program->info, fetch_shader, spec, perm_idx)) {
std::optional<Shader::Gcn::FetchShaderData> stage_fetch{};
if (!LoadShaderMeta(ar, program->info, stage_fetch, spec, perm_idx)) {
return false;
}
// Only the stage that has a fetch shader (the vertex stage) sets the pipeline's fetch data,
// as at runtime. A later stage, a geometry shader above all, has none and must not clear it.
if (stage_fetch) {
preload_fetch_shader = std::move(stage_fetch);
}
std::vector<u32> spv{};
Storage::DataBase::Instance().Load(Storage::BlobType::ShaderBinary,
@@ -278,6 +325,9 @@ bool PipelineCache::LoadPipelineStage(Serialization::Archive& ar, size_t stage)
module = CompileSPV(spv, instance.GetDevice());
it_pgm.value() = std::move(program);
} else {
// The permutation joins the program already cached: its specialization must point at
// that program's info, not at the one this load made, which is gone when it returns.
spec.info = &it_pgm.value()->info;
const auto& it = std::ranges::find(it_pgm.value()->modules, spec, &Program::Module::spec);
if (it != it_pgm.value()->modules.end()) {
// If the permutation is already preloaded, make sure it has the same permutation index
+69
View File
@@ -0,0 +1,69 @@
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#include "core/vr/late_frame_wait.h"
#include <algorithm>
#include <cstdio>
#include <thread>
#include <vector>
using namespace std::chrono_literals;
using Clock = std::chrono::steady_clock;
using Core::Vr::LateFrameResult;
int main() {
int failures = 0;
std::mutex mutex;
Core::Vr::LateFrameSignal delivered;
bool ready = false;
auto run = [&](auto woke, auto last, float ms) {
std::unique_lock lock{mutex};
return Core::Vr::WaitForLateFrame(delivered, lock, woke, last, 90, ms,
[&] { return ready; });
};
auto now = Clock::now();
if (run(now, now, 0) != LateFrameResult::Skipped)
++failures;
if (run(now, now - 1s, 3) != LateFrameResult::Skipped)
++failures;
if (run(now, Clock::time_point{}, 3) != LateFrameResult::Skipped)
++failures;
ready = true;
if (run(now - 1s, now, 3) != LateFrameResult::Ready)
++failures;
ready = false;
now = Clock::now();
if (run(now - 10ms, now - 11ms, 3) != LateFrameResult::TimedOut)
++failures;
std::jthread producer{[&] {
std::this_thread::sleep_for(1ms);
std::scoped_lock lock{mutex};
ready = true;
delivered.Notify();
}};
now = Clock::now();
if (run(now, now, 30) != LateFrameResult::Ready)
++failures;
producer.join();
ready = false;
std::jthread spurious{[&] {
std::this_thread::sleep_for(1ms);
delivered.Notify();
}};
now = Clock::now();
if (run(now, now, 3) != LateFrameResult::TimedOut)
++failures;
spurious.join();
std::vector<double> waits;
for (int i = 0; i < 50; ++i) {
now = Clock::now();
if (run(now, now, 3) != LateFrameResult::TimedOut)
++failures;
waits.push_back(
std::chrono::duration<double, std::milli>(Clock::now() - now).count());
}
std::sort(waits.begin(), waits.end());
printf("3-ms timeout: median %.3f p95 %.3f max %.3f ms\n", waits[25],
waits[47], waits.back());
printf("late-frame disabled/stale/race/deadline/arrival/spurious checks: %d "
"failures\n",
failures);
return failures ? 1 : 0;
}
@@ -0,0 +1,41 @@
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#include "shader_recompiler/info.h"
#include <cstdio>
#include <cstring>
#include <memory>
#include <vector>
int main() {
auto source = std::make_unique<Shader::InfoPersistent>();
for (unsigned i = 0; i < Shader::NUM_IMAGES; ++i)
source->images.push_back(Shader::ImageResource{.sharp_idx = i + 17});
for (unsigned i = 0; i < Shader::NUM_SAMPLERS; ++i)
source->samplers.push_back(Shader::SamplerResource{.sharp_idx = i + 91});
std::vector<unsigned char> bytes(sizeof(*source));
std::memcpy(bytes.data(), source.get(), bytes.size());
auto restored = std::make_unique<Shader::InfoPersistent>();
std::memcpy(restored.get(), bytes.data(), bytes.size());
source.reset(); // Original process/object storage is gone, as on a warm cache
// launch.
int failures = 0;
auto inside = [&](const void *p) {
auto at = reinterpret_cast<uintptr_t>(p),
begin = reinterpret_cast<uintptr_t>(restored.get());
return at >= begin && at < begin + sizeof(*restored);
};
if (!inside(restored->images.data()) || !inside(restored->samplers.data()))
++failures;
if (restored->images.size() != Shader::NUM_IMAGES ||
restored->samplers.size() != Shader::NUM_SAMPLERS)
++failures;
for (unsigned i = 0; i < Shader::NUM_IMAGES; ++i)
if (restored->images[i].sharp_idx != i + 17)
++failures;
for (unsigned i = 0; i < Shader::NUM_SAMPLERS; ++i)
if (restored->samplers[i].sharp_idx != i + 91)
++failures;
printf("persistent shader image/sampler lists survive byte relocation and "
"original destruction at full capacity: %d failures\n",
failures);
return failures ? 1 : 0;
}