Skip to content

fix(agc): compare host imports when device writes reach the write watch - #1046

Closed
aryanrawatispro-ship-it wants to merge 5 commits into
boykopovar:mainfrom
aryanrawatispro-ship-it:test/agc-recorder-ctest
Closed

aryanrawatispro-ship-it wants to merge 5 commits into
boykopovar:mainfrom
aryanrawatispro-ship-it:test/agc-recorder-ctest

Conversation

@aryanrawatispro-ship-it

@aryanrawatispro-ship-it aryanrawatispro-ship-it commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

What

agc_driver_recorder_tests is built but never run in CI. On the Linux job's device (lavapipe) it fails on main on every run, and the process then segfaults at exit:

AGC graphics: the partial publish did not copy exactly the partly covered unit

Cause. lavapipe runs command buffers on CPU threads, so its writes into a host import go through the page tables, and the write watch collects them as CPU stores. The probe that decides whether imports stay watched only checks a GPU read, which reports 0 of 4 pages on lavapipe, so imports stay watched. In unitShadowTests, the whole publish's copy into the import is then collected as a CPU store over unit 3. After the unit is retiled again, the partial publish finds it written since the retile and drops the slab's newer bytes as stale: PublishShadow returns 0 and the unit is no longer shadowed. Any write the driver records into a watched import on such a device reads the same way.

Fixes #1429.

Five commits:

  1. fix(agc): compare host imports when device writes reach the write watch: ProbeImportWriteProtection also fills the scratch import on the GPU and collects again (writtenAfterFill). When the read or the fill reports pages as written, imported ranges are compared, as on amdgpu. On lavapipe the startup line now reads:
    [write-watch] host imports resolve write protection: yes (0 of 4 scratch pages written after a GPU read, 4 after a GPU write, 0 after the import); imported ranges are compared
    
  2. test(agc): run the recorder tests in ctest: registers agc_driver_recorder with the other driver tests. It skips with 77 without a usable Vulkan device (the Windows job), unless ANYPS5_REQUIRE_VULKAN is set (the Linux job). The draw snapshot reuse test (69a9420) assumed watched imports. With imports compared, a draw copies its input every time, as that commit intends, so the test now reports itself as not run there, like the unit shadow test. It also failed on main with APS5_WRITE_WATCH_IMPORTS=unwatch, the amdgpu path.
  3. test(agc): gate the import window checks on the range's own writes: importWindowTests took its write checks when the scratch import probe reported pages written at import time. That probe runs on a separate mapping. On NVIDIA it reports 4 of 4 pages while the test's range is unchanged, so the case failed (agc_driver_recorder_tests: on Linux NVIDIA a reported import write does not invalidate a cache, and the test has never run in CI #1532). The case now branches on the range's own UnchangedSince after the collect. Suggested by @MarcZX100 and measured by @Dextroid17 (RTX 3080 Ti: exit 1 before, 0 after).
  4. test(agc): report the dma-buf import probe as not run without /dev/udmabuf: importWatchTests required the dma-buf import probe (feat(agc): import shared direct memory through udmabuf on Linux #943) to succeed whenever the device supports dma-buf import. GitHub's Linux runner has no /dev/udmabuf, and many distributions create it as root:kvm 0660 (fix(agc): name the reason when /dev/udmabuf cannot be opened #1688), so the suite stopped in CI at (u) the dma-buf import probe failed at open /dev/udmabuf. When the probe fails at that step, the case now reports the dma-buf probe as not tested, as it does without dma-buf import. Any other probe failure still fails.
  5. test(agc): read storage results through the flush hook over unwatched memory: since fix(win): windows host import texture reuploads #2096 (0e1d25a), a refresh over an untracked import whose bytes still match the image's snapshot keeps the image's own results pending instead of storing them, as it already did over watched memory. storageRefreshTests (the unwatched case) and staleGenerationTests read the guest bytes directly and expected the refresh itself to store, so on current main they stopped at the refresh's store of the results did not reach guest memory (lavapipe and RADV, reported by @adriadam10) and then at (s) results pending when their memory left the watch did not reach it. Both now read the memory through GuestMemory::Read, as the watched storage refresh case already did. Its flush hook stores the pending results first.

No title needs this. It puts the suite that covers storage write-back, DCC keys, labels and unit shadows into CI, so every pull request that changes those paths runs it.

Tested

  • Linux (Ubuntu 24.04 on WSL2, kernel 6.18), GCC 13.2, Release, BUILD_TESTING=ON, lavapipe (Mesa 25.2.8).
  • On main, instrumented: the partial publish returns 0 and unit 3 is no longer shadowed. Right after the whole publish, UnchangedSinceCollected(unit3, unit, pre) is false: the publish's own copy was collected as a CPU write.
  • Rebased onto 5cb747c1, with CI's environment (VK_ICD_FILENAMES set to lavapipe, ANYPS5_REQUIRE_VULKAN=1, GALLIUM_OVERRIDE_CPU_CAPS=avx): agc_driver_recorder passes (0.36 s), also under ctest -R '^agc_driver_recorder$' --repeat until-fail:3 and in 3 direct runs. WSL has no /dev/udmabuf, so the dma-buf probe reports itself as not tested, as on the CI runner. With APS5_WRITE_WATCH_IMPORTS=watch (the old lavapipe decision) it fails at the partial publish did not copy exactly the partly covered unit. With VK_ICD_FILENAMES pointing at no driver it skips (77); with ANYPS5_REQUIRE_VULKAN=1 as well it fails.
  • Full ctest on that head: 558 tests. The failures are guest_sce_net and nid_patcher_dynamic_strings (the resolver test; the local toolchain adds its own RUNPATH), as on earlier bases, and guest_video_out_resolution_status, whose window gets no Vulkan from this machine's SDL offscreen driver; the video-out flip tests skip here for the same reason. tools/check_test_registration.py build passes with KNOWN_UNRUN empty, and tools/check_conventions.py passes.
  • The failure from the review reproduces on dc4b7104 without the fifth commit. It is in the first storageRefreshTests call, whose block comes from aligned_alloc and is never watched, so this PR's import decision does not reach it; RADV compares imports with or without this PR. With only fix(win): windows host import texture reuploads #2096's comparison switched off (compareUntracked returning false), the suite passes there without the fifth commit.
  • The changed checks still fail when they should. When the write-back over an untracked import counts every block as written by the CPU (the results never reach memory), the suite fails at the refresh's store of the results did not reach guest memory; with the earlier cases that catch it skipped, it fails at (s) results pending when their memory left the watch did not reach it. When results dead under new 0001 keys are kept, it fails at new 0001 clear keys did not clear the image.
  • On an earlier base, verbose ctest output with the new decision and with APS5_WRITE_WATCH_IMPORTS=watch (the old lavapipe decision) differs only in the recorder suite. There, draw snapshot reuse, unit shadows and the watched storage refresh report themselves as not run on lavapipe.
  • I have no NVIDIA, AMD or Intel hardware. Results from others on this PR:
  • Not built on Windows. The probe is Linux-only, and the Windows job has no Vulkan driver, so the test skips there. With ci: fail when a test skips that the job's device is not expected to skip #1028, the Linux job does not skip it, and on Windows it falls under the agc_driver_* rule.
  • After a failed check the suite still segfaults at exit, as on main. That path is not touched here.

Checklist

  • Based on current main; no other open PR implements the same functions
  • One topic per PR; follow-ups go in a new PR
  • No comments in code except technical debt
  • Unimplemented paths throw (NotImplemented_nid_no_patch); silent stubs are listed in TechnicalDebt
  • No notes, investigation .md files or images added to the repository (attach them to this PR)
  • New third-party code is a submodule built from source
  • Generic behaviour, not specific to one title
  • Depends on: none
  • AI-assisted: yes

🤖 Generated with Claude Code

@orbz22

orbz22 commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Tested the PR head (7857d68) on Windows with real GPUs.

Windows 11, MinGW-w64 GCC 15.2.0 (WinLibs r7), -DBUILD_TESTING=ON, target agc_driver_recorder_tests:

Case Result
default device, ctest -R '^agc_driver_recorder$' --repeat until-fail:3 passed 3/3, ~0.8 s each
default device, ANYPS5_REQUIRE_VULKAN=1 passed
VK_LOADER_DRIVERS_DISABLE=* skipped (77)
VK_LOADER_DRIVERS_DISABLE=*, ANYPS5_REQUIRE_VULKAN=1 failed, as intended
VK_LOADER_DRIVERS_SELECT=nv-vk64.json (RTX 3060 Laptop, driver 32.0.16.1692), 3 direct runs passed 3/3, "host imports stay watched"
VK_LOADER_DRIVERS_SELECT=igvk64.json (Intel UHD, driver 32.0.101.7085), 3 direct runs passed 3/3, "host imports stay watched"

No suite reported itself as not run on either driver. Tested with AI assistance (Claude Code).

@johnmaia

johnmaia commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Tested the PR head (7857d68) on AMD and Intel, since the description notes it was not run on AMD hardware.

Ubuntu 24.04.5, GCC 13.3, Release, Mesa 25.2.8; i3-12100 with RX 7600 (RADV) and UHD 730 (ANV). ctest -R '^agc_driver_recorder$' with ANYPS5_REQUIRE_VULKAN=1:

device probe line result
lavapipe yes (0 of 4 ... after a GPU read, 4 after a GPU write, 0 after the import) passes
RX 7600 yes (4 of 4 ... after a GPU read, 4 after a GPU write, 4 after the import) fails: (w) results pending over the import window did not reach guest memory
UHD 730 yes (4 of 4 ... after a GPU read, 4 after a GPU write, 0 after the import) fails: same

importWindowTests pins ImportWatch::Watch, which the driver never picks on these GPUs; it fails the same way at d8c7c0c. With it skipped when imports are compared, ANV then fails minLodTests: it takes the integer part of the fractional view minimum LOD, which VK_EXT_image_view_min_lod allows.

#1210 fixes both in the test file. With it on top of this PR, the suite passes 3/3 on all three devices.

@oneandonlydean

Copy link
Copy Markdown
Collaborator

Conflicts with main in GuestBufferMemory.hpp/.cpp (#943 changed the import-watch decision), CMakeLists.txt and tests/Recorder.cpp; please rebase. #1210 goes in first, since the suite fails on RADV and ANV without it.

@Dextroid17

Copy link
Copy Markdown
Contributor

I resolved this onto current main (084aeeb6) and ran it, because it is the fix for #1429 and it has been sitting CONFLICTING. It works — and the four conflicts are all "main moved into the same functions", not cosmetic:

1. core/libs/prx/libSceAgcDriver/CMakeLists.txt — main added agc_spirv_module_tests and agc_driver_bda_device_tests to the same foreach. Keep both and append agc_driver_recorder_tests:

foreach(agcTest IN ITEMS ... agc_driver_graphics_tests agc_driver_bda_device_tests agc_driver_recorder_tests ${agcExecutionTests})

2. Graphics/include/GuestBufferMemory.hpp — main added its own field to ImportProbe. Keep both (writtenByCpu from main, writtenAfterFill from this PR).

3. Graphics/src/GuestBufferMemory.cpp — two conflicts:

  • The host-import decision line: main now reads probe.writtenAfterSubmit != 0, and the dma-buf line below it is main's (keep the dma-buf block as main has it; this PR's line is about host imports, so it replaces the host-import line only).
state.unwatchImports = probe.writtenAfterSubmit != 0 || probe.writtenAfterFill != 0;
  • The probe's step list: main now ends with a CPU store (*base = 2 → collect(probe.writtenByCpu)), this PR adds a GPU fill. Keep both, with the fill step before the CPU store — the other order lets the fill's collect count the CPU store's pages, and then writtenAfterFill != 0 would not mean what it claims.

4. tests/Recorder.cpp — main expanded the same import-probe block with a dma-buf probe section; keep that block and add writtenAfterFill to the printed line.

Verification on 084aeeb6 + that resolution:

Lavapipe, CI environment (VK_ICD_FILENAMES → lavapipe, ANYPS5_REQUIRE_VULKAN=1, GALLIUM_OVERRIDE_CPU_CAPS=avx), three runs (CI's --repeat until-fail:3):

[write-watch] host imports resolve write protection: yes (0 of 4 scratch pages written after a GPU read, 4 after a GPU write, 0 after the import); imported ranges are compared
Recorder read tracking and label tests passed        ← 3/3, exit 0

That is the line the description predicts, and it is the failure from #1429 turning green.

Real GPU (RTX 3080 Ti, NVIDIA 615.71.09) — no behaviour change: writtenAfterFill reads 0 there, so the decision stays stay watched. Diffing the whole log against main's run of the same binary, the only difference is the added diagnostic:

[write-watch] host imports resolve write protection: no (0 of 4 scratch pages written after a GPU read, 0 after a GPU write, 4 after the import); imported ranges stay watched

So the added term is inert on a device where imports stay watched, which is what you want from a probe.

One thing worth knowing before this is registered. On the same GPU, on main, this binary already fails — at Recorder.cpp:2130, (w) a cache over the pages the import reported sees no change, identically with and without this PR, because writtenAtImport is 4 there and that block is skipped on lavapipe. Filing it separately as #1532 so it does not get attached to this change; registering the test makes CI green on lavapipe and leaves that red on NVIDIA until it is answered.

Verified against 084aeeb6; main has since moved (to b1c5f10d), so a rebase may hit fresh conflicts.

Written with AI assistance.

@Dextroid17

Copy link
Copy Markdown
Contributor

The conflict list for this branch is stale — it now conflicts in 4 files / 6 hunks on current main (4b005b22), including a main()/openDevice() conflict in Recorder.cpp and the new agc_driver_constant_store_tests skip in CMakeLists.txt. I resolved them and re-verified, because a resolution is worth little if it will not apply for you:

Refreshed onto 4b005b22 and verified

  conflicts resolved    4 files / 6 hunks, resolved by keeping both sides' intent:
                          - CMakeLists.txt   main's list + agc_driver_recorder_tests, main's constant_store skip kept
                          - GuestBufferMemory.hpp   both probe counters (writtenByCpu and writtenAfterFill)
                          - GuestBufferMemory.cpp   fill step inserted BEFORE the CPU store, so both are measured
                          - Recorder.cpp     main's int main(int argc, char** argv) + this PR's openDevice()
  build                 cmake --build build --target agc_driver_recorder_tests   -> clean
  lavapipe (CI device)  2/2 runs EXIT=0, "Recorder read tracking and label tests passed"
  the intended change   [write-watch] host imports resolve write protection: yes
                        (0 of 4 scratch pages written after a GPU read, 4 after a GPU write, 0 after the import)

That 4 after a GPU write is exactly what this PR is for — on unpatched main the same line reads 0 ... 0 ... 4 and the cache check then fails (AGC graphics: (w) a cache over the pages the import reported sees no change), so the fill-based counter genuinely changes the verdict on the Linux CI device.

Apply it without redoing the resolution: git apply 1046-refreshed.patch on top of 4b005b22, or git am if you prefer it as a commit.

1046-refreshed.patch (141 lines, applies to 4b005b22)
diff --git a/core/libs/prx/libSceAgcDriver/CMakeLists.txt b/core/libs/prx/libSceAgcDriver/CMakeLists.txt
index 8e6fb4aa..266b82bf 100644
--- a/core/libs/prx/libSceAgcDriver/CMakeLists.txt
+++ b/core/libs/prx/libSceAgcDriver/CMakeLists.txt
@@ -600,7 +600,7 @@ foreach(agcExecutionTestSource IN LISTS agcExecutionTestSources)
 endforeach()
 
 if(BUILD_TESTING)
-    foreach(agcTest IN ITEMS agc_driver_tests agc_shader_memory_tests agc_structurizer_tests agc_spirv_module_tests agc_dpp_write_tests agc_masked_select_tests agc_vop1_control_decode_tests agc_validate_program_tests agc_shader_disk_cache_tests agc_shader_control_flow_tests agc_driver_flip_tests agc_driver_pm4_tests agc_driver_graphics_tests agc_driver_bda_device_tests ${agcExecutionTests})
+    foreach(agcTest IN ITEMS agc_driver_tests agc_shader_memory_tests agc_structurizer_tests agc_spirv_module_tests agc_dpp_write_tests agc_masked_select_tests agc_vop1_control_decode_tests agc_validate_program_tests agc_shader_disk_cache_tests agc_shader_control_flow_tests agc_driver_flip_tests agc_driver_pm4_tests agc_driver_graphics_tests agc_driver_bda_device_tests agc_driver_recorder_tests ${agcExecutionTests})
         if(agcTest STREQUAL "agc_driver_constant_store_tests")
             continue()
         endif()
diff --git a/core/libs/prx/libSceAgcDriver/Graphics/include/GuestBufferMemory.hpp b/core/libs/prx/libSceAgcDriver/Graphics/include/GuestBufferMemory.hpp
index bb9428fe..91b269e8 100644
--- a/core/libs/prx/libSceAgcDriver/Graphics/include/GuestBufferMemory.hpp
+++ b/core/libs/prx/libSceAgcDriver/Graphics/include/GuestBufferMemory.hpp
@@ -43,6 +43,7 @@ struct ImportProbe {
     std::uint32_t writtenAtImport = 0;
     std::uint32_t writtenAfterSubmit = 0;
     std::uint32_t writtenByCpu = 0;
+    std::uint32_t writtenAfterFill = 0;
 };
 
 ImportProbe ProbeImportWriteProtection(const Context& context);
diff --git a/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp b/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp
index 3d96f101..4a9fef8e 100644
--- a/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp
+++ b/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp
@@ -309,8 +309,8 @@ void decideImportWatch(const Context& context, HostImports& state) {
         state.unwatchImports = true;
         std::fprintf(stderr, "[write-watch] host imports resolve write protection: unknown (probe failed at %s, %d); imported ranges are compared\n", probe.failure, static_cast<int>(probe.result));
     } else {
-        state.unwatchImports = probe.writtenAfterSubmit != 0;
-        std::fprintf(stderr, "[write-watch] host imports resolve write protection: %s (%u of %u scratch pages written after a GPU read, %u after the import); imported ranges %s\n", state.unwatchImports ? "yes" : "no", probe.writtenAfterSubmit, probe.pages, probe.writtenAtImport, state.unwatchImports ? "are compared" : "stay watched");
+        state.unwatchImports = probe.writtenAfterSubmit != 0 || probe.writtenAfterFill != 0;
+        std::fprintf(stderr, "[write-watch] host imports resolve write protection: %s (%u of %u scratch pages written after a GPU read, %u after a GPU write, %u after the import); imported ranges %s\n", state.unwatchImports ? "yes" : "no", probe.writtenAfterSubmit, probe.pages, probe.writtenAfterFill, probe.writtenAtImport, state.unwatchImports ? "are compared" : "stay watched");
     }
     state.unwatchDmaBufImports = state.unwatchImports;
     if (!context.dmaBufImport) return;
@@ -1145,7 +1145,7 @@ void runImportProbe(const Context& context, std::uint64_t base, std::uint64_t by
     HostImport import{base, bytes, VK_NULL_HANDLE, VK_NULL_HANDLE, 0};
     VkBuffer destination = VK_NULL_HANDLE;
     VkDeviceMemory destinationMemory = VK_NULL_HANDLE;
-    VkCommandBuffer commands = VK_NULL_HANDLE;
+    VkCommandBuffer commands[2]{};
     VkFence fence = VK_NULL_HANDLE;
     bool submitted = false;
     const auto collect = [&](std::uint32_t& pages) {
@@ -1175,24 +1175,34 @@ void runImportProbe(const Context& context, std::uint64_t base, std::uint64_t by
         VkCommandBufferAllocateInfo allocate{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO};
         allocate.commandPool = context.pool;
         allocate.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
-        allocate.commandBufferCount = 1;
-        if ((probe.result = context.Function<PFN_vkAllocateCommandBuffers>("vkAllocateCommandBuffers")(context.device, &allocate, &commands)) != VK_SUCCESS) return "vkAllocateCommandBuffers";
+        allocate.commandBufferCount = 2;
+        if ((probe.result = context.Function<PFN_vkAllocateCommandBuffers>("vkAllocateCommandBuffers")(context.device, &allocate, commands)) != VK_SUCCESS) return "vkAllocateCommandBuffers";
         VkCommandBufferBeginInfo begin{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
         begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
-        if ((probe.result = context.Function<PFN_vkBeginCommandBuffer>("vkBeginCommandBuffer")(commands, &begin)) != VK_SUCCESS) return "vkBeginCommandBuffer";
+        if ((probe.result = context.Function<PFN_vkBeginCommandBuffer>("vkBeginCommandBuffer")(commands[0], &begin)) != VK_SUCCESS) return "vkBeginCommandBuffer";
         const VkBufferCopy region{0, 0, bytes};
-        context.Function<PFN_vkCmdCopyBuffer>("vkCmdCopyBuffer")(commands, import.buffer, destination, 1, &region);
-        if ((probe.result = context.Function<PFN_vkEndCommandBuffer>("vkEndCommandBuffer")(commands)) != VK_SUCCESS) return "vkEndCommandBuffer";
+        context.Function<PFN_vkCmdCopyBuffer>("vkCmdCopyBuffer")(commands[0], import.buffer, destination, 1, &region);
+        if ((probe.result = context.Function<PFN_vkEndCommandBuffer>("vkEndCommandBuffer")(commands[0])) != VK_SUCCESS) return "vkEndCommandBuffer";
+        if ((probe.result = context.Function<PFN_vkBeginCommandBuffer>("vkBeginCommandBuffer")(commands[1], &begin)) != VK_SUCCESS) return "vkBeginCommandBuffer";
+        context.Function<PFN_vkCmdFillBuffer>("vkCmdFillBuffer")(commands[1], import.buffer, 0, bytes, 0x02020202u);
+        if ((probe.result = context.Function<PFN_vkEndCommandBuffer>("vkEndCommandBuffer")(commands[1])) != VK_SUCCESS) return "vkEndCommandBuffer";
         const VkFenceCreateInfo fenceInfo{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO};
         if ((probe.result = context.Function<PFN_vkCreateFence>("vkCreateFence")(context.device, &fenceInfo, nullptr, &fence)) != VK_SUCCESS) return "vkCreateFence";
-        VkSubmitInfo submit{VK_STRUCTURE_TYPE_SUBMIT_INFO};
-        submit.commandBufferCount = 1;
-        submit.pCommandBuffers = &commands;
-        if ((probe.result = context.Function<PFN_vkQueueSubmit>("vkQueueSubmit")(context.queue, 1, &submit, fence)) != VK_SUCCESS) return "vkQueueSubmit";
-        submitted = true;
-        if ((probe.result = context.Function<PFN_vkWaitForFences>("vkWaitForFences")(context.device, 1, &fence, VK_TRUE, 10'000'000'000ull)) != VK_SUCCESS) return "vkWaitForFences";
-        submitted = false;
+        const auto execute = [&](VkCommandBuffer buffer) -> const char* {
+            if ((probe.result = context.Function<PFN_vkResetFences>("vkResetFences")(context.device, 1, &fence)) != VK_SUCCESS) return "vkResetFences";
+            VkSubmitInfo submit{VK_STRUCTURE_TYPE_SUBMIT_INFO};
+            submit.commandBufferCount = 1;
+            submit.pCommandBuffers = &buffer;
+            if ((probe.result = context.Function<PFN_vkQueueSubmit>("vkQueueSubmit")(context.queue, 1, &submit, fence)) != VK_SUCCESS) return "vkQueueSubmit";
+            submitted = true;
+            if ((probe.result = context.Function<PFN_vkWaitForFences>("vkWaitForFences")(context.device, 1, &fence, VK_TRUE, 10'000'000'000ull)) != VK_SUCCESS) return "vkWaitForFences";
+            submitted = false;
+            return nullptr;
+        };
+        if (const char* step = execute(commands[0])) return step;
         if (!collect(probe.writtenAfterSubmit)) return "the collect after the submission";
+        if (const char* step = execute(commands[1])) return step;
+        if (!collect(probe.writtenAfterFill)) return "the collect after the fill";
         *reinterpret_cast<volatile std::uint8_t*>(base) = 2;
         if (!collect(probe.writtenByCpu)) return "the collect after a CPU store";
         return nullptr;
@@ -1200,7 +1210,7 @@ void runImportProbe(const Context& context, std::uint64_t base, std::uint64_t by
     probe.failure = run();
     if (submitted) context.Function<PFN_vkQueueWaitIdle>("vkQueueWaitIdle")(context.queue);
     if (fence != VK_NULL_HANDLE) context.Function<PFN_vkDestroyFence>("vkDestroyFence")(context.device, fence, nullptr);
-    if (commands != VK_NULL_HANDLE) context.Function<PFN_vkFreeCommandBuffers>("vkFreeCommandBuffers")(context.device, context.pool, 1, &commands);
+    if (commands[0] != VK_NULL_HANDLE) context.Function<PFN_vkFreeCommandBuffers>("vkFreeCommandBuffers")(context.device, context.pool, 2, commands);
     if (destination != VK_NULL_HANDLE) context.Function<PFN_vkDestroyBuffer>("vkDestroyBuffer")(context.device, destination, nullptr);
     if (destinationMemory != VK_NULL_HANDLE) context.Function<PFN_vkFreeMemory>("vkFreeMemory")(context.device, destinationMemory, nullptr);
     if (import.buffer != VK_NULL_HANDLE) destroyImport(context, import);
diff --git a/core/libs/prx/libSceAgcDriver/tests/Recorder.cpp b/core/libs/prx/libSceAgcDriver/tests/Recorder.cpp
index 5d478262..e95b74a7 100644
--- a/core/libs/prx/libSceAgcDriver/tests/Recorder.cpp
+++ b/core/libs/prx/libSceAgcDriver/tests/Recorder.cpp
@@ -1701,7 +1701,7 @@ void importWatchTests(const Device& device) {
     }
     const auto probe = ProbeImportWriteProtection(context);
     Require(probe.failure == nullptr, std::string("(u) the import probe failed at ") + (probe.failure != nullptr ? probe.failure : "") + " (" + std::to_string(static_cast<int>(probe.result)) + ")");
-    std::cout << "import probe: " << probe.writtenAfterSubmit << " of " << probe.pages << " scratch pages written after a GPU read, " << probe.writtenAtImport << " after the import\n";
+    std::cout << "import probe: " << probe.writtenAfterSubmit << " of " << probe.pages << " scratch pages written after a GPU read, " << probe.writtenAfterFill << " after a GPU write, " << probe.writtenAtImport << " after the import\n";
     Require(probe.writtenByCpu != 0, "(u) a CPU store after the import probe's GPU read was not collected");
     if (context.dmaBufImport) {
         const auto dmaBuf = ProbeDmaBufImportWriteProtection(context);
@@ -2863,9 +2863,21 @@ void keysFillTests(const Device& device, Recorder& recorder) {
     recorder.Sync();
 }
 
+std::unique_ptr<Device> openDevice() {
+    try {
+        return std::make_unique<Device>();
+    } catch (const std::exception& error) {
+        if (std::getenv("ANYPS5_REQUIRE_VULKAN") != nullptr) throw;
+        std::cout << "skipped, no usable Vulkan device: " << error.what() << '\n';
+        return nullptr;
+    }
+}
+
 int main(int argc, char** argv) {
     try {
-        Device device;
+        const auto opened = openDevice();
+        if (opened == nullptr) return 77;
+        auto& device = *opened;
         std::lock_guard gpu(GpuMutex());
         std::cout << "host imports " << (PrepareImportWatch(device.GetContext()) == ImportWatch::Unwatch ? "are compared" : "stay watched") << '\n';
         Recorder recorder(device.GetContext());

It is your call whether to fold this in or resolve it yourself — I am not opening a competing PR, since the work is yours. If you would rather I hand it over as a branch on my fork instead of a patch in a comment, say the word.

Written with AI assistance.

@MarcZX10

MarcZX10 commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

While reviewing #1532 against the current #1046 head, I found the same range mismatch in importWindowTests: importWrites comes from ProbeImportWriteProtection on a separate scratch mapping, then the test takes its cached generation after constructing the StorageTexture and calls HostImportFor on that existing import. The scratch probe does not establish that this range changed. The issue's instrumented result (cached=160, after=161, unchanged=1) also shows the generation increment alone is not evidence of a write.

I made a local test-only patch that branches on the actual range's UnchangedSince(address, surfaceBytes, cached) result after collecting it. If the range changed, it checks the changed-block and CPU-store invariants; otherwise it reports that this range had no writes and still runs the pending GPU-result checks. The test target builds and the binary passes on lavapipe here, but this environment reports no write watching: the import window not tested, so that run does not validate the NVIDIA path. The issue's independent device measurements support the range-specific premise.

Since #1046 already changes and registers this same test, I have not opened a duplicate PR. Please consider folding this correction into #1046, or let me know if the importWrites condition is intended to prove a different property.

@Dextroid17

Copy link
Copy Markdown
Contributor

I read the test at the current head (7857d689fa) and @MarcZX100's reading is accurate. Correcting my own first grep, which missed it: this PR does touch those lines, but only in the printout.

  • The test's branch is taken on the scratch probe — ProbeImportWriteProtection(context) → probe.writtenAtImport. That establishes that the write-watch machinery engages after an import in a scratch mapping; it does not establish that the import range this test then exercises changed.
  • The actual logic change is in Graphics/src/GuestBufferMemory.cpp (unwatchImports = probe.writtenAfterSubmit != 0 || probe.writtenAfterFill != 0); this PR's change to tests/Recorder.cpp is the added writtenAfterFill counter in the printed line. So @MarcZX100's correction is additive to the test, not a rewrite of the fix.
  • The range-specific evidence is the actual range's own UnchangedSince(address, surfaceBytes, cached) taken after the write, which is what his test-only patch uses.
  • Neither his local run nor a software-Vulkan run settles the NVIDIA path: the test itself reports no write watching: the import window not tested when the host cannot watch writes.

The question stands for @aryanrawatispro-ship-it: if writtenAtImport is meant to prove a different property (that the watch mechanism engages at all), the test's message should say so; otherwise the gate should be that range's own UnchangedSince.

@Dextroid17

Copy link
Copy Markdown
Contributor

Third arm, measured on hardware — @MarcZX100's reading holds, and here is what it costs.

Setup: a local branch = this PR's four files applied to current main (ae91d978), plus a test-only edit that replaces the scratch-probe gate with the range's own state:

-    const bool importWrites = probe.writtenAtImport != 0;
 ...
-        if (importWrites) {
-            Require(!UnchangedSince(address, surfaceBytes, cached), "(w) a cache over the pages the import reported sees no change");
+        if (!UnchangedSince(address, surfaceBytes, cached)) {

build/tests/agc_driver_recorder_tests, ANYPS5_REQUIRE_VULKAN=1, exit codes:

arm NVIDIA RTX 3080 Ti lavapipe
this PR as-is 1 — AGC graphics: (w) a cache over the pages the import reported sees no change 0
range-specific gate 0 — the import reports no pages here: only the pending results of the import window checked 0
range-specific gate, Graphics/src/GuestBufferMemory.cpp back at main 0 (not caught) 1 — AGC graphics: the partial publish did not copy exactly the partly covered unit

Three things this settles:

  1. The gate is a proxy, as he says. On NVIDIA the scratch probe reports 4 after the import while the range the case then asserts about reads unchanged, so without the correction the case fails on a condition that range never met.
  2. The correction removes that false failure (NVIDIA 1 → 0) and takes the honest branch instead.
  3. It also leaves the import-window invariants unexercised on both devices I have. NVIDIA takes the else branch; lavapipe bails earlier (host imports are compared, not watched: the import window not tested). So the gate becomes honest but, on this hardware, toothless for the import window — a RADV/AMD datapoint is what would show the branch actually running.

The negative control does still bite, elsewhere: with writtenAfterFill reverted, lavapipe fails at the partial publish did not copy exactly the partly covered unit, so the fill half of this PR is load-bearing for that case — and NVIDIA is blind to it.

One more thing for the reviewer: this PR also registers agc_driver_recorder_tests into ctest (EXCLUDE_FROM_ALL OFF + add_test), so whichever gate lands becomes part of the suite. On this evidence, the range-specific gate is the one to register.

No PR from me — the change belongs in this one. Written with AI assistance.

@johnmaia

johnmaia commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

RADV/ANV datapoint for the question at the end of @Dextroid17's last comment. Setup: this PR's head 7857d689 merged onto main f1451396, with the conflicts resolved as in his refreshed patch (fill step before the CPU store). Machine: Ubuntu 24.04.5, Mesa 25.2.8. agc_driver_recorder_tests with ANYPS5_REQUIRE_VULKAN=1 passes (exit 0) on all three devices here:

device import probe (after a GPU read / after a GPU write / after the import) decision import window
RX 7600 (RADV) 4 / 4 / 4 of 4 compared not tested
UHD 730 (ANV) 4 / 4 / 0 of 4 compared not tested
llvmpipe 0 / 4 / 0 of 4 compared not tested

So the import-window branch can't be reached on AMD (or Intel): a GPU read through a host import already dirties the pages, imports are compared, and the case returns before either gate is evaluated. Forcing APS5_WRITE_WATCH_IMPORTS=watch doesn't help. It fails earlier on all three with the partial publish did not copy exactly the partly covered unit, which is the miss this PR's fill counter exists to catch, and RADV/ANV also lose the device. On these devices only NVIDIA keeps imports watched, so the gate question can only be settled there. The llvmpipe row (0 after a read, 4 after a write) is this PR's change doing its job.

@aryanrawatispro-ship-it

aryanrawatispro-ship-it commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor Author

Rebased onto f1451396. I resolved the conflicts the way @Dextroid17 laid out:

  • The probe keeps both counters (writtenByCpu from main, writtenAfterFill from this PR).
  • The GPU fill runs before main's CPU store, so writtenAfterFill does not count the store's pages.
  • main's dma-buf decision is unchanged. Only the host-import decision line changes.
  • agc_driver_recorder_tests is added to main's ctest list. main's main(int argc, char** argv) is kept, together with openDevice().

On the gate question from @MarcZX100 and @Dextroid17: writtenAtImport was not meant to prove anything about the range the test uses. It was a proxy, and on NVIDIA it was the wrong one. A new commit, 08939059 test(agc): gate the import window checks on the range's own writes, branches on UnchangedSince(address, surfaceBytes, cached) after the collect. This is the same test-only change Dextroid17 measured: NVIDIA 1 → 0, lavapipe stays 0. Thanks to @johnmaia for the RADV/ANV run. It shows that only NVIDIA keeps imports watched, so only NVIDIA reaches that branch.

Local results on the rebased head (WSL2, GCC 13.2, Release, lavapipe from Mesa 25.2.8):

  • [write-watch] host imports resolve write protection: yes (0 of 4 scratch pages written after a GPU read, 4 after a GPU write, 0 after the import); imported ranges are compared
  • In WSL the suite stops at (u) the dma-buf import probe failed at open /dev/udmabuf. WSL has no /dev/udmabuf, and that check comes from main's dma-buf probe, not from this PR. With that one check skipped in a local-only build, it passes 3/3.
  • Negative control: with APS5_WRITE_WATCH_IMPORTS=watch it fails at the partial publish did not copy exactly the partly covered unit.
  • With no driver it skips (77), and with ANYPS5_REQUIRE_VULKAN=1 as well it fails.
  • Full ctest: 495 tests. Apart from the dma-buf stop above, the only failures are guest_sce_net and nid_patcher_dynamic_strings, which also fail on main in this toolchain.

I have no NVIDIA, AMD or Intel hardware here, so the NVIDIA result for the new gate is Dextroid17's, not mine.

Written with AI assistance (Claude Code).

@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 9, 2026
@github-actions github-actions Bot added the needs rebase Conflicts with main, rebase needed label Oct 9, 2026
@Dextroid17

Copy link
Copy Markdown
Contributor

Verified your head (f2124645) on the device CI actually runs — lavapipe extracted locally, Mesa 26.2.4 / LLVM 23.1.1:

agc_driver_recorder         Passed  0.20 s
agc_recorder_kept_bytes     Passed  0.00 s

Both green, so the registration and the gating both hold on the CI device.

One gotcha worth knowing, because I hit it myself: if a newly registered test fails in 0.00 s with no output at all, its binary was not built — agc_recorder_kept_bytes did exactly that to me until I built the target explicitly. That was my error, not your branch's, and I mention it only because the same thing will bite anyone who runs ctest without a full build.

I have not re-derived your NVIDIA 1 → 0, lavapipe stays 0 numbers, so this confirms the CI device only. The part I did measure earlier — that the check should branch on UnchangedSince(address, surfaceBytes, cached) rather than on writtenAtImport — is what your 08939059 does, and it reads correctly.

Written with AI assistance.

@MarcZX10

MarcZX10 commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

I checked the current PR head against upstream main (d70b8998). git merge-tree confirms real content conflicts in core/libs/prx/libSceAgcDriver/CMakeLists.txt and tools/check_test_registration.py. CONTRIBUTING asks PR branches to stay current with main; please rebase/resolve these two files, then rerun the checks on the new head. The existing green Linux/Windows results are for the previous head f2124645.

@adriadam10 adriadam10 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the work and for keeping this current. It conflicts with main in core/libs/prx/libSceAgcDriver/CMakeLists.txt and tools/check_test_registration.py; please rebase.

I rebased it locally onto bfef67f36 (the two conflicts are mechanical: keep main's list plus agc_driver_recorder_tests, and drop it from KNOWN_UNRUN). After that agc_driver_recorder fails, with CI's setup (lavapipe from Mesa 25.2.8, ANYPS5_REQUIRE_VULKAN=1, GALLIUM_OVERRIDE_CPU_CAPS=avx) and on RADV:

AGC graphics: the refresh's store of the results did not reach guest memory

It comes from core/libs/prx/libSceAgcDriver/tests/Recorder.cpp:1764 (memoryHolds). main has changed Recorder.cpp and Graphics/ since f2124645 (#2176, #1441, #2299, #856), so please check which of them this case now depends on, fix it, and rerun agc_driver_recorder on the new head. Registering the test in ctest means the Linux job goes red until it passes.

The import probe only checked whether a GPU read of an imported range
reports its pages as written. On a device that runs its commands on the
CPU (lavapipe), the device's own writes into an import go through the
page tables and are collected as CPU stores. A unit shadow publish then
marks its unit as written, and the next partial publish drops the slab's
newer bytes as stale: agc_driver_recorder_tests stops at "the partial
publish did not copy exactly the partly covered unit".

The probe now also fills the scratch import on the GPU and collects
again. When the fill reports pages as written, imported ranges are
compared, as they are when a read does. lavapipe reports 0 of 4 pages
after the read and 4 of 4 after the fill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
agc_driver_recorder_tests was built and run by hand only. It is now
registered with the other driver tests and skips with 77 when there is
no usable Vulkan device, unless ANYPS5_REQUIRE_VULKAN is set.

The draw snapshot reuse test assumed watched imports. With imports
compared (amdgpu, lavapipe, or APS5_WRITE_WATCH_IMPORTS=unwatch), a draw
copies its input every time, as intended, so the test reports itself as
not run there, like the unit shadow test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
importWindowTests took its write checks when the import probe reported
pages written at import time. The probe runs on a separate scratch
mapping, so it does not show that this test's range changed. On NVIDIA
the probe reports 4 of 4 pages while the range reads unchanged, and the
case failed at "a cache over the pages the import reported sees no
change" (boykopovar#1532).

The case now branches on the range's own UnchangedSince after the
collect. When the range changed, it checks the changed blocks and that
the window does not read as a CPU store. Otherwise it checks only the
pending results, as it did before when the probe reported no pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mabuf

importWatchTests required the dma-buf import probe to succeed whenever
the device supports dma-buf import. The probe also needs /dev/udmabuf.
GitHub's Linux runner has no such node, and many distributions create
it as root:kvm 0660 (boykopovar#1688). The probe then fails at "open
/dev/udmabuf", and the suite stopped there in CI. The driver already
handles this case: such ranges are copied instead of imported.

When the probe fails at that step, the case now reports the dma-buf
probe as not tested, as it does when the device has no dma-buf import.
Any other probe failure still fails the case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… memory

0e1d25a (boykopovar#2096) compares an untracked import's bytes with the image's
snapshot. When they still match, a refresh keeps the image's own
results pending instead of storing them, as it already does over
watched memory. storageRefreshTests (unwatched case) and
staleGenerationTests read the guest bytes directly, so they expected
the refresh itself to store the results, and stopped at "the refresh's
store of the results did not reach guest memory" and "(s) results
pending when their memory left the watch did not reach it".

Both now read the memory through GuestMemory::Read, as the watched
storage refresh case already does; its flush hook stores the pending
results first. Results that were dropped still fail both checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 9, 2026
@aryanrawatispro-ship-it

Copy link
Copy Markdown
Contributor Author

@adriadam10 Rebased onto 5cb747c1. I resolved the two conflicts as you described: main's list plus agc_driver_recorder_tests, and the test dropped from KNOWN_UNRUN. #1225 has since removed video_out_flip_tests from that set as well, so it is now set().

The failure comes from #2096 (0e1d25a8), not from #2176, #1441, #2299 or #856. It is in the first storageRefreshTests call. That call's block comes from aligned_alloc and is never watched, so the import decision in this PR does not reach it. This is also why RADV fails: it compares imports with or without this PR. Since #2096, a refresh over an untracked import compares its bytes with the image's snapshot. When they still match, the refresh keeps the image's own results pending instead of storing them, as it already did over watched memory. The test read texels directly, so it expected the refresh itself to store them. On dc4b7104 with only compareUntracked switched off, the suite passed without any test change.

New commit 9579e622 reads that memory through GuestMemory::Read, as the watched case already did. Its flush hook stores the pending results first. staleGenerationTests read its bytes the same way and stopped next, at (s) results pending when their memory left the watch did not reach it, so it gets the same change. Both checks still fail when the results never reach memory: I tried a write-back that counts every untracked block as written by the CPU.

On the new head, with CI's environment (lavapipe from Mesa 25.2.8, ANYPS5_REQUIRE_VULKAN=1, GALLIUM_OVERRIDE_CPU_CAPS=avx):

  • agc_driver_recorder passes, also under --repeat until-fail:3.
  • With APS5_WRITE_WATCH_IMPORTS=watch (the old lavapipe decision) it still fails at the partial publish did not copy exactly the partly covered unit.
  • Full ctest: 558 tests. The only failures are local: guest_sce_net, nid_patcher_dynamic_strings, and guest_video_out_resolution_status, whose window gets no Vulkan from this machine's SDL offscreen driver.

I have no RADV device. The two changed checks take the same unwatched path on every device, but I have not run the rest of the suite on RADV on this head.

Written with AI assistance (Claude Code).

@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

Other open pull requests touch the same code. Merging one will break the other, or both implement the same thing. If this one builds on them, list them in Depends on.

Pull request Conflicting files (lines on main) Same exports Same new files
#1309 core/libs/prx/libSceAgcDriver/CMakeLists.txt L673
#1331 core/libs/prx/libSceAgcDriver/tests/Recorder.cpp L2971
#1640 core/libs/prx/libSceAgcDriver/CMakeLists.txt L673
#1959 core/libs/prx/libSceAgcDriver/CMakeLists.txt L673
#2470 core/libs/prx/libSceAgcDriver/CMakeLists.txt L673
#2501 core/libs/prx/libSceAgcDriver/CMakeLists.txt L673

@github-actions github-actions Bot added the needs rebase Conflicts with main, rebase needed label Oct 9, 2026
oneandonlydean added a commit that referenced this pull request Oct 11, 2026
The import probe only checked whether a GPU read of an imported range
reports its pages as written. lavapipe runs command buffers on CPU
threads, so its writes into an import go through the page tables and
are collected as CPU stores. In agc_unit_shadow the whole publish's
copy into the import is then collected as a CPU store over unit 3, and
the next partial publish drops the slab's newer bytes as stale: "the
partial publish did not copy exactly the partly covered unit".

The probe now also fills the scratch range on the GPU and collects
again. When the read or the fill reports pages as written, host and
dma-buf imports are compared. lavapipe reports 0 of 4 pages after the
read and 4 of 4 after the fill; NVIDIA reports 0 after the fill.

The probe change is the first commit of #1046.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agc libSceAgc* build needs rebase Conflicts with main, rebase needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

agc_driver_recorder_tests: partial publish drops a shadowed unit without copying it

7 participants