Skip to content

test(shader): cover half expansion bit patterns - #1505

Merged
adriadam10 merged 1 commit into
boykopovar:mainfrom
Sanjaysrinivas:test/shader-half-expansion
Oct 11, 2026
Merged

adriadam10 merged 1 commit into
boykopovar:mainfrom
Sanjaysrinivas:test/shader-half-expansion

Conversation

@Sanjaysrinivas

Copy link
Copy Markdown
Contributor

What

Depends on #1463. Add a focused execution regression for the payload-preserving half expansion contract introduced there. This PR contains only HalfExpansion.cpp; it does not duplicate the production fix.

The test sends runtime buffer values through the public Recompile interface and the normal Vulkan driver, then compares four raw result words per lane:

  • v_cvt_f32_f16 on the low half and on a shifted high half.
  • v_ldexp_f16 with SDWA BYTE_0 and BYTE_1, exponent zero. The result must remain the selected half subnormal even when the unselected bits encode a NaN.

Twenty fixed vectors cover both signed zeros, subnormals, the normal boundary, finite values, infinities, quiet NaN sign/payload, and the 0xfff0fff0 source reported in #1463. Three runs request guest wave32, guest wave64 with the default target, and guest wave64 with ComputeTarget(32): 640 exact result comparisons. Quiet NaNs are checked by bits; the checked-in test does not assert signaling-NaN instruction behavior.

The existing CMake execution-test glob registers this automatically. No production code, dependencies, coverage counters, or investigation files are added.

Tested

  • Branch base: 3069680b709d783355e381f582a3ce5320099075 (current main when refreshed).
  • Ubuntu 24.04.3 / WSL2, GCC 13.3, CMake 4.4 / Ninja, Release. Built the actual CMake targets from this checkout.
  • With only EmitF16BitsToF32 from fix(shader): convert f16 bits to f32 without UnpackHalf2x16 #1463 head 58f596ee894d5d0d326a379032950ff4170c6964 applied locally: 7/7 focused CTest tests passed, 0 skipped. They cover the new fixture, Float16Misc, Float16Modifiers, Float16Rounding, Float16ResultModifiers, SdwaFloatSelectors and SdwaLdexpF16.
  • Integration fixture execution used lavapipe / llvmpipe (LLVM 20.1.2, native subgroup 8), with ANYPS5_REQUIRE_VULKAN=1 and shader caching disabled. The new fixture also passed before the dependency on the earlier 6e037e98 base: lavapipe does not reproduce the native NVIDIA NaN failure.
  • LLVM 18 llvm-mc independently assembled the embedded shader for both gfx1013 and gfx1030; both exactly match all 14 fixture dwords, SHA-256 184d3b5e2cb9b9a534b47dd4107e2bb28d0bc2e77fc3283acd854c5e2a52bf07.
  • Before/after standalone probe modules passed spirv-val --target-env vulkan1.1.
  • Native Windows 10.0.26300 GPU checks cover the shared emitter. Both devices detected a negative control that deliberately changed one expected bit: exactly one mismatch each.
  • Repository conventions passed for the test commit and this PR description.

Focused suite reproduction after applying #1463 to this branch:

cmake -S . -B build -G Ninja -DBUILD_TESTING=ON -DCMAKE_BUILD_TYPE=Release
cmake --build build --target agc_driver_half_expansion_tests \
  agc_driver_float16_misc_tests agc_driver_float16_modifiers_tests \
  agc_driver_float16_rounding_tests agc_driver_float16_result_modifiers_tests \
  agc_driver_sdwa_float_selectors_tests agc_driver_sdwa_ldexp_f16_tests
ANYPS5_REQUIRE_VULKAN=1 ctest --test-dir build --output-on-failure \
  -R '^agc_driver_(half_expansion|float16_(misc|modifiers|rounding|result_modifiers)|sdwa_(float_selectors|ldexp_f16))$'

The native GPU probe below exercises the actual shared SPIR-V emitter with runtime SSBO inputs, independently of the guest-shader integration fixture. It checks all 65,536 raw half patterns plus 20 packed inputs, with four selections each: 262,224 comparisons per device. Its signaling-NaN cases check the helper's raw-bit contract only; they do not establish guest floating-point mode behavior.

Native GPU Before: raw-bit mismatches After #1463 Comparisons per capture
NVIDIA RTX 4070 Laptop 2,056 0 262,224
AMD Radeon 780M 0 0 262,224

The NVIDIA baseline includes 1,034 quiet-NaN mismatches, 1,022 signaling-NaN raw-contract mismatches, and zero finite/infinity mismatches. The retained integration test uses quiet NaNs only. The baseline module was exported at 6e037e98; the widening helper is unchanged in the refreshed 3069680b main base. The corrected module was freshly exported from the refreshed base with the #1463 helper.

Two representative native probe captures, showing the low/high FP32 words:

Device Packed input Before After #1463 / expected
RTX 4070 Laptop 7e01fe01 7fffffff 7fffffff ffc02000 7fc02000
RTX 4070 Laptop fff0fff0 7fffffff 7fffffff fffe0000 fffe0000
Radeon 780M 7e01fe01 ffc02000 7fc02000 ffc02000 7fc02000
Radeon 780M fff0fff0 fffe0000 fffe0000 fffe0000 fffe0000
Capture metadata
{
  "captured": "2026-10-08",
  "native_rdna2_or_ps5_execution": false,
  "baseline_spirv_sha256": "cd065e5c5d762135dbf7a6a896d94273492cdf95d0308ecfee01c2ceb6a9523d",
  "corrected_spirv_sha256": "58fcc7085782a3643ff25d298c10b7b490bdd7e7e1b37426c447be8b4bf42418",
  "guest_shader_fixture_sha256": "7cf2b22a7c60abd2a70268a19fbc376ba4eba6245563d917a69f387ed61030af",
  "devices": [
    {
      "device": "NVIDIA GeForce RTX 4070 Laptop GPU",
      "vendor_id": 4318,
      "device_id": 10272,
      "driver_version": 2585198592,
      "inputs": 65556,
      "comparisons": 262224,
      "mismatch_count": 0
    },
    {
      "device": "AMD Radeon 780M Graphics",
      "vendor_id": 4098,
      "device_id": 6400,
      "driver_version": 8389003,
      "inputs": 65556,
      "comparisons": 262224,
      "mismatch_count": 0
    }
  ]
}
One-off native probe sources and reproduction

These sources are supporting investigation artifacts, included here rather than tracked in the repository. Save the three files below in build-contribution/shader-gap-research/ under the checkout under test. Configure a Linux GCC/Ninja build at build-contribution/half-expansion-main-build and build agc_driver_half_expansion_tests first. Run python3 build-contribution/shader-gap-research/build_half_evidence.py in that Linux environment to export the real emitter's SPIR-V. The exporter uses the same CMake compiler definitions, include paths and shader archive.

For the Windows GPU execution used here, install the Python binding beside the scripts:

python -m pip install --target build-contribution/shader-gap-research/python-vulkan vulkan==1.3.275.1
python build-contribution/shader-gap-research/run_half_evidence.py --device 0 --label before

Repeat the build/export with #1463 applied, then run with --label after (asserts no mismatches) and --label negative --negative-control (asserts exactly the injected mismatch). Use --device 1 for the second adapter. --label before records mismatches without failing so a driver that already preserves the bits can also supply a baseline. JSON results include the actual adapter identity, driver number, SPIR-V hash, category counts and all 20 packed rows.

The raw expansion matrix includes signaling encodings without treating this as an instruction-level signaling-NaN or MODE oracle. The integration fixture's SDWA cases additionally execute the half-precision ldexp/round-trip path; this standalone probe directly checks widening of the selected bytes.

export_half_evidence.cpp

#include "Recompiler.hpp"
#include "IntermediateRepresentation/IrProgram.hpp"
#include "SpirvBackend/SpirvEmitterInstructions.hpp"
#include <array>
#include <fstream>
#include <iostream>
#include <string>

using namespace ShaderRecompiler;

static void Save(const std::string& name, const std::vector<std::uint32_t>& words) {
    std::ofstream out(name, std::ios::binary);
    out.write(reinterpret_cast<const char*>(words.data()), words.size() * sizeof(words[0]));
    if (!out) throw std::runtime_error("failed to save " + name);
}

static void HalfConversions(const std::string& directory) {
    IrProgram program;
    const ShaderStageInputInfo inputs{};
    SpirvEmitterState state(program, inputs);
    auto& m = state.module;
    m.EmitCapability(spv::CapabilityShader);
    m.EmitCapability(spv::CapabilitySignedZeroInfNanPreserve);
    m.EmitExtension("SPV_KHR_float_controls");
    m.AddMemoryModel(spv::AddressingModelLogical, spv::MemoryModelGLSL450);
    const auto u32 = TypeU32(state);
    const auto array = m.DecoratedType(spv::OpTypeRuntimeArray, {{spv::OpDecorate, {spv::DecorationArrayStride, 4u}}}, u32);
    const auto structure = m.Type(spv::OpTypeStruct, array);
    m.AddAnnotation(spv::OpDecorate, structure, spv::DecorationBlock);
    m.AddAnnotation(spv::OpMemberDecorate, structure, 0u, spv::DecorationOffset, 0u);
    const auto buffer = m.DefineGlobalVariable(m.Type(spv::OpTypePointer, spv::StorageClassStorageBuffer, structure), spv::StorageClassStorageBuffer);
    m.AddAnnotation(spv::OpDecorate, buffer, spv::DecorationDescriptorSet, 0u);
    m.AddAnnotation(spv::OpDecorate, buffer, spv::DecorationBinding, 0u);
    const auto gid = m.DefineGlobalVariable(m.Type(spv::OpTypePointer, spv::StorageClassInput, TypeU32Vector(state, 3u)), spv::StorageClassInput);
    m.AddAnnotation(spv::OpDecorate, gid, spv::DecorationBuiltIn, spv::BuiltInGlobalInvocationId);
    const auto main = m.AllocateId();
    const auto voidType = m.Type(spv::OpTypeVoid);
    m.EmitEntryPoint(spv::ExecutionModelGLCompute, main, "main", {gid});
    m.AddExecutionMode(main, spv::ExecutionModeLocalSize, 64u, 1u, 1u);
    m.AddExecutionMode(main, spv::ExecutionModeSignedZeroInfNanPreserve, 32u);
    m.AddFunction(spv::OpFunction, voidType, main, spv::FunctionControlMaskNone, m.Type(spv::OpTypeFunction, voidType));
    m.AddFunction(spv::OpLabel, m.AllocateId());
    const auto invocation = m.AllocateId();
    m.AddFunction(spv::OpLoad, TypeU32Vector(state, 3u), invocation, gid);
    const auto index = m.AllocateId();
    m.AddFunction(spv::OpCompositeExtract, u32, index, invocation, 0u);
    const auto offset = Binary(state, spv::OpIMul, u32, index, ConstantU32(state, 5u));
    const auto address = [&](std::uint32_t component) {
        const auto result = m.AllocateId();
        m.AddFunction(spv::OpAccessChain, m.Type(spv::OpTypePointer, spv::StorageClassStorageBuffer, u32), result, buffer,
            ConstantU32(state, 0u), Binary(state, spv::OpIAdd, u32, offset, ConstantU32(state, component)));
        return result;
    };
    const auto source = m.AllocateId();
    m.AddFunction(spv::OpLoad, u32, source, address(0u));
    for (std::uint32_t component = 0; component < 4u; ++component) {
        auto selected = source;
        if (component == 1u) selected = Binary(state, spv::OpShiftRightLogical, u32, source, ConstantU32(state, 16u));
        if (component >= 2u) {
            selected = m.AllocateId();
            m.AddFunction(spv::OpBitFieldUExtract, u32, selected, source, ConstantU32(state, (component - 2u) * 8u), ConstantU32(state, 8u));
        }
        const auto expanded = EmitF16BitsToF32(state, selected);
        const auto bits = m.AllocateId();
        m.AddFunction(spv::OpBitcast, u32, bits, expanded);
        m.AddFunction(spv::OpStore, address(component + 1u), bits);
    }
    m.AddFunction(spv::OpReturn);
    m.AddFunction(spv::OpFunctionEnd);
    Save(directory + "/half-expansion-context.spv", m.Finalize());
}


int main(int argc, char** argv) {
    try {
        if (argc != 2) throw std::runtime_error("expected artifact directory");
        HalfConversions(argv[1]);
        return 0;
    } catch (const std::exception& error) {
        std::cerr << error.what() << '\n';
        return 1;
    }
}

build_half_evidence.py

from pathlib import Path
import re
import shlex
import subprocess

here = Path(__file__).resolve().parent
root = here.parents[1]
base = root / 'build-contribution/half-expansion-main-build'
ninja = (base / 'build.ninja').read_text()
unit = re.search(r'^build (\S+\.o): CXX_COMPILER__shader_recompiler_agc_driver_\S+ (\S+) \|\|[^\n]*\n((?:  [^\n]*\n)*)', ninja, re.M)
attributes = unit.group(3)
defines = shlex.split(re.search(r'^  DEFINES = (.+)$', attributes, re.M).group(1))
includes = shlex.split(re.search(r'^  INCLUDES = (.+)$', attributes, re.M).group(1))
libs = base / 'core/libs/libs/unpatched'
archive = base / 'core/libs/prx/libSceAgcDriver/libshader_recompiler_agc_driver.a'
glslang = [base / '3rdparty/glslang/SPIRV/libSPIRV.a', base / '3rdparty/glslang/glslang/libglslang.a', base / '3rdparty/glslang/glslang/libglslang-default-resource-limits.a']
binary = here / 'export_half_evidence'
command = ['g++', '-std=c++20', '-O1', *defines, *includes, str(here / 'export_half_evidence.cpp'), str(archive),
    '-Wl,--start-group', *map(str, glslang), '-Wl,--end-group', str(libs / 'libc.prx'), '-lm', '-pthread',
    f'-Wl,-rpath,{libs}', f'-Wl,-rpath-link,{libs}', '-o', str(binary)]
subprocess.run(command, check=True)
subprocess.run([str(binary), str(here)], check=True)

run_half_evidence.py

from pathlib import Path
import argparse
import hashlib
import json
import math
import struct
import sys

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE / 'python-vulkan'))
import vulkan as v

# The binding copies nested C structs without retaining their pointer owners.
# Retain this short-lived tool's constructor results through device destruction.
KEEP_ALIVE = []
def retain_constructor(constructor):
    def retained(*a, **kw):
        result = constructor(*a, **kw)
        KEEP_ALIVE.append(result)
        return result
    return retained
for constructor_name in dir(v):
    constructor = getattr(v, constructor_name)
    if constructor_name.startswith('Vk') and callable(constructor):
        setattr(v, constructor_name, retain_constructor(constructor))

parser = argparse.ArgumentParser()
parser.add_argument('--device', type=int, default=0)
parser.add_argument('--label', required=True)
parser.add_argument('--negative-control', action='store_true')
args = parser.parse_args()

def words(data):
    return list(struct.unpack('<' + 'I' * (len(data) // 4), data))

app = v.VkApplicationInfo(pApplicationName='AnyPS5 half expansion evidence', apiVersion=v.VK_MAKE_VERSION(1, 1, 0))
instance = v.vkCreateInstance(v.VkInstanceCreateInfo(pApplicationInfo=app), None)
physical = v.vkEnumeratePhysicalDevices(instance)[args.device]
properties = v.vkGetPhysicalDeviceProperties(physical)
print('Device:', properties.deviceName, flush=True)
float_properties = v.ffi.new('VkPhysicalDeviceFloatControlsProperties*', v.VkPhysicalDeviceFloatControlsProperties())
v.vkGetPhysicalDeviceProperties2(physical, v.VkPhysicalDeviceProperties2(pNext=float_properties))
assert float_properties.shaderSignedZeroInfNanPreserveFloat32
family = next(i for i, p in enumerate(v.vkGetPhysicalDeviceQueueFamilyProperties(physical)) if p.queueFlags & v.VK_QUEUE_COMPUTE_BIT)
extensions = [e.extensionName for e in v.vkEnumerateDeviceExtensionProperties(physical, None)]
device = v.vkCreateDevice(physical, v.VkDeviceCreateInfo(
    pQueueCreateInfos=[v.VkDeviceQueueCreateInfo(queueFamilyIndex=family, queueCount=1, pQueuePriorities=[1.0])],
    ppEnabledExtensionNames=['VK_KHR_shader_float_controls'] if 'VK_KHR_shader_float_controls' in extensions else []), None)
queue = v.vkGetDeviceQueue(device, family, 0)
memory_properties = v.vkGetPhysicalDeviceMemoryProperties(physical)
pool = v.vkCreateCommandPool(device, v.VkCommandPoolCreateInfo(queueFamilyIndex=family), None)
command = v.vkAllocateCommandBuffers(device, v.VkCommandBufferAllocateInfo(commandPool=pool,
    level=v.VK_COMMAND_BUFFER_LEVEL_PRIMARY, commandBufferCount=1))[0]
allocated = []

def memory_type(bits, flags):
    return next(i for i in range(memory_properties.memoryTypeCount)
        if bits & (1 << i) and memory_properties.memoryTypes[i].propertyFlags & flags == flags)

def buffer(size, usage):
    handle = v.vkCreateBuffer(device, v.VkBufferCreateInfo(size=size, usage=usage, sharingMode=v.VK_SHARING_MODE_EXCLUSIVE), None)
    requirements = v.vkGetBufferMemoryRequirements(device, handle)
    memory = v.vkAllocateMemory(device, v.VkMemoryAllocateInfo(allocationSize=requirements.size,
        memoryTypeIndex=memory_type(requirements.memoryTypeBits, v.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | v.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)), None)
    v.vkBindBufferMemory(device, handle, memory, 0)
    allocated.append((handle, memory))
    return handle, memory

def write(memory, data):
    mapped = v.vkMapMemory(device, memory, 0, len(data), 0)
    mapped[:] = data
    v.vkUnmapMemory(device, memory)

def read(memory, size):
    mapped = v.vkMapMemory(device, memory, 0, size, 0)
    result = bytes(mapped)
    v.vkUnmapMemory(device, memory)
    return result

def module(name):
    code = (HERE / name).read_bytes()
    return v.vkCreateShaderModule(device, v.VkShaderModuleCreateInfo(codeSize=len(code), pCode=code), None)

def begin():
    v.vkResetCommandPool(device, pool, 0)
    v.vkBeginCommandBuffer(command, v.VkCommandBufferBeginInfo(flags=v.VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT))

def submit():
    v.vkEndCommandBuffer(command)
    fence = v.vkCreateFence(device, v.VkFenceCreateInfo(), None)
    try:
        v.vkQueueSubmit(queue, 1, [v.VkSubmitInfo(pCommandBuffers=[command])], fence)
        v.vkWaitForFences(device, 1, [fence], True, 30_000_000_000)
    finally:
        v.vkDestroyFence(device, fence, None)


fixed_inputs = [0x3c003c00, 0xc000bc00, 0x80000000, 0x00008000, 0x03ff0001,
    0x040003ff, 0x7bff0400, 0xfbff7bff, 0xfc007c00, 0x7e01fe01, 0xfe017e01,
    0x7fff7e55, 0xfffffe55, 0xfff0fff0, 0xa5a500f0, 0xdead00ff, 0x7c003555,
    0x3555fc00, 0x3c000200, 0x80018001]
inputs = list(range(65536)) + fixed_inputs
while len(inputs) % 64:
    inputs.append(0)
data = b''.join(struct.pack('<5I', x, *([0xdeadbeef]*4)) for x in inputs)

def expected_half(half):
    if (half & 0x7fff) > 0x7c00:
        return ((half & 0x8000) << 16) | 0x7f800000 | ((half & 0x3ff) << 13)
    return struct.unpack('<I', struct.pack('<f', struct.unpack('<e', struct.pack('<H', half))[0]))[0]

try:
    storage, storage_memory = buffer(len(data), v.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT)
    write(storage_memory, data)
    descriptor_layout = v.vkCreateDescriptorSetLayout(device, v.VkDescriptorSetLayoutCreateInfo(pBindings=[
        v.VkDescriptorSetLayoutBinding(binding=0, descriptorType=v.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, descriptorCount=1, stageFlags=v.VK_SHADER_STAGE_COMPUTE_BIT)]), None)
    descriptor_pool = v.vkCreateDescriptorPool(device, v.VkDescriptorPoolCreateInfo(maxSets=1, pPoolSizes=[
        v.VkDescriptorPoolSize(type=v.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, descriptorCount=1)]), None)
    descriptor = v.vkAllocateDescriptorSets(device, v.VkDescriptorSetAllocateInfo(descriptorPool=descriptor_pool, pSetLayouts=[descriptor_layout]))[0]
    v.vkUpdateDescriptorSets(device, 1, [v.VkWriteDescriptorSet(dstSet=descriptor, dstBinding=0, descriptorCount=1,
        descriptorType=v.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, pBufferInfo=[v.VkDescriptorBufferInfo(buffer=storage, offset=0, range=len(data))])], 0, None)
    layout = v.vkCreatePipelineLayout(device, v.VkPipelineLayoutCreateInfo(pSetLayouts=[descriptor_layout]), None)
    shader_path = HERE / 'half-expansion-context.spv'
    shader = module(shader_path.name)
    pipeline = v.vkCreateComputePipelines(device, v.VK_NULL_HANDLE, 1, [v.VkComputePipelineCreateInfo(
        stage=v.VkPipelineShaderStageCreateInfo(stage=v.VK_SHADER_STAGE_COMPUTE_BIT, module=shader, pName='main'), layout=layout)], None)[0]
    begin()
    v.vkCmdBindPipeline(command, v.VK_PIPELINE_BIND_POINT_COMPUTE, pipeline)
    v.vkCmdBindDescriptorSets(command, v.VK_PIPELINE_BIND_POINT_COMPUTE, layout, 0, 1, [descriptor], 0, None)
    v.vkCmdDispatch(command, len(inputs)//64, 1, 1)
    v.vkCmdPipelineBarrier(command, v.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, v.VK_PIPELINE_STAGE_HOST_BIT, 0,
        1, [v.VkMemoryBarrier(srcAccessMask=v.VK_ACCESS_SHADER_WRITE_BIT, dstAccessMask=v.VK_ACCESS_HOST_READ_BIT)], 0, None, 0, None)
    submit()
    observed = words(read(storage_memory, len(data)))
    mismatches, examples, fixed_results = 0, [], []
    categories = {'quiet_nan':0, 'signaling_nan':0, 'finite_or_infinity':0}
    for i, x in enumerate(inputs[:65536+len(fixed_inputs)]):
        selected = [x & 0xffff, x >> 16, x & 0xff, (x >> 8) & 0xff]
        expected = [expected_half(h) for h in selected]
        if args.negative_control and i == 0:
            expected[0] ^= 1
        actual = observed[i*5+1:i*5+5]
        for column, (a, e) in enumerate(zip(actual, expected)):
            if a != e:
                mismatches += 1
                h = selected[column]
                category = ('quiet_nan' if h & 0x200 else 'signaling_nan') if (h & 0x7fff) > 0x7c00 else 'finite_or_infinity'
                categories[category] += 1
                if len(examples) < 8:
                    examples.append({'input':f'{x:08x}', 'column':column, 'actual':f'{a:08x}', 'expected':f'{e:08x}'})
        if i >= 65536:
            fixed_results.append({'input':f'{x:08x}', 'actual':[f'{a:08x}' for a in actual], 'expected':[f'{e:08x}' for e in expected]})
    result = {'device':properties.deviceName, 'vendor_id':properties.vendorID, 'device_id':properties.deviceID,
        'driver_version':properties.driverVersion, 'label':args.label,
        'native_rdna2_or_ps5_execution':False, 'scope':'actual half expansion emitter with runtime SSBO input; raw-bit contract, not guest floating-point mode equivalence',
        'module_sha256':hashlib.sha256(shader_path.read_bytes()).hexdigest(), 'inputs':65536+len(fixed_inputs),
        'comparisons':4*(65536+len(fixed_inputs)), 'mismatch_count':mismatches, 'mismatch_categories':categories,
        'examples':examples, 'fixed_results':fixed_results, 'negative_control':args.negative_control}
    (HERE / f'half-evidence-{args.label}-device-{args.device}.json').write_text(json.dumps(result, indent=2)+'\n')
    print(json.dumps({k:v for k,v in result.items() if k != 'fixed_results'}, indent=2), flush=True)
    if args.negative_control:
        assert mismatches == 1 and examples[0]['input'] == '00000000', 'negative control failed'
    elif args.label != 'before':
        assert mismatches == 0, 'incorrect GPU output'
finally:
    v.vkDeviceWaitIdle(device)
    for name, destroy in [('pipeline',v.vkDestroyPipeline), ('shader',v.vkDestroyShaderModule),
        ('layout',v.vkDestroyPipelineLayout), ('descriptor_pool',v.vkDestroyDescriptorPool),
        ('descriptor_layout',v.vkDestroyDescriptorSetLayout)]:
        if name in globals():
            destroy(device, globals()[name], None)
    for handle, memory in allocated:
        v.vkDestroyBuffer(device, handle, None)
        v.vkFreeMemory(device, memory, None)
    v.vkDestroyCommandPool(device, pool, None)
    v.vkDestroyDevice(device, None)
    v.vkDestroyInstance(instance, None)

The expectation for finite expansion was calculated independently with Python's IEEE half unpack / float pack. Quiet NaN goldens preserve the source sign and shift the payload into the FP32 mantissa, matching #1463's contract. A useful external reference is the published native gfx1035 interpolation result, where half fe01 survives the reported interpolation as fe01. That is supporting arithmetic evidence, not a direct native execution of this new conversion fixture. The AMD SDWA failure described in #1463 motivates the packed-byte cases.

Limits: the local GPUs are an NVIDIA RTX 4070 Laptop GPU and an AMD Radeon 780M (RDNA3). No new RDNA2 or PS5 execution was performed. This PR adds zero instruction coverage percentage and does not settle PS5 SPI interpolation or per-shader MODE semantics. It remains draft pending #1463.

Checklist

@Sanjaysrinivas
Sanjaysrinivas marked this pull request as ready for review October 8, 2026 10:21

@klosixgaming-arch klosixgaming-arch left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent focused regression test for the half expansion bit pattern preservation. The 20 fixed vectors cover all the important edge cases (signed zeros, subnormals, normal boundary, infinities, quiet NaNs with various payloads). Running across multiple wave sizes and compute targets is thorough.

The documentation of the native GPU probe results in the PR description is very helpful for understanding the real-world impact of this fix. Ready to merge once #1463 is merged.

@klosixgaming-arch klosixgaming-arch left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good addition to the test suite. This comprehensive Vulkan execution test for half expansion operations verifies correct handling of various edge cases including denormals, infinities, and NaNs.

Strengths:

  • Comprehensive test coverage with 20 different input vectors covering various edge cases
  • Tests both wave sizes (32 and 64) and different compute targets
  • Clear error messages that identify the specific failure condition (wave size, lane, input value, expected vs actual)
  • Good use of existing Vulkan test infrastructure

Technical notes:

  • The approach of testing multiple wave sizes ensures compatibility across different GPU architectures
  • Proper handling of both low and high half expansion as well as BYTE_0/BYTE_1 ldexp operations
  • Test properly verifies all four output components for each input vector

This will improve confidence in the shader recompiler's half-precision floating point handling. Ready to merge.

@github-actions github-actions Bot added the agc libSceAgc* label Oct 8, 2026
@adriadam10

Copy link
Copy Markdown
Collaborator

Checked: builds and passes on lavapipe; the 20 vectors match v_cvt_f32_f16 / SDWA v_ldexp_f16 on a gfx1035 HW run bit for bit (quiet NaNs only, so independent of IEEE mode). No issues in the test itself.

The test also passes without #1463 on lavapipe, so we can't see it fail here; its value is on NVIDIA and AMD Windows drivers, which we can't run.

@oneandonlydean could you run agc_driver_half_expansion on the RTX 4090 with and without #1463? It should fail before and pass after. If so, this is ready to merge once #1463 lands.

@oneandonlydean

Copy link
Copy Markdown
Collaborator

RTX 4090, NVIDIA 610.57.04, Linux, main b7bb268. agc_driver_half_expansion without #1463 fails 5/5, always wave 32, lane 9, input 0x7e01fe01, low half is 0x7fffffff, expected 0xffc02000. With #1463 it passes 5/5. So it fails before and passes after, as expected.

@Silverash-i8

Copy link
Copy Markdown
Contributor

Windows run on two more GPUs: Windows 11, WinLibs GCC 15.2.0 r7, Release, merged onto main d31b57a, RTX 5060 Laptop and the Arrow Lake iGPU (ANYPS5_GPU=Intel).

AI-assisted (Claude Code); I ran this on my machine.

@adriadam10

Copy link
Copy Markdown
Collaborator

Checked on lavapipe with #1463 on main: agc_driver_half_expansion builds and passes, the vectors match a gfx1035 HW run bit for bit, CI is green. Fails without #1463 on RTX 4090 (@oneandonlydean) and RTX 5060 (@Silverash-i8) and passes with it; merging.

@adriadam10
adriadam10 merged commit 0490ca1 into boykopovar:main Oct 11, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agc libSceAgc*

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants