Repository navigation
Report real free address space and attribute page boxes by holder - #1078
Merged
Merged
Conversation
The address-space watch read its free figure from GlobalMemoryStatusEx. Wine computes ullAvailVirtual as ullTotalVirtual minus the working set (dlls/kernelbase/memory.c), and on macOS the working set is the mach resident size of the whole process, 64-bit host side included (dlls/ntdll/unix/process.c). The figure falls with resident growth whatever the 32-bit space holds. A GTA IV log shows it: from line 7249 on the largest free block exceeds the free total (2030 against 1836 MiB, 1182 against 903 at the end), and the threshold warnings fired on resident growth. The free figure is now the sum of the free regions of the VirtualQuery walk that already found the largest block, each trimmed to the 64 KiB allocation granularity, one allocation-free walk for both, tallied by mtld3d_core::address_space::FreeSpace. The crash lines carry free_mib and largest_free_mib from it in place of avail_virtual_mib, and GlobalMemoryStatusEx is no longer called. Two latches drive the warnings: the free total (1536 down to 128 MiB) and the largest free block (512, 256, 128 MiB), so a fragmented space still warns. A crossing names the lowest threshold crossed and is followed by the region map, which used to log once and only below a 512 MiB largest block. The first sample only arms the latches, so a process that starts below some thresholds (2 GiB without large-address-aware) logs one info line instead of warning at once. The same line listed 1042 MiB of page boxes with about 610 MiB owned by nothing it named. It now splits the PE-side page boxes by holder in padded bytes, so they add up: texture staging (page-rounded, cube faces included, with the requested total beside it), surfaces (system-memory and scratch backing, lockable render-target staging, back-buffer read-back pages), vertex/index backing, encoder leases (renamed backings and upload snapshots until the encoder's acknowledgment), the recycle pool, and other. A new HeldPages wrapper in mtld3d-core charges surfaces and owned leases to their gauges. The shared retention gauge is dropped from the line because the native encoder charges host pages to it too. The watch logs on its own target, mtld3d::d3d9::mem_watch: the breakdown at debug, so RUST_LOG=mtld3d::d3d9::mem_watch=debug enables it without the layer's per-pass debug output, and the warnings and the map at warn. The walk runs every sample, after Present's stall timer stops, and the debug line reports its region count and time; a test process walks about 240 regions in 150 to 240 microseconds. The sample interval goes from 120 to 600 presents with the debug line every second sample, ten seconds as before. A 64-bit build skips the walk and the thresholds, whose walk took 9 to 19 ms there, and keeps the breakdown.
This was referenced Oct 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Symptoms
The address-space watch's "free" figure is not free address space. In a GTA IV run (
GTAIV-24284.log, 15 debug lines from line 216 to 22375), from line 7249 on the largest free block is larger than the free total, which cannot happen for real free space:2030 MiBlargest against1836 MiBfree at line 7249, andaddress space: 903 MiB free, largest free block 1182 MiBon the last line. The threshold warnings (1536, 1024, 768, 512, 256, 128 MiB) are driven by that figure, so they fire on resident growth, and an earlier run reported255 MiB freewhile the 32-bit space was fine.The same last line lists
page boxes 1042 MiBwhile the holders it names account for about 430 MiB of it (texture staging 295, vertex/index backing 3, retained 2, and 126 parked in the recycle pool per the perf summary), leaving about 610 MiB with no owner in the log.The periodic line is gated on
mtld3d::d3d9at debug, which also turns on every per-pass shader log of the layer and distorts frame times.Cause
avail_virtual_mibreadGlobalMemoryStatusEx. Wine computesullAvailVirtualasullTotalVirtualminus the process working set (dlls/kernelbase/memory.c, "approximate"), and on macOS the working set is the mach resident size of the whole process (dlls/ntdll/unix/process.c), the 64-bit host side included. The figure therefore falls as the process grows resident, whatever the 32-bit space holds.The page-box gap has parts the line did not see. Texture staging was summed at the levels' requested lengths, while every level is its own page box rounded up to 16 KiB, so a chain of small levels holds several times what it asks for; cube face staging was not counted at all. Surfaces are not in the texture registry the watch walks, so the backing of system-memory offscreen plain surfaces, the staging of lockable render targets and back-buffer read-back pages were counted only in the total. The "retained" figure was the device's shared retention gauge, which the native encoder also charges with pages it allocates on the host side, outside the 32-bit space. Which part holds the 610 MiB in GTA IV is conjecture until the next run logs the new line; the rounding of small mip levels is the likely one.
Changes
The free figure is now the sum of the free regions of a
VirtualQuerywalk, each trimmed to the 64 KiB allocation granularity so a sliver no reservation can use counts nothing, from the same walk that finds the largest free block (crash::free_space, one walk, allocation-free so the exception handler can call it too). The tally is pure, inmtld3d_core::address_space::FreeSpace.GlobalMemoryStatusExis no longer called: the working-set figure it gives under Wine says nothing about the address space and is easy to read as if it did. The crash lines (FATAL,fault outside d3d9.dll) carryfree_mibandlargest_free_mibfrom the same walk in place ofavail_virtual_mib.Two threshold latches watch the samples: the free total (1536 down to 128 MiB, as before) and the largest free block (512, 256, 128 MiB), so a fragmented space with plenty free in total still warns before a large allocation fails. A sample that crosses thresholds logs one warning per latch naming the lowest one crossed (
threshold_step, pure and tested), then oneaddress space map:line. The map used to log once, and only when the largest block fell below 512 MiB. The first sample only arms the latches: a process that starts below some thresholds, such as one without large-address-aware and its 2 GiB, logs one info line and reports only the lower ones, rather than warning about every threshold at its first present. The latches advance by compare-exchange, so two devices cannot report one crossing twice.The page boxes are reported by holder, in padded bytes on the PE side so they add up to the total: texture staging (every level not dropped and every cube face level, split by pool and usage, with the requested total beside it), surfaces, vertex/index backing, encoder leases, the recycle pool, and
other. A newHeldPageswrapper inmtld3d-core(held_pages) charges a page box to its holder's gauge while held, with the charged length stored so a replacement throughDerefMutcannot skew the gauge. Surfaces hold their system-memory backing, lockable staging and read-back pages through it, andGuestOwnedPageLeaseholds its renamed backing or upload snapshot through it until the encoder's acknowledgment. The shared retention gauge is no longer in the line, since it counts host pages too. The warnings carry the same breakdown.The watch logs on its own target,
mtld3d::d3d9::mem_watch: the periodic breakdown at debug, the threshold warnings and the map at warn, soRUST_LOG=mtld3d::d3d9::mem_watch=debugturns on the breakdown alone. Nothing in the tree, the tests or the docs read these lines on the old target.The walk now runs on every sample instead of only for the debug line. It runs after
Present's stall timer has stopped, so it is not charged to the present block, and the debug line reports how many regions it walked and how long it took, in microseconds of the PE side's calibrated counter, so the next game run measures its real cost. In a test process it costs about 0.75 microseconds a region under Wine (150 to 240 microseconds for 225 to 255 regions, one outlier at 2.9 ms while four tests ran at once); no game has been measured yet. The sample interval goes from 120 to 600 presents, about five seconds at 120 presents a second, with the debug line every second sample, which keeps its old ten-second cadence. A 64-bit build skips the walk and the thresholds, since its space cannot run out and Wine took 9 to 19 ms to walk it in a test process, and keeps the breakdown, which still says what the layer holds in memory.The new debug line, from an i686 test process:
address space: 4028 MiB free, largest free block 2048 MiB, walked 225 regions in 164 us; mtld3d holds 0 textures with 0 MiB of mip data; page boxes 0 MiB: texture staging 0, surfaces 0, vertex/index backing 0, encoder leases 0, pool parked 0, other 0; texture staging split default static 0 / default dynamic 0 / other 0, 0 MiB before page rounding; vertex/index backing split writeonly static 0 / dynamic 0 / other 0; locks on static default textures 0docs/ARCHITECTURE.mdgets a section on the watch and a row for the new target.Alternatives considered
Keeping the
GlobalMemoryStatusExfigure relabelled as resident memory. It is the resident size of the whole process, host side included, which Activity Monitor already shows, and it is the number that misled the earlier reading, so it is dropped rather than kept under a new name.Tagging every
PageBoxwith its holder and keeping one counter per holder in the page-box module. That attributes every byte by construction, but it changes every constructor and the pool's park and pop paths for a diagnostic. The texture walk the watch already does, plus one gauge each for surfaces and owned encoder leases, covers the holders the log could not see, andotherkeeps whatever is left visible.Scaling the thresholds to the process's total user space (2 GiB without large-address-aware, about 4 GiB with it). The thresholds are headroom figures, and 128 MiB left is as close to failure in a 2 GiB process as in a 4 GiB one, so scaling would move the low ones the wrong way. Arming the latches at the first sample keeps the absolute values and only drops the ones a process starts below.
Walking the address space a slice at a time across presents to keep the sample interval at 120. The thresholds are a diagnostic whose point is the trail before a failure, and a five-second interval keeps that trail; the slicing would add state for no other gain.
Verification
make fmt, thenmake check(formatting, Clippy on both PE arches and the Unix workspace,make audit,make doc): green.make test-unit: 1995 and 755 host tests passed, including the newaddress_space::tests(free-region sums, granule trimming of slivers, unaligned and aligned regions and the top of the space, the largest block never above the total, the first-sample arming, crossings once and after a recovery, the largest-block latch on a fragmented space, the microsecond conversion, the holder split and its text) andheld_pages::tests(the surface gauge charges padded bytes while held, returns them on drop and oninto_page, and returns the charged length after aDerefMutreplacement).make test ISOLATED=1: the host tests as above, and the end-to-end suite on both legs with 0 failed (i686: 1072 passed, 13 ignored; x86_64: 1070 passed, 13 ignored; 6 processes each). The suite reads none of the watch's lines.RUST_LOG='mtld3d=warn,mtld3d::d3d9::mem_watch=debug' make test-e2e-i686 ISOLATED=1 FILTER='e2e::device::'produced the debug line above, 16 of them, each with the walk's region count and time.RUST_LOG=mtld3d::d3d9::mem_watch=debug, a free figure never below the largest free block, page-box holders that add up to the total, and the walk's cost in that game.Rules check
Checked against
CONTRIBUTING.md,docs/CONVENTIONS.mdanddocs/ARCHITECTURE.md. New statics holding mutable state, each with its argument in its doc block:SURFACE_BYTESandENCODER_LEASE_BYTESinmtld3d_core::held_pages(the resource is process-wide, since the watch reads them against the one 32-bit address space and the process-wide page-box total; a surface can outlive its device and a lease retires after the frame that recorded it, so neither has a device record to give its bytes back to), andNEXT_LARGEST_THRESHOLDinmem_watch.rs(the largest free block is a property of the one address space).NEXT_THRESHOLDwas already process-wide and is renamedNEXT_FREE_THRESHOLD; theMAP_LOGGEDlatch is gone. The test moduleheld_pages/tests.rshas aGAUGElock static, with the argument that the gauge it serialises is process-wide. Derives:ThresholdStepandThresholdReportderiveDebug,PartialEqandEqfor the tests'assert_eq!; noCloneorCopy, so the derive inventory is unchanged.FreeSpacederives nothing and has a hand-writtenDefaultthat callsnew, which Clippy'snew_without_defaultrequires. No config key, environment variable, wire field, dependency or lint suppression. The new log target follows the per-moduleLOG_TARGETpattern and has its row in the target table. The pure parts (the free-space tally, the threshold latch, the microsecond conversion, the holder split and its text, the held-pages gauge) live inmtld3d-corewith their tests in<stem>/tests.rs; theVirtualQuerywalk stays inwindows/d3d9and has one unsafe block with its SAFETY comment, shared by the free-space tally and the region map.make checkenforces the doc-block shape, the tests-in-own-files layout, the derive inventory and the suppression ban, and ran green.