Skip to content

perf: reduce map picker memory with a page-backed thumbnail cache - #47

Merged
maotovisk merged 8 commits into
mainfrom
feat/map-picker-memory
Sep 21, 2026
Merged

maotovisk merged 8 commits into
mainfrom
feat/map-picker-memory

Conversation

@robertoesteves13

Copy link
Copy Markdown
Collaborator

Summary

Adds a page-backed (unmanaged) thumbnail cache for map picker backgrounds and removes the two things that made the picker grow the process by hundreds of megabytes. Measured on Windows x64, 2880x1800 @175%, the real app window, the real picker and a 95-mapset / 87-background library.

Commits

  • feat: add OS page allocator for unmanaged bitmap buffers — new Utils/PageAllocator.cs (VirtualAlloc/mmap), AllowUnsafeBlocks in the csproj (required by LibraryImport).
  • feat: add page-backed thumbnail cache with Avalonia decode and resize — new Utils/BitmapStorageHandler.cs: Load(path) decodes with Bitmap.DecodeToWidth, center-crops to the 2:1 page and blits straight into a pre-allocated page; path-keyed cache hits skip decode and blit entirely.
  • feat: serve map picker card backgrounds from the thumbnail cache — one line in SongMapsetCardViewModel.BuildBackgroundImage.
  • perf: virtualize the map picker mapset list — ItemsPanel StackPanel → VirtualizingStackPanel.
  • perf: freeze the modal backdrop blur instead of animating a live effect — ModalHost no longer keeps a live BlurEffect on the window shell.

Why

1. A live BlurEffect over WindowShell (which contains the whole app UI), animated 0→10 px over 260 ms, with a BitmapCache. Every composited frame re-blurs a full window-sized surface (~20 MB at 2880x1760) and the native allocator never returns it. Because the effect stays attached while the modal is open, any animation above it re-blurs the window — that also covers the picker's compositor-driven smooth scrolling.

2. The picker list was not virtualized. ItemsControl + StackPanel realized every loaded card (~1.2 MB each) and paging appends 20 more per scroll, so memory grew with the library.

Measurements

phase before after
first modal open, peak delta +208 MB +70…79 MB
2nd / 3rd open — +3.4 MB / +1.0 MB
scroll, 20 → 80 cards loaded +53.4 MB, unbounded +16 MB, realized containers pinned at 6–9
picker open / close (app window, main) — 438 MB → 517 MB → 421 MB (released on close)
87 backgrounds through the cache, standalone — +18 MB peak, decode transients freed

For context, an empty maximized window in a bare Avalonia app on this display already measures 245.9 MB (77 MB before the window) — that floor, not the picker, is most of the process footprint.

Trade-offs / behaviour changes

  • The backdrop blur is no longer animated: it is applied in one step at open (the backdrop fade and the card slide/fade are unchanged) and is frozen while a modal is open — content behind it does not animate. It is re-taken if the window is resized while open.
  • The thumbnail cache is keyed by path only, so if a background file changes in place while its page is cached, the stale thumbnail is served. Easy to extend with an mtime check in BitmapStorageInfo.
  • Pages are process-wide (BitmapStorageHandler.Shared), committed lazily on first use (20 × 512 KiB) and returned to the OS at exit.
  • With VirtualizingStackPanel the scroll extent is estimated while further pages load; worth an eyeball on a very large library.
  • Headers in the tool views and BeatmapSourceCard still use ArtworkBitmapUtils.DecodePreview at 800/1200/480 — unchanged here.

Notes for review

  • Avalonia has no zero-copy path from caller-owned memory to a renderable bitmap: new Bitmap(PixelFormat, AlphaFormat, IntPtr, …) copies into an SKBitmap, and the uncopied route (protected Bitmap(IBitmapImpl)) needs IDrawableBitmapImpl, which is internal to Avalonia.Skia. So a Load costs one 512 KiB memcpy; the cache saves the decode + crop, not the copy.
  • The frozen backdrop is captured with two 1:1 RenderTargetBitmap.Render(Visual) passes (blur applied through an off-tree Image with BlurEffect). Scaling a render target through its DPI is not reliable on this window: with the extended-client-area window the content is drawn 1:1 into the smaller bitmap, which shows up as a magnified, cropped backdrop.
  • #if windows was never defined (SDK defines WINDOWS, and only for OS-specific TFMs), so the UNIX allocator branch was the one every Windows build ran. Also fixed: MEM_LARGE_PAGES can never be satisfied by a 512 KiB page (needs 2 MiB granularity + SeLockMemoryPrivilege), and VirtualFree must be called with MEM_RELEASE alone and dwSize == 0.

Testing

No permanent tests added — MapWizard.Tests has no Avalonia host. Verified with throwaway harnesses (deleted) that drove the real MainWindow, the real picker view model and the real song library:

  • page store byte-identical to the returned bitmap; cover-crop correct in both branches (2:1 source and panorama); JPEG and PNG; cache hit after the source file was deleted; 23 images over a 20-page round-robin; allocator alloc/free/zeroing/edge writes.
  • picker card lifecycle through SetBackgroundActive; 25-card scroll sweep; reactivation served from the cached page rather than a re-decode (file replaced in place to prove it).
  • paired screenshots of the live app (picker open vs closed) confirming the blurred backdrop is pixel-aligned with the live UI and that the shell restores cleanly on close.

dotnet build is clean (one pre-existing MainWindow.axaml AVLN3001 warning).

Untested here: the UNIX mmap path (no Linux/macOS available).

@maotovisk

Copy link
Copy Markdown
Owner

Unfortunately needed to revert the blur optimization: it was VERY clever but it didn't respect window geometry (was looking very bad on wayland) + animations, so i added some toggles for animations and motion on the appearance tab, will revisit some form of it in the future tho.

Also to make you happy i added a GruvBox palette.

@maotovisk
maotovisk merged commit f05d81c into main Sep 21, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants