ml/64-mingw-sparse - #1081
Conversation
windows has no equivalent of `mmap(MAP_FIXED)`: `MapViewOfFileEx` fails if the address is occupied, and `UnmapViewOfFile` only accepts the base of a whole view, so a hole cannot be punched in the loom to drop the image mapping into. windows 10 1803 added placeholder reservations, which do provide it. this reserves the loom as a placeholder that is split in two at the image boundary, and backs the volatile half with a pagefile section rather than private memory -- moving the boundary means unmapping and remapping that half, and windows cannot resize a private region from below without discarding it, while section contents outlive their views. the placeholder apis are resolved with `GetProcAddress`, since mingw's import libraries do not reliably cover `kernelbase.dll` and their absence is recoverable rather than a link error.
`VirtualProtect` rejects `PAGE_READWRITE` on a `FILE_MAP_COPY` view, so `mprotect(PROT_READ | PROT_WRITE)` on a demand-paged image page would fail and take the fault handler with it. `VirtualQuery` the range and use `PAGE_WRITECOPY` instead. NB: keyed off `AllocationProtect` rather than `Protect`, since an already-copied page reports `PAGE_READWRITE` while its view is still write-copy.
`_main_init` forced `u3o_no_demand` on windows unconditionally, with no flag to turn it back on. drops that, and adapts the mapping path: - `_ce_loom_mapf` gets a windows body that maps the granularity-floored prefix of the image and blits the ragged tail, since mappings are placed at the 64KB allocation granularity while a loom page is 16KB. the image is mapped copy-on-write but protected read-only, so stores still fault into `u3e_fault` instead of being silently privatized. - `_ce_loom_unmapf` is a new hook, called from `u3e_save` before the patch is applied. windows refuses to truncate a mapped file, and does not guarantee coherence between a mapped view and writes through the file handle. a no-op elsewhere. - `u3m_init` reserves the loom as a placeholder, degrading to a plain mapping and to `u3o_no_demand` on windows older than 10 1803. - `u3e_yolo` walks the loom region by region, as `VirtualProtect` cannot span mappings and a demand-paged loom is two of them. `--swap` now errors on windows rather than failing obscurely: the ephemeral file is remapped a page at a time, which the allocation granularity precludes. snapshot validation keeps forcing `u3o_no_demand`, since it reads the loom in the window where the image is unmapped.
error 1455 is `ERROR_COMMITMENT_LIMIT`, not missing placeholder support: windows does not overcommit, so the whole loom is charged against RAM plus the paging file whether or not it is ever touched. reporting it as a capability problem sent the reader in the wrong direction. `_wnd_procs` now reports its own failure, `_wnd_fail` hints at the loom size and paging file on 1455, and the fallback in `u3m_init` says what windows actually needs rather than pointing at the linux swap docs.
windows does not overcommit. a pagefile-backed section is charged against RAM plus the paging file when it is created, whether or not it is ever touched, so `CreateFileMapping` of a whole loom fails with `ERROR_COMMITMENT_LIMIT` on any box smaller than the loom. a 16GB loom could not boot on a 2GB VM at all, with or without demand paging. creates the loom section `SEC_RESERVE`, so its pages are reserved rather than committed, and commits them on first touch: - `u3_wnd_loom_commit` commits a range, and `u3_wnd_loom_fault` commits a page if and only if it was reserved. - `u3e_fault` tries the latter before the dirty check. a first touch is always a volatile page, which is already dirty in the bitmap and would otherwise be reported strange. - the vectored handler now routes *read* faults inside the loom to `u3m_fault` as well, since a reserved page faults on read too. this matches libsigsegv, which has always delivered both. stray reads outside the loom are left alone. - ranges the kernel writes into -- `pread` in `_ce_loom_blit_pages` -- are committed up front, as the handler cannot rescue a kernel-mode access. - the guard page is committed before being protected, and `u3e_yolo` skips reserved regions rather than committing the whole loom. sparseness survives a save because `_ce_patch_save_page` already skips pages the allocator marks `u3a_free_pg`, so untouched memory is never read back. `u3m_init` now takes the placeholder path regardless of `u3o_no_demand`, since sparseness is worth having on its own; that flag now only chooses whether the image is mapped or blitted.
standalone, not part of the build. the sparse loom rests on the claim that a section's contents *and* commitment survive unmapping and remapping its view, which the documentation does not state outright and which cannot be checked by cross-compiling. if it is false, the loom silently loses the heap on the first save that moves the image boundary. the probe performs the same placeholder dance as `_wnd_remap` at the same 16GB loom size as the failing report, and reports PASS/FAIL per claim. it also measures the commit charge difference against a non-SEC_RESERVE section, and confirms how an access violation on a reserved page presents to a vectored handler.
`u3m_boot_lite` calls `u3m_pave` before `u3e_init`, so the road is written to loom page zero while `u3P.gar_w` is still zero. on a sparse loom that write is a first touch, and `u3e_fault` read `pag_w == gar_w` as a guard page hit, bailing with `ward bogus (>0 0 1048575<)`. commits the faulting page in `u3m_fault`, ahead of everything else. it cannot wait for `u3e_fault`: `u3m_water` runs first and dereferences the road, which lives in the loom and may itself be untouched, so a first touch there would fault inside the handler. also stops treating a zero `gar_w` as page zero. it means the guard page is unposted, and no fault can be a guard hit until it is -- a misreading that was latent everywhere, not just on windows.
migration holds a stale loom open at its own base, alongside the live one, so the mapping layer can no longer assume a single region. turns its state into a small array and adds `u3_wnd_loom_hold` / `u3_wnd_loom_drop` for scratch regions. a stale loom differs from the live one in two ways: it is never tracked or saved, so its image pages stay writable rather than trapping stores, and its boundary never moves, so it is mapped once. `_wnd_remap` therefore takes the image page protection as an argument. `u3_wnd_loom_fault` now checks region membership before committing. it runs on every fault, and must not commit reserved memory belonging to something else. that also lets `u3m_fault` resolve first touches before its loom bounds check, so a fault in a stale loom -- which sits outside the live loom -- is handled rather than reported as external.
migration mapped the stale loom anonymously and then mapped the old image over it with `MAP_FIXED`. on windows both halves fail: the anonymous mapping is a fully committed section of loom size, which does not fit on a box smaller than the loom, and a mapping cannot be replaced in place anyway. booting a 32-bit pier with a 64-bit binary died at the first of these with `boot: mapping 4096MB failed`. reserves and maps in one operation via `u3_wnd_loom_hold` instead, so the stale loom is sparse and the image lands copy-on-write over its bottom. the v4 south segment sits above the image in reserved space, so it is committed before being blitted.
a loobean `c3y` is 0, so the zeroed `yes_o` flag on a static region slot read as *yes*. every stale loom slot looked taken before anything had claimed one, and migration died with `no free stale loom slot`. drops the flag and derives occupancy from `len_i`, which is zero-safe by construction. `_wnd_reserve` now commits to the slot only once it has succeeded, so a failed reservation leaves it free rather than half claimed. `u3_wnd_loom_live` was wrong the same way, and would have claimed the loom was a placeholder reservation before `u3m_init` ran.
the 32->64 migration wrote the new snapshot back to the same image.bin the stale loom was still mapped from, and windows refuses to truncate a file while any mapping of it remains open. `_ce_image_resize` failed with `image truncate: Permission denied`. `_ce_loom_unmapf` drops the live loom's view of the image, but not the stale loom's second mapping of the same file. so release the stale loom before `u3m_save`, as `_disk_migrate_h` already does for the other direction. factors that teardown into `_disk_drop_stale_loom` rather than repeating the four-way ifdef, and gives the v1-v4 switch a path for an unrecognized version, which previously fell out to the teardown at the end.
dropping the image view was not enough to let `_ce_image_resize` truncate: windows keeps a file's section attached to the handle that created it, so the section outlives the view until that descriptor is closed. and the descriptor `_ce_loom_unmapf` left open is the very one the truncate goes through. `_disk_migrate_h` has closed its fd for this reason since dbe52c3; the live loom's image never did. so reopen it in `_ce_loom_unmapf`, once the view is gone. also replaces `ftruncate` on windows with `SetEndOfFile`, because the CRT reports a still-mapped file, a sharing violation and a permissions problem all as EACCES -- "Permission denied" gave no way to tell which. failures now name the win32 error, and call out 1224 specifically. `u3_wnd_loom_drop` no longer swallows its unmap failures, and releases both halves of a split reservation rather than just the first. its callers were ignoring the result; they now assert on it.
win32 error 1224 confirmed a surviving section on image.bin. the holder is the stale loom: a migration reads the old image.bin and writes the new snapshot back over the same file, and windows keeps a file's section attached to the handle that created it, so mapping the stale loom holds that file against every later resize of it. both resizes, in fact. the failure moved from the truncate at the end of `u3m_save` to the one in the crash-recovery patch `u3e_live` applies, which runs earlier and hit the same section. so `u3_wnd_loom_hold` now reads the image into its reservation and holds nothing open. that costs commit charge for the image where a mapping would not, but a migration is a one-time operation, and no arrangement of mappings can be correct while source and destination are one file. reads go direct rather than through the `pread` shim, whose offset is an `off_t` and so may be 32 bits wide; a stale image can exceed 2GB.
after a migration the process keeps going and boots the ship, so `u3m_init` runs a second time at the same base. `u3m_stop` in between closes the image and frees the dirty bitmap but never releases the loom, and `mmap(MAP_FIXED)` does not care -- it replaces whatever is there. windows does: `VirtualAlloc2` refuses an occupied address with `ERROR_INVALID_ADDRESS`. so release a standing reservation before making a new one, matching what `MAP_FIXED` has always done. factors the teardown out of `u3_wnd_loom_drop` as `_wnd_release`. 487 also now says the address is occupied. it read as a shortage of memory, because the fallback path that follows reports the loom not fitting in RAM plus the paging file, which was not the problem.
6c365ad to
abb5d17
Compare
Boot and run tests between optimized 32-bit builds (
|
| Metric | ml/64-mingw-sparse |
ml/64 |
sparse --no-demand |
ml/64 --no-demand |
|---|---|---|---|---|
| Working set | 29,824 K | 781,788 K | 782,020 K | 782,020 K |
| Peak working set | 29,824 K | 781,788 K | 782,028 K | 782,020 K |
| Memory (private working set) | 15,232 K | 2,256 K | 2,268 K | 2,292 K |
| Memory (shared working set) | 14,592 K | 779,532 K | 779,752 K | 779,728 K |
| Commit size | 190,460 K | 170,868 K | 170,816 K | 170,904 K |
| Page faults | 8,322 | 196,314 | 196,382 | 196,349 |
| I/O read bytes | 34,288 | 785,941,999 | 785,942,000 | 785,942,000 |
26× less resident memory, ~23,000× less file I/O at boot, 24× fewer page faults.
ml/64 reads the entire snapshot into the loom at startup — I/O read bytes is almost exactly the size of image.bin. The sparse branch maps it instead, so it reads 34 KB and only faults in the ~29 MB the ship actually touches.
The three right-hand columns agree to within 0.03% on every metric, which is the control: demand paging accounts for the whole difference, and --no-demand on the sparse branch reproduces ml/64 exactly — so the fallback path (also taken on Windows older than 10 1803) is unchanged.
Commit size is higher on sparse by ~20 MB, as expected: the image is mapped PAGE_WRITECOPY, so pages the ship dirties become private copies, which this column counts. ml/64 keeps the loom in one pagefile section, where the cost sits in shared working set instead.
The urth process is unaffected in all four runs (~35 MB working set, ~24k page faults, ~6 KB read).
The fresh-boot pair is confounded: a fake zod saves during boot, and the two samples are three minutes apart against a snapshot timer, so some of that 488 vs 910 MB gap is likely one process having saved and remapped while the other hadn't. The restart test is the controlled one.
These numbers don't measure sparseness: every column is per-process, and SEC_RESERVE only shows in system commit charge.
Boot time
ml/64-mingw-sparse
C:\Users\Administrator\Desktop\vere>powershell -Command "Measure-Command { .\zig-out\x86_64-windows-gnu\urbit.exe -F nec --urth-loom 29 --loom 31 -x -t }"
disk: loaded epoch 0i0
loom: mapped 2048MB
boot: protected loom
live: logical boot
boot: installed 2179 jets
boot: parsing %brass pill
disk: loaded epoch 0i0
loom: mapped 2048MB
boot: protected loom
live: mapped: MB/785.940.480
boot: installed 2179 jets
Days : 0
Hours : 0
Minutes : 2
Seconds : 6
Milliseconds : 910
Ticks : 1269105048
TotalDays : 0.00146887158333333
TotalHours : 0.035252918
TotalMinutes : 2.11517508
TotalSeconds : 126.9105048
TotalMilliseconds : 126910.5048
ml/64
C:\Users\Administrator\Desktop\vere-ref>powershell -Command "Measure-Command { .\zig-out\x86_64-windows-gnu\urbit.exe -F nec --urth-loom 29 --loom 31 -x -t }"
disk: loaded epoch 0i0
loom: mapped 2048MB
boot: protected loom
live: logical boot
boot: installed 2179 jets
boot: parsing %brass pill
disk: loaded epoch 0i0
loom: mapped 2048MB
boot: protected loom
live: loaded: MB/785.940.480
boot: installed 2179 jets
Days : 0
Hours : 0
Minutes : 2
Seconds : 8
Milliseconds : 368
Ticks : 1283686536
TotalDays : 0.00148574830555556
TotalHours : 0.0356579593333333
TotalMinutes : 2.13947756
TotalSeconds : 128.3686536
TotalMilliseconds : 128368.6536
The Windows loom: a lifecycle walkthroughHow vere initializes, runs, and releases the snapshot with demand paging enabled on Windows. Written for someone who knows Paths are relative to the repository root. 0. Five Windows conceptsPOSIX collapses these into
The one that trips people up: a section can itself be reserved rather than committed ( Minimum Windows
1. Boot, part one: reserving the loom
The function writes into the slot only on success (wloom.c:391), so a failed reservation leaves it free rather than half-claimed. After this the loom is one view of a reserved section, with nothing committed. Functionally this is 2. Boot, part two: mapping the image
That function is short and mostly arithmetic: gan_w = granularity_bytes >> (u3a_page + u3a_word_bytes_shift); // 64KB/16KB = 4
map_w = pgs_w & ~(gan_w - 1); // granule-floored page count
tal_w = pgs_w - map_w; // ragged tail, 0..3 pagesPlaceholders can only be split at the 64KB allocation granularity, but a loom page is 16KB. So the image is mapped up to the last whole 64KB granule, and the leftover ≤3 pages are The real work is
Two things worth internalising about step 6:
3. Running: three kinds of faultEverything enters through It calls (a) First touch of a reserved page — the sparse case. Resolved first, at manage.c:2331, via The ordering is load-bearing for two reasons. (b) Write to a clean image page — the demand-paging case. Falls through to (c) The guard page — unchanged from POSIX, except that 4. Saving
Step 3 exists entirely because Windows refuses to truncate a mapped file. On POSIX steps 3–5 are just Step 5 is the boundary-move case, and the one still untested by 5. Release
Tracing it liveThe two highest-value breakpoints:
The two places where Windows semantics are genuinely unlike anything in POSIX are the placeholder split/coalesce dance in |
`VirtualAlloc2` and `MapViewOfFile3` were looked up with `GetProcAddress` so that their absence degraded to `u3o_no_demand` rather than failing to link. that bought less than it cost: they are exported from the `api-ms-win-core-memory-l1-1-6` api set, which links cleanly with one build flag, and windows 10 1803 is now vere's floor by decision rather than by accident. `UnmapViewOfFileEx` never needed it at all -- it is in kernel32, and has been since windows 8. drops `_wnd_procs`, the three function-pointer typedefs and their statics, and calls the imports directly. NB: the loader resolves imports eagerly, so an older windows now refuses to start the binary rather than booting without demand paging. that is the intended trade.
there is no `madvise(MADV_DONTNEED)` on windows, so `_ce_toss_pages` is a no-op there and a long-running ship never returns its free space. picking a replacement needs measurements this cannot get from the documentation. the range `u3e_toss` hands over is awkward: it lives in the SEC_RESERVE view, most of it was never touched and so is reserved rather than committed, and `u3e_save` asserts the PAGE_NOACCESS guard page sits strictly inside it. so the probe asks both which call frees pages and whether the range has to be walked with `VirtualQuery` rather than passed whole. reports residency per page via `QueryWorkingSetEx`, rather than as a process-wide RSS delta, so the numbers mean something. also asks whether committed section pages can be decommitted at all -- if they cannot, the sparse loom's commit charge is a high-water mark rather than a live measure, which is worth knowing either way. `DiscardVirtualMemory`, `VirtualUnlock` and `QueryWorkingSetEx` all resolve from kernel32, so none of this moves the 1803 floor.
`_ce_toss_pages` was a no-op there, so a long-running ship never returned its free space. the probe settled which mechanism to use: [A] DiscardVirtualMemory ok, 64 of 64 pages freed, contents zeroed [B] VirtualAlloc(MEM_RESET) ok, but all 64 pages stay resident [C] VirtualUnlock trims, but keeps the pagefile write [D] VirtualFree(MEM_DECOMMIT) fails (87) on a section view so `DiscardVirtualMemory`, which frees the pages outright and leaves them reading as zero -- the same observable behaviour as `MADV_DONTNEED`, so the platforms do not diverge. it rejects anything not committed and accessible, though, and the range `u3e_toss` hands over contains both: most of the free space was never touched and so is reserved, and `u3e_save` asserts the PAGE_NOACCESS guard page sits strictly inside it. the probe confirmed both are refused with `ERROR_INVALID_PARAMETER`, so the range is walked with `VirtualQuery` and applied region by region. the walk is also clamped to the region: the caller derives its length from the road watermarks, which can underflow, and discarding past the loom would take memory that is not ours. NB: [D] means commit charge is not reclaimed -- committed pages of a section view stay committed until the view is unmapped, so the loom's commit is a high-water mark. resident memory is returned; commit is not. `DiscardVirtualMemory` resolves from kernel32, so the 1803 floor is unchanged.
`_wnd_fail` printed a bare DWORD, which meant looking up 1224, 1455 and 487 by hand every time one appeared. `FormatMessageA` renders the text alongside the code. into a stack buffer rather than with FORMAT_MESSAGE_ALLOCATE_BUFFER: `u3_wnd_loom_fault` calls this from the fault handler, which is no place for a `LocalAlloc`. falls back to the bare code if formatting fails.
5dd42f7 to
3f9e2b6
Compare







Windows loom: demand paging and sparseness
Two branches, fourteen commits, ~1540 lines (about 1170 of them new Windows-only files). Branched from
dbe52c39daonml/64.ml/64-mingw-demand— 4 commits. Demand paging on Windows.ml/64-mingw-sparse— 10 more commits on top. A sparse loom, plus the migration path.Why this was one piece of work
Two independent features that needed the same foundation:
image.bininto the loom instead ofpreading every page of it. Saves boot time and RAM proportional to snapshot size. Windows had it forced off unconditionally, with no flag to turn it back on.Both need the loom to stop being one flat committed anonymous mapping.
The three POSIX assumptions Windows breaks
mmap(MAP_FIXED)replaces part of an existing mapping, atomically.MapViewOfFileExfails if the address is occupied, andUnmapViewOfFileonly accepts a whole view's base — so a hole cannot be punched in the loom to drop the image into. This blocked demand paging.Overcommit. A Windows pagefile-backed section is charged against RAM plus the paging file when it is created, not when touched. This blocked booting entirely on a small box.
Uniform page protection.
VirtualProtectcannot span separate mappings, cannot touch reserved pages, and rejectsPAGE_READWRITEon a copy-on-write view.Placeholder reservations (Windows 10 1803+) answer (1): reserve a range, split it, replace each piece with a mapping. That is genuinely the
MAP_FIXEDsemantic.SEC_RESERVEanswers (2). (3) is a series of small adjustments at existing call sites.The new layer
pkg/noun/platform/windows/wloom.{c,h}— all Win32 behind a small C interface.events.c,manage.canddisk.ccall into it and never see aHANDLE.The live loom is at most two mappings inside one placeholder reservation:
Two design decisions carry most of the weight.
The volatile half is a section view, not private memory. Moving the image boundary means unmapping and remapping that half, and Windows cannot resize a private region from below without discarding it — the heap and stack would go with it. Section contents outlive their views. The view always sits at section offset == loom offset, so addresses are stable across boundary moves. Growth is safe because the pages absorbed into the image are exactly the ones
_ce_patch_applyjust wrote to disk.The image is mapped
PAGE_WRITECOPY, then protected down toPAGE_READONLY. Stores therefore still fault intou3e_faultinstead of being silently privatized. The view must be created write-copy; a read-only view cannot be upgraded later.VirtualAlloc2/MapViewOfFile3/UnmapViewOfFileExare resolved withGetProcAddressfromkernelbase.dllrather than linked — mingw's import libraries do not reliably cover them, and their absence on pre-1803 Windows degrades tou3o_no_demandrather than failing to link. Nobuild.ziglink or_WIN32_WINNTchanges were needed.Sparseness
The loom section is created
SEC_RESERVE, so its pages are reserved rather than committed, and commit charge tracks what the ship actually uses.u3_wnd_loom_commitcommits a range;u3_wnd_loom_faultcommits a page if and only if it was reserved.u3m_faulttries the latter first, ahead of everything else. It cannot wait foru3e_fault:u3m_waterruns before it and dereferences the road, which lives in the loom and may itself be an untouched page.u3m_faultas well, since a reserved page faults on read too. This matches libsigsegv, which always delivered both. Stray reads outside the loom are left alone.u3e_yoloskips reserved regions rather than committing the whole loom.Sparseness survives a save because
_ce_patch_save_pagealready skips pages the allocator marksu3a_free_pg, so untouched memory is never read back.u3m_inittakes the placeholder path regardless ofu3o_no_demand— sparseness is worth having on its own, so that flag now only chooses whether the image is mapped or blitted.Migration
disk.cmapped the stale loom anonymously and then mapped the old image over it withMAP_FIXED. On Windows both halves fail: the anonymous mapping is a fully committed section of loom size, and a mapping cannot be replaced in place.wloomgrew a small region array so a stale loom can coexist with the live one at its own base.The stale loom is read, not mapped. The stale image and the migrated snapshot are the same file — a migration reads the old
image.binand writes the new one back over it — and Windows keeps a file's section attached to the handle that created it, so any mapping holds that file against every later resize of it. No arrangement of mappings is correct while source and destination are one file. Reading costs commit charge for the image where a mapping would not, but a migration is a one-time operation.Three ordering bugs fell out, all latent on POSIX:
_disk_migrate_loomreleased the stale loom afteru3m_save._disk_migrate_hhad already been fixed this way indbe52c39da; its sibling had not._ce_loom_unmapfdropped the live image view but left its descriptor open — and Windows keeps the section attached to that descriptor, so it has to be cycled.u3m_initruns a second time after a migration, because the process goes on to boot the ship.u3m_stopnever released the loom, andMAP_FIXEDnever cared.Deliberately not supported
--swaperrors out on Windows rather than failing obscurely. The ephemeral file is remapped a page at a time, which the 64KB allocation granularity precludes against a 16KB loom page.u3o_no_demandon Windows, since it reads the loom in the window where the image is unmapped.