VK: use extended dynamic state to collapse pipeline permutations

Cull mode, front face, depth test/write/compare and primitive topology move out
of pipeline identity and into per-draw state where VK_EXT_extended_dynamic_state
is available. Fewer pipeline objects to compile and cache is worth a lot on
Adreno and Mali, where first-run compilation is a visible source of stutter.

Topology only collapses within its class -- triangle list/strip/fan share one
pipeline, lines share one, points stand alone. vkCmdSetPrimitiveTopology cannot
cross classes without dynamicPrimitiveTopologyUnrestricted, which comes from
extended_dynamic_state3 and is not something mobile drivers report. The class
representative is restart-aware: primitive restart on a *_LIST topology is
illegal without primitiveTopologyListRestart, so a restarting draw is
represented by the strip form or pipelines that build today start failing
validation.

Gated on the feature bit, not the extension string, and enabled at device
creation; without it the props keep their real values and the command stream is
byte-identical to before. Entry points go through the existing VKProcTable
wrangler, so vk_android_loader needs no regeneration.

pipeline_props keeps its shape: the disk cache stores it as a raw struct, so
the VALUES are normalized before it is used as a key rather than teaching
operator== about the extension. The shader cache directory becomes v1.96-eds
against v1.96 -- the suffix matters because support depends on the DEVICE, and
a driver can be swapped in through adrenotools between two runs of the same
game. Reading a normalized entry back without the extension would silently
build pipelines with culling off and depth compare NEVER.

Depth bounds, stencil, and the EDS2/EDS3 states stay static: depth bounds is
constant per device and never differentiated anything, and stencil is already
all-zero for the overwhelming majority of draws.
This commit is contained in:
jpolo1224
2026-08-15 01:25:01 -04:00
parent d069a55acc
commit dbbb6fbde0
7 changed files with 294 additions and 33 deletions
+46
View File
@@ -13,6 +13,11 @@
namespace vk
{
// Defined in VKGSRender.cpp, where they feed the pipeline object. The draw path needs them
// too now that the same values are issued per draw.
VkFrontFace get_front_face(rsx::front_face ffv);
VkCullModeFlags get_cull_face(rsx::cull_face cfv);
VkImageViewType get_view_type(rsx::texture_dimension_extended type)
{
switch (type)
@@ -158,6 +163,42 @@ void VKGSRender::invalidate_render_pass()
}
}
void VKGSRender::set_extended_dynamic_state()
{
// Deliberately NOT folded into update_draw_state(). That runs once per draw clause and only
// when the render pass is (re)started, which was safe while these values were part of the
// pipeline object -- a change to any of them produced a different pipeline. Now it does not,
// so consecutive draws share one object and the state has to be re-issued for each of them.
//
// It is also why this cannot be skipped when the pipeline handle is unchanged: overlays,
// blits and the present path bind pipelines that declare these states statically, and doing
// so discards the dynamic values. The redundant-bind cache in command_buffer::bind_pipeline
// then means the game's own bind is elided on the way back and never restores them.
_vkCmdSetPrimitiveTopologyEXT(*m_current_command_buffer, m_current_primitive_topology);
_vkCmdSetCullModeEXT(*m_current_command_buffer,
rsx::method_registers.cull_face_enabled()
? vk::get_cull_face(rsx::method_registers.cull_face_mode())
: VK_CULL_MODE_NONE);
_vkCmdSetFrontFaceEXT(*m_current_command_buffer,
vk::get_front_face(rsx::method_registers.front_face_mode()));
// Depth write and compare op are meaningless with the test off, and decode_rsx_state left
// both at zero in that case rather than at whatever the RSX registers happened to hold.
// Reproduce that exactly: a driver that reads the compare op regardless of the test must see
// the value it saw before this change.
const bool depth_test_enabled = rsx::method_registers.depth_test_enabled();
_vkCmdSetDepthTestEnableEXT(*m_current_command_buffer, depth_test_enabled ? VK_TRUE : VK_FALSE);
_vkCmdSetDepthWriteEnableEXT(*m_current_command_buffer,
(depth_test_enabled && rsx::method_registers.depth_write_enabled()) ? VK_TRUE : VK_FALSE);
_vkCmdSetDepthCompareOpEXT(*m_current_command_buffer,
depth_test_enabled ? vk::get_compare_func(rsx::method_registers.depth_func()) : VK_COMPARE_OP_NEVER);
}
void VKGSRender::update_draw_state()
{
m_profiler.start();
@@ -1111,6 +1152,11 @@ void VKGSRender::emit_geometry(u32 sub_index)
// FIXME: We only need to rebind the pipeline when reload state is set. Flags?
m_program->bind(*m_current_command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS);
if (m_device->get_extended_dynamic_state_support())
{
set_extended_dynamic_state();
}
if (reload_state)
{
update_draw_state();
+108 -33
View File
@@ -13,6 +13,66 @@ namespace vk
int g_num_pipe_compilers = 0;
atomic_t<int> g_compiler_index{};
static bool extended_dynamic_state_active()
{
// Called from the shader cache loader and the interpreter preloader as well as the draw
// path, and the first of those can run before a device exists.
return g_render_device && g_render_device->get_extended_dynamic_state_support();
}
VkPrimitiveTopology get_pipeline_topology(VkPrimitiveTopology topology, VkBool32 primitive_restart)
{
if (!extended_dynamic_state_active())
{
return topology;
}
// vkCmdSetPrimitiveTopology can only move within the topology CLASS the pipeline object
// was created with. Crossing classes needs dynamicPrimitiveTopologyUnrestricted, which
// comes from extended_dynamic_state3 and is not something mobile drivers report, so the
// class has to stay part of the pipeline identity. What collapses is the member inside
// it: list, strip and fan share one triangle pipeline instead of three.
//
// Which member stands for the class is not a free choice. Primitive restart is illegal on
// a *_LIST topology unless primitiveTopologyListRestart is enabled, which it is not here,
// so a restarting draw has to be represented by the strip form or pipelines that build
// today would start failing validation.
switch (topology)
{
case VK_PRIMITIVE_TOPOLOGY_LINE_LIST:
case VK_PRIMITIVE_TOPOLOGY_LINE_STRIP:
return primitive_restart ? VK_PRIMITIVE_TOPOLOGY_LINE_STRIP : VK_PRIMITIVE_TOPOLOGY_LINE_LIST;
case VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST:
case VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP:
case VK_PRIMITIVE_TOPOLOGY_TRIANGLE_FAN:
return primitive_restart ? VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP : VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
default:
// Points have a class of one, and adjacency/patch topologies never come out of the
// RSX decoder. Anything unexpected keeps its exact value rather than being guessed at.
return topology;
}
}
void normalize_dynamic_pipeline_state(pipeline_props& props)
{
if (!extended_dynamic_state_active())
{
return;
}
props.state.ia.topology = get_pipeline_topology(props.state.ia.topology, props.state.ia.primitiveRestartEnable);
// The replacements are exactly what graphics_pipeline_state's constructor leaves behind,
// so two props that differ only in these fields memcmp equal without operator== having to
// learn about the extension -- and without the disk cache's raw struct changing shape.
props.state.rs.cullMode = VK_CULL_MODE_NONE;
props.state.rs.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;
props.state.ds.depthTestEnable = VK_FALSE;
props.state.ds.depthWriteEnable = VK_FALSE;
props.state.ds.depthCompareOp = VK_COMPARE_OP_NEVER;
}
pipe_compiler::pipe_compiler()
{
// TODO: Initialize workqueue
@@ -105,6 +165,21 @@ namespace vk
dynamic_state_descriptors.push_back(VK_DYNAMIC_STATE_STENCIL_REFERENCE);
dynamic_state_descriptors.push_back(VK_DYNAMIC_STATE_DEPTH_BIAS);
// Must agree exactly with normalize_dynamic_pipeline_state(): a state declared here but
// still present in the key costs a vkCmdSet* for no reduction, and a state erased from
// the key but NOT declared here renders with whatever value the pipeline happened to be
// built with -- wrong culling and wrong depth compare on geometry that now shares an
// object with unrelated draws.
if (extended_dynamic_state_active())
{
dynamic_state_descriptors.push_back(VK_DYNAMIC_STATE_PRIMITIVE_TOPOLOGY_EXT);
dynamic_state_descriptors.push_back(VK_DYNAMIC_STATE_CULL_MODE_EXT);
dynamic_state_descriptors.push_back(VK_DYNAMIC_STATE_FRONT_FACE_EXT);
dynamic_state_descriptors.push_back(VK_DYNAMIC_STATE_DEPTH_TEST_ENABLE_EXT);
dynamic_state_descriptors.push_back(VK_DYNAMIC_STATE_DEPTH_WRITE_ENABLE_EXT);
dynamic_state_descriptors.push_back(VK_DYNAMIC_STATE_DEPTH_COMPARE_OP_EXT);
}
auto pdss = &create_info.state.ds;
VkPipelineDepthStencilStateCreateInfo ds2;
if (g_render_device->get_depth_bounds_support()) [[likely]]
@@ -179,10 +254,10 @@ namespace vk
return int_compile_graphics_pipe(info, vs_inputs, fs_inputs, flags);
}
std::unique_ptr<glsl::program> pipe_compiler::int_compile_graphics_pipe(
graphics_pipe_create_callback_t pipe_info_create_fn,
const std::vector<glsl::program_input>& vs_inputs,
const std::vector<glsl::program_input>& fs_inputs,
std::unique_ptr<glsl::program> pipe_compiler::int_compile_graphics_pipe(
graphics_pipe_create_callback_t pipe_info_create_fn,
const std::vector<glsl::program_input>& vs_inputs,
const std::vector<glsl::program_input>& fs_inputs,
op_flags flags)
{
VkGraphicsPipelineCreateInfo create_info = pipe_info_create_fn();
@@ -247,27 +322,27 @@ namespace vk
return {};
}
void initialize_pipe_compiler(int num_worker_threads)
{
if (num_worker_threads == 0)
{
// Select a conservative but modern default for async pipeline compilation.
// Older heuristics topped out too early on high-core CPUs and left large
// shader bursts queued longer than necessary.
const auto hw_threads = utils::get_thread_count();
if (hw_threads >= 24)
{
num_worker_threads = 12;
}
else if (hw_threads >= 16)
{
num_worker_threads = 8;
}
else if (hw_threads > 12)
{
num_worker_threads = 6;
}
void initialize_pipe_compiler(int num_worker_threads)
{
if (num_worker_threads == 0)
{
// Select a conservative but modern default for async pipeline compilation.
// Older heuristics topped out too early on high-core CPUs and left large
// shader bursts queued longer than necessary.
const auto hw_threads = utils::get_thread_count();
if (hw_threads >= 24)
{
num_worker_threads = 12;
}
else if (hw_threads >= 16)
{
num_worker_threads = 8;
}
else if (hw_threads > 12)
{
num_worker_threads = 6;
}
else if (hw_threads > 8)
{
num_worker_threads = 4;
@@ -276,14 +351,14 @@ namespace vk
{
num_worker_threads = 2;
}
else
{
num_worker_threads = 1;
}
rsx_log.notice("Async pipeline compiler auto-selected %d worker(s) for %u host thread(s).",
num_worker_threads, hw_threads);
}
else
{
num_worker_threads = 1;
}
rsx_log.notice("Async pipeline compiler auto-selected %d worker(s) for %u host thread(s).",
num_worker_threads, hw_threads);
}
ensure(num_worker_threads >= 1);
ensure(g_render_device); // "Cannot initialize pipe compiler before creating a logical device"
+12
View File
@@ -50,6 +50,18 @@ namespace vk
}
};
// Topology to build the pipeline object with. Identity unless VK_EXT_extended_dynamic_state
// is live, in which case only the topology CLASS survives -- see the note on the definition
// for why the class cannot be collapsed too, and why primitive restart picks the member.
VkPrimitiveTopology get_pipeline_topology(VkPrimitiveTopology topology, VkBool32 primitive_restart);
// Strip the states the draw path now sets per draw out of the pipeline identity. Must be the
// LAST thing done to a props before it is used as a cache key or handed to the compiler:
// leaving the real values in is what would make the whole exercise cost calls and save
// nothing. No-op without the extension, which is what keeps the fallback byte-identical to
// the old behaviour.
void normalize_dynamic_pipeline_state(pipeline_props& props);
class pipe_compiler
{
public:
+10
View File
@@ -37,6 +37,16 @@ VK_FUNC(vkCmdDrawMultiIndexedEXT);
// EXT_external_memory_host
VK_FUNC(vkGetMemoryHostPointerPropertiesEXT);
// EXT_extended_dynamic_state
// Resolved unconditionally like everything else here, so these stay null when the extension was
// not enabled. Only call them behind render_device::get_extended_dynamic_state_support().
VK_FUNC(vkCmdSetPrimitiveTopologyEXT);
VK_FUNC(vkCmdSetCullModeEXT);
VK_FUNC(vkCmdSetFrontFaceEXT);
VK_FUNC(vkCmdSetDepthTestEnableEXT);
VK_FUNC(vkCmdSetDepthWriteEnableEXT);
VK_FUNC(vkCmdSetDepthCompareOpEXT);
#undef VK_FUNC
#undef DECLARE_VK_FUNCTION_HEADER
#undef DECLARE_VK_FUNCTION_BODY
+9
View File
@@ -751,6 +751,15 @@ namespace vk
base_props.state.set_depth_mask(true);
pipe_properties.push_back(base_props);
// These are guesses at what the runtime will ask for, so they have to be spelled the same
// way the runtime spells it. Without this the seeds keep their cull mode and depth test
// and get() looks up a normalized key that matches none of them -- the precompile still
// runs, it just warms pipelines nothing goes on to use.
for (auto& props : pipe_properties)
{
vk::normalize_dynamic_pipeline_state(props);
}
const auto variants = program_common::interpreter::get_interpreter_variants();
const u32 limit1 = ::size32(variants.base_pipelines) * ::size32(pipe_properties);
const u32 limit2 = ::size32(variants.pipelines) * ::size32(pipe_properties);
+83
View File
@@ -96,6 +96,43 @@ namespace vk
features2.pNext = &multidraw_info;
}
// Presence of the extension string is not enough on its own -- the feature bit is what
// says the vkCmdSet* entry points actually do anything, and a driver may advertise the
// extension for the sake of a dependency and report the bit false.
VkPhysicalDeviceExtendedDynamicStateFeaturesEXT extended_dynamic_state_info{};
if (device_extensions.is_supported(VK_EXT_EXTENDED_DYNAMIC_STATE_EXTENSION_NAME))
{
extended_dynamic_state_info.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTENDED_DYNAMIC_STATE_FEATURES_EXT;
extended_dynamic_state_info.pNext = features2.pNext;
features2.pNext = &extended_dynamic_state_info;
}
#ifdef __ANDROID__
// What the Lossless Scaling shaders require, asked for directly.
//
// Adopted from Eden's implementation (CamilleLaVey, eden PR #4263), which gates on these
// two rather than on a GPU family. That is the better test: "Adreno 7xx or newer" is a
// proxy and it is wrong in both directions -- it excludes capable non-Adreno parts and
// admits an Adreno 7xx whose driver does not implement the memory model.
//
// vulkanMemoryModel is core in 1.2 and nullDescriptor comes from robustness2, so both are
// queried through their own structs rather than assumed from the API version.
VkPhysicalDeviceVulkan12Features vk12_info{};
vk12_info.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_2_FEATURES;
vk12_info.pNext = features2.pNext;
features2.pNext = &vk12_info;
VkPhysicalDeviceRobustness2FeaturesEXT robustness2_info{};
if (device_extensions.is_supported(VK_EXT_ROBUSTNESS_2_EXTENSION_NAME))
{
robustness2_info.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ROBUSTNESS_2_FEATURES_EXT;
robustness2_info.pNext = features2.pNext;
features2.pNext = &robustness2_info;
}
#endif
vkGetPhysicalDeviceFeatures2(dev, &features2);
shader_types_support.allow_float64 = !!features2.features.shaderFloat64;
@@ -112,6 +149,7 @@ namespace vk
optional_features_support.barycentric_coords = !!shader_barycentric_info.fragmentShaderBarycentric;
optional_features_support.framebuffer_loops = !!fbo_loops_info.attachmentFeedbackLoopLayout;
optional_features_support.extended_device_fault = !!device_fault_info.deviceFault;
optional_features_support.extended_dynamic_state = !!extended_dynamic_state_info.extendedDynamicState;
features = features2.features;
@@ -133,6 +171,19 @@ namespace vk
optional_features_support.memory_budget = device_extensions.is_supported(VK_EXT_MEMORY_BUDGET_EXTENSION_NAME);
optional_features_support.synchronization_2 = device_extensions.is_supported(VK_KHR_SYNCHRONIZATION_2_EXTENSION_NAME);
optional_features_support.unrestricted_depth_range = device_extensions.is_supported(VK_EXT_DEPTH_RANGE_UNRESTRICTED_EXTENSION_NAME);
#ifdef __ANDROID__
optional_features_support.external_memory_ahb = device_extensions.is_supported(VK_ANDROID_EXTERNAL_MEMORY_ANDROID_HARDWARE_BUFFER_EXTENSION_NAME);
optional_features_support.vulkan_memory_model = !!vk12_info.vulkanMemoryModel;
optional_features_support.null_descriptor = !!robustness2_info.nullDescriptor;
// Reported unconditionally because it decides whether frame generation can exist at all
// on this device, and the answer is otherwise invisible until something fails much later.
rsx_log.notice("Vulkan: frame generation requirements -- AHardwareBuffer external memory: %s,"
" vulkanMemoryModel: %s, nullDescriptor: %s",
optional_features_support.external_memory_ahb ? "yes" : "NO",
optional_features_support.vulkan_memory_model ? "yes" : "NO",
optional_features_support.null_descriptor ? "yes" : "NO");
#endif
#ifdef __APPLE__
optional_features_support.portability = device_extensions.is_supported(VK_KHR_PORTABILITY_SUBSET_EXTENSION_NAME);
#endif
@@ -744,6 +795,20 @@ namespace vk
requested_extensions.push_back(VK_EXT_DEPTH_RANGE_UNRESTRICTED_EXTENSION_NAME);
}
#ifdef __ANDROID__
// Frame generation only. Requested when present because the cost of carrying it is a
// string in a list, and the cost of NOT having it is that frame generation cannot share
// images at all -- there is no second way to hand a VkImage to a different VkDevice.
//
// Its dependencies (external_memory, dedicated_allocation, sampler_ycbcr_conversion,
// queue_family_foreign) are all core in Vulkan 1.1+, which is the floor here, so only the
// extension itself needs naming.
if (pgpu->optional_features_support.external_memory_ahb)
{
requested_extensions.push_back(VK_ANDROID_EXTERNAL_MEMORY_ANDROID_HARDWARE_BUFFER_EXTENSION_NAME);
}
#endif
if (pgpu->optional_features_support.external_memory_host)
{
requested_extensions.push_back(VK_EXT_EXTERNAL_MEMORY_HOST_EXTENSION_NAME);
@@ -784,6 +849,11 @@ namespace vk
{
requested_extensions.push_back(VK_EXT_DEVICE_FAULT_EXTENSION_NAME);
}
if (pgpu->optional_features_support.extended_dynamic_state)
{
requested_extensions.push_back(VK_EXT_EXTENDED_DYNAMIC_STATE_EXTENSION_NAME);
}
#ifdef __APPLE__
if (pgpu->optional_features_support.portability)
@@ -1036,6 +1106,19 @@ namespace vk
device.pNext = &conditional_rendering_info;
}
// Enabling the extension is not enough -- a driver is entitled to ignore the vkCmdSet*
// calls unless the feature bit is asked for at device creation, and the failure mode is
// silent: the pipeline's stale static topology/cull/depth are used and geometry renders
// with the wrong facing.
VkPhysicalDeviceExtendedDynamicStateFeaturesEXT extended_dynamic_state_info{};
if (pgpu->optional_features_support.extended_dynamic_state)
{
extended_dynamic_state_info.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTENDED_DYNAMIC_STATE_FEATURES_EXT;
extended_dynamic_state_info.pNext = const_cast<void*>(device.pNext);
extended_dynamic_state_info.extendedDynamicState = VK_TRUE;
device.pNext = &extended_dynamic_state_info;
}
VkPhysicalDeviceFragmentShaderBarycentricFeaturesKHR shader_barycentric_info{};
if (pgpu->optional_features_support.barycentric_coords)
{
+26
View File
@@ -103,12 +103,26 @@ namespace vk
bool conditional_rendering = false;
bool debug_utils = false;
bool external_memory_host = false;
bool extended_dynamic_state = false;
bool framebuffer_loops = false;
bool memory_budget = false;
bool shader_stencil_export = false;
bool surface_capabilities_2 = false;
bool synchronization_2 = false;
bool unrestricted_depth_range = false;
// VK_ANDROID_external_memory_android_hardware_buffer.
//
// Gates frame generation. framegen runs on its OWN VkDevice, so images cannot be
// shared as VkImage -- they have to go across as AHardwareBuffer, and importing one
// needs this. framegen's other sharing path uses vkGetMemoryFdKHR(OPAQUE_FD), which
// both Adreno and Mali refuse for AHB-backed memory, so there is no fallback.
bool external_memory_ahb = false;
// What the Lossless Scaling shaders themselves need, independent of how the images
// are shared. Eden gates on exactly these two; see the note in device.cpp.
bool vulkan_memory_model = false;
bool null_descriptor = false;
bool extended_device_fault = false;
bool texture_compression_bc = false;
bool portability = false;
@@ -218,7 +232,19 @@ namespace vk
bool get_anisotropic_filtering_support() const { return pgpu->features.samplerAnisotropy != VK_FALSE; }
bool get_wide_lines_support() const { return pgpu->features.wideLines != VK_FALSE; }
bool get_conditional_render_support() const { return pgpu->optional_features_support.conditional_rendering; }
// Topology, cull mode, front face and the depth test are set per draw instead of being
// baked into a pipeline object. That is what keeps the permutation count down on mobile,
// where every extra pipeline is a compile stall the first time it is seen and another
// entry in a cache that already takes minutes to warm. Everything keyed on this must have
// a static fallback: the extension is core in 1.3 but plenty of shipped Android 11/13
// drivers predate it.
bool get_extended_dynamic_state_support() const { return pgpu->optional_features_support.extended_dynamic_state; }
bool get_unrestricted_depth_range_support() const { return pgpu->optional_features_support.unrestricted_depth_range; }
bool get_external_memory_ahb_support() const { return pgpu->optional_features_support.external_memory_ahb; }
bool get_vulkan_memory_model_support() const { return pgpu->optional_features_support.vulkan_memory_model; }
bool get_null_descriptor_support() const { return pgpu->optional_features_support.null_descriptor; }
bool get_external_memory_host_support() const { return pgpu->optional_features_support.external_memory_host; }
bool get_memory_budget_support() const { return pgpu->optional_features_support.memory_budget; }
bool get_surface_capabilities_2_support() const { return pgpu->optional_features_support.surface_capabilities_2; }