Skip to content

perf(libc): size each write-watch query to the region it covers - #4875

Merged
oneandonlydean merged 1 commit into
boykopovar:mainfrom
nikowolf123:perf/libc-write-watch-capacity
Oct 13, 2026
Merged

oneandonlydean merged 1 commit into
boykopovar:mainfrom
nikowolf123:perf/libc-write-watch-capacity

Conversation

@nikowolf123

@nikowolf123 nikowolf123 commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

What

WindowsMappings::Collect walks a range one VirtualQuery region at a time and gives each GetWriteWatch call the whole remaining output capacity, up to the tracker's 65536 entries. The call's cost follows that capacity, not the size of the range it walks. Flexible memory is committed in 16 KiB allocations, one region each, so a collect over it pays the full-capacity price once per 16 KiB.

Each query now gets room for at most the pages its region spans, which is the most it can report. The pages returned, their order and the overflow handling when the shared buffer fills are unchanged: the capacity is still capped by what is left in it. The span counts every 4 KiB page the walked part of the region touches, so a walk that starts inside a page still has room for that page.

This is the GetWriteWatch buffer sizing from DotDebian's #2863, split out as oneandonlydean and adriadam10 asked in that PR's discussion and review. #2863 carries it inside its queue-worker prewalk change and conflicts with main; once this lands, #2863 can drop its copy. #2863's formula, (stop - cursor + 4095) / 4096, is one page short when the walk starts inside a page and ends inside the next one; this version rounds both ends. Other open PRs that touch WindowsMappings.hpp (#4661, #4535, #4367) merge cleanly with it.

Numbers on Windows 11 26200, i9-14900KF, median of 2000 calls or 31 rounds (other build jobs were running, load average about 21):

GetWriteWatch on a clean 16 KiB allocation capacity 4 4096 16384 65536
per call 0.20 us 0.60 us 2.00 us 6.50 us
resetting walk of 33 MiB in 16 KiB allocations (2112 regions) main (remaining capacity) this PR (clamped) memcmp of the same bytes
no store 15.06 ms 1.13 ms 2.11 ms
every page stored 15.35 ms 1.67 ms 2.20 ms

The benchmark is standalone and reproduces the region walk of Collect outside the repository:

clamp_bench.cpp
#include <windows.h>
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <vector>

using VirtualAlloc2Fn = PVOID(WINAPI*)(HANDLE, PVOID, SIZE_T, ULONG, ULONG, MEM_EXTENDED_PARAMETER*, ULONG);

static double Now() {
    static const double perMs = [] { LARGE_INTEGER f; QueryPerformanceFrequency(&f); return f.QuadPart / 1000.0; }();
    LARGE_INTEGER c;
    QueryPerformanceCounter(&c);
    return c.QuadPart / perMs;
}

static double Median(std::vector<double> values) {
    std::sort(values.begin(), values.end());
    return values[values.size() / 2];
}

int main() {
    const auto allocate2 = reinterpret_cast<VirtualAlloc2Fn>(GetProcAddress(LoadLibraryA("kernelbase.dll"), "VirtualAlloc2"));
    constexpr std::size_t bytes = std::size_t{33} << 20;
    constexpr std::size_t granule = std::size_t{16} << 10;
    constexpr ULONG_PTR trackerPages = 65536;
    auto* base = static_cast<std::uint8_t*>(allocate2(GetCurrentProcess(), nullptr, bytes, MEM_RESERVE | MEM_RESERVE_PLACEHOLDER, PAGE_NOACCESS, nullptr, 0));
    for (std::size_t offset = 0; offset < bytes; offset += granule) {
        if (offset + granule < bytes) VirtualFree(base + offset, granule, MEM_RELEASE | MEM_PRESERVE_PLACEHOLDER);
        if (!allocate2(GetCurrentProcess(), base + offset, granule, MEM_RESERVE | MEM_COMMIT | MEM_REPLACE_PLACEHOLDER | MEM_WRITE_WATCH, PAGE_READWRITE, nullptr, 0)) return 1;
    }
    std::memset(base, 1, bytes);
    std::vector<void*> pages(trackerPages);
    std::vector<std::uint8_t> copy(base, base + bytes);
    const auto walk = [&](bool clamp) {
        ULONG_PTR found = 0;
        for (std::size_t offset = 0; offset < bytes; offset += granule) {
            MEMORY_BASIC_INFORMATION memory{};
            VirtualQuery(base + offset, &memory, sizeof(memory));
            ULONG_PTR available = clamp ? std::min<ULONG_PTR>(trackerPages - found, granule / 4096) : trackerPages - found;
            DWORD pageSize = 0;
            GetWriteWatch(WRITE_WATCH_FLAG_RESET, base + offset, granule, pages.data() + found, &available, &pageSize);
            found += available;
        }
        return found;
    };
    walk(false);
    std::printf("per-call GetWriteWatch on one clean 16 KiB allocation, median of 2000 calls:\n");
    for (const ULONG_PTR capacity : {ULONG_PTR{4}, ULONG_PTR{4096}, ULONG_PTR{16384}, ULONG_PTR{65536}}) {
        std::vector<double> times;
        for (int i = 0; i < 2000; ++i) {
            ULONG_PTR available = capacity;
            DWORD pageSize = 0;
            const double start = Now();
            GetWriteWatch(WRITE_WATCH_FLAG_RESET, base, granule, pages.data(), &available, &pageSize);
            times.push_back((Now() - start) * 1000.0);
        }
        std::printf("  capacity %6llu: %.2f us\n", static_cast<unsigned long long>(capacity), Median(times));
    }
    std::printf("resetting walk of 33 MiB in 16 KiB allocations (2112 regions), median of 31 rounds:\n");
    for (const bool storeEveryPage : {false, true}) {
        std::vector<double> remaining, clamped, compare;
        for (int round = 0; round < 31; ++round) {
            for (const bool clamp : {false, true}) {
                if (storeEveryPage) for (std::size_t offset = 0; offset < bytes; offset += 4096) base[offset] ^= 1;
                const double start = Now();
                const auto found = walk(clamp);
                (clamp ? clamped : remaining).push_back(Now() - start);
                if (storeEveryPage && found != bytes / 4096) return 2;
                if (!storeEveryPage && found != 0) return 3;
            }
            const double start = Now();
            volatile int differs = std::memcmp(base, copy.data(), bytes);
            static_cast<void>(differs);
            compare.push_back(Now() - start);
        }
        std::printf("  %s: remaining capacity %.2f ms, clamped %.2f ms, memcmp %.2f ms\n", storeEveryPage ? "every page stored" : "no store", Median(remaining), Median(clamped), Median(compare));
    }
    return 0;
}

No title trace: I have no PS5 or game files. Which titles keep most of their write-watched memory in 16 KiB flexible-memory allocations is not measured here; the saving applies to every collect over such memory.

Tested

Windows 11 Pro 26200, i9-14900KF, RTX 5080 (NVIDIA 616.92), WinLibs GCC 15.2.0, Release with tests:

  • New CheckSplitPrivateCollect in guest_memory: a resetting collect over eight 16 KiB allocations reports every stored page exactly once and in order with an output capacity of 32, 5 and 1, then nothing until the next store; a single store in the fourth allocation reports only that page. It passes with main's Collect and with this change, which is the point: the change must not alter what a collect returns.
  • guest_memory: 30 of 30 runs (ctest --repeat until-fail:30).
  • Full native ctest (ANYPS5_REQUIRE_VULKAN=1, --parallel 4) on this commit: 1021 tests, 0 failed.
  • Linux and macOS: not affected; WindowsMappings.hpp and the new check are Windows only.
  • tools/check_conventions.py --base origin/main with this title and body: passed.
  • Rebased onto main 2f969f1 together with perf(agc): compare Windows direct imports only after a write-watch mark #4878 (no conflicts in this PR's files): native full ctest with both changes, 1064 tests, 0 failed; guest_memory 10 of 10 runs. On cac4e96 the only failure was patched_libraries, which failed the same way on unmodified main then (feat(libkernel_sys): implement renameat #4877; fixed by fix(libkernel_sys): reach rename through a _nid_no_patch export #5025).

In a title, measured by @gjonn on Windows 11 with an RTX 5090 Laptop GPU while profiling Gran Turismo 7 (PPSA01317), on a local branch with the same per-segment cap (comment):

  • GetWriteWatch calls went from about 8 µs to about 0.5 µs on average.
  • The write-watch walk time on the queue workers in a race scene fell from 2.5-3.6 s to 0.1-0.4 s per 10 s.

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 under Silent stubs in their library's technical debt file
  • 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
  • Unblocks: none
  • GPUs tested: n/a
  • AI-assisted: yes. Written with Claude Code; I reviewed every line and ran the tests above.

🤖 Generated with Claude Code

WindowsMappings::Collect handed GetWriteWatch the whole remaining output
capacity for every private region it walked. The call's cost follows that
capacity, not the size of the range: on Windows 11 (26200) a query of one
16 KiB region took 0.35 us with room for 4 entries and 6.5 us with room
for the 65536 the write tracker passes. Flexible memory is committed in
16 KiB allocations, one region each, so a resetting walk over 33 MiB of it
took 14.7 ms, 5.8 times a memcmp of the same bytes.

Each query now gets room for the pages its region spans, which is the most
it can report, so every walk returns the same pages; the 33 MiB walk takes
1.23 ms. guest_memory checks that a walk over eight 16 KiB allocations
reports every stored page once, in order, with room for 32, 5 and 1 pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nikowolf123
nikowolf123 force-pushed the perf/libc-write-watch-capacity branch from b803cbb to 44767a7 Compare October 13, 2026 11:57
@gjonn

gjonn commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Measured this while profiling PPSA01317 on Windows 11 (RTX 5090 Laptop). The cost of GetWriteWatch grows with the output count it is given, not with the range it walks. Same clean 192 KiB MEM_WRITE_WATCH region, WRITE_WATCH_FLAG_RESET, 3,000 calls per row:

output capacity µs per call
49 (the region's pages) 0.29
512 0.60
4,096 0.89
65,536 (what the tracker passes) 7.10

In game, on a local branch with the same per-segment cap in the collect loop:

  • GetWriteWatch calls went from mostly 4-16 µs (about 8 µs average) to mostly under 1 µs (about 0.5 µs average).
  • Write-watch walk time on the queue workers in a race scene fell from 2.5-3.6 s to 0.1-0.4 s per 10 s.

That branch also calls GetWriteWatch once per resident run, which main does not, so the call counts there are higher than on main.

On the count itself: rounding both ends to pages, as this PR does, matters. The simpler (stop - cursor + 4095) / 4096 under-counts by one page when both ends are unaligned, which can leave a dirty page unreported.

@nikowolf123

Copy link
Copy Markdown
Contributor Author

Thank you, that's the in-title measurement this PR was missing: per-call cost following the output capacity (0.29 µs at 49 entries vs 7.10 µs at 65,536), and the GT7 race scene's walk time falling from 2.5-3.6 s to 0.1-0.4 s per 10 s.

The page count is computed as you describe: both ends are rounded to pages, so a 4 KiB range starting mid-page counts the two pages it touches, where (stop - cursor + 4095) / 4096 gives one.

@oneandonlydean oneandonlydean self-assigned this Oct 13, 2026
@oneandonlydean
oneandonlydean merged commit 7a0c838 into boykopovar:main Oct 13, 2026
16 of 17 checks passed
danielradosa added a commit to danielradosa/AnyPS5 that referenced this pull request Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants