Memory Pressure

How RawCull measures memory and reacts to macOS pressure events

Memory Pressure

RawCull can hold many images in memory while scanning and culling. The memory-pressure code exists to keep the app responsive when macOS reports pressure and to make cache behavior visible during development.

Source Map

FileRole
Actors/SharedMemoryCache.swiftOwns cache limits, memory-pressure dispatch source, pressure counters, and cache clearing
Model/ViewModels/MemoryViewModel.swiftSamples total system memory, used system memory, app memory, and pressure threshold for the UI
Model/Cache/CacheConfig.swiftContains CacheRecommendationPolicy and cache-limit calculation
Views/Settings/MemoryTab.swiftDisplays memory stats, cache limits, and pressure status
Views/RawCullSidebarMainView/MemoryWarningLabelView.swiftShows the sidebar warning during pressure

Runtime Flow

flowchart TD
    A["SharedMemoryCache.ensureReady"] --> B["startMemoryPressureMonitoring"]
    B --> C["DispatchSourceMemoryPressure"]
    C --> D{"event"}
    D -->|"normal"| E["refreshConfig from settings/adaptive policy"]
    D -->|"warning"| F["shrink cache limits to 60 percent"]
    D -->|"critical"| G["clear memory caches and set 50 MB preview cap"]
    F --> H["Notify file handler warning"]
    G --> H
    E --> I["Clear warning"]

SharedMemoryCache starts monitoring lazily during ensureReady(). That is called before thumbnail cache use, so the memory-pressure handler is active before large thumbnail work begins.

Measuring System Memory

MemoryViewModel.getUsedSystemMemory() uses Mach VM statistics:

usedMemory = min((wired + active + compressed) * pageSize, physicalMemory)

The model intentionally uses a simple definition of “used”:

VM fieldMeaning
wire_countWired pages that cannot be paged out
active_countPages currently active
compressor_page_countPages held by the VM compressor

The result is clamped to physical memory so the UI cannot show impossible totals.

Measuring App Memory

RawCull measures its own process footprint with:

task_info(... TASK_VM_INFO ...).phys_footprint

That is the value displayed as app memory usage. It is a better signal than simply adding image sizes because it reflects what the kernel says the process currently costs.

Percentages In The UI

MemoryViewModel exposes:

memoryPressurePercentage = memoryPressureThreshold / totalMemory * 100
usedMemoryPercentage = usedMemory / totalMemory * 100
appMemoryPercentage = appMemory / usedMemory * 100

The default pressure threshold is 85 percent of physical memory:

memoryPressureThreshold = totalMemory * 0.85

This UI threshold is informational. The actual pressure response is driven by macOS DispatchSourceMemoryPressure events.

Cache Limit Policy

CacheRecommendationPolicy.adaptiveLimits(...) is the first place to inspect when tuning memory behavior.

RawCull has two independent RAM budgets:

CacheNSCachePrimary contentSettings maximum
PreviewmemoryCachePreview and loupe thumbnailsmemoryCacheSizeMB
GridgridThumbnailCacheGrid-size thumbnailsgridCacheSizeMB

Both use byte cost as the binding constraint. The preview count limit is 10,000 and the grid count limit is 3,000, so normal eviction should be driven by pixel cost rather than item count.

At normal pressure:

  1. Choose a baseline from physical RAM.
  2. Estimate free memory from Mach stats.
  3. Keep a 3 GB reserve.
  4. Use half of the remaining expandable memory as extra cache budget.
  5. Split that extra budget 65 percent preview cache and 35 percent grid cache.
  6. Round to 256 MB steps.
  7. Clamp by machine tier and user maximums.

The current tiers are:

Physical RAMPreview baselineGrid baselinePreview tier capGrid tier capDefault user maxima
Less than 32 GB2,048 MB768 MB4,096 MB1,024 MB4,096 / 1,024 MB
32 GB to less than 64 GB4,096 MB1,024 MB8,000 MB2,000 MB4,096 / 1,024 MB
64 GB or more8,000 MB2,000 MB8,000 MB2,000 MB8,000 / 2,000 MB

The policy never treats a user maximum as a target. It calculates an adaptive recommendation and then clamps preview and grid independently to their saved maxima. At warning or critical state, calculateConfig(from:) uses 60 percent of the tier baseline, rounded up to 256 MB and bounded by the configured minimum and user maximum.

At non-normal pressure, RawCull returns reduced baseline limits and then the live pressure handler may shrink or clear active caches immediately.

Pressure Responses

EventCode pathEffect
NormalhandleMemoryPressureEvent, .normalSet pressure level to normal, increment normal counter, refresh config, clear warning
Warning.warningSet pressure level to warning, increment warning counter, reduce the current preview and grid cost limits to 60 percent, retain entries for NSCache to evict, show warning
Critical.criticalSet pressure level to critical, increment critical counter, clear preview and grid RAM caches, reset their cost/count mirrors, clear the recent-eviction ring, and set the preview limit to 50 MB

The grid cache is cleared on critical pressure too. The disk caches are not deleted; only RAM is released.

The 50 MB critical cap applies to the preview cache. Critical handling clears the grid cache but does not rewrite its live cost limit. A later .normal event calls refreshConfig(), re-runs the adaptive calculation using current memory and saved settings, restores both live limits, and clears the UI warning. Recovery does not repopulate either cache; normal demand and preload work warm them again.

Why Pressure State Is Lock-Backed

currentPressureLevel is read by the UI without await. The value is stored in an OSAllocatedUnfairLock, which makes the read/write contract explicit while avoiding a main-actor or cache-actor hop during frequent sampling.

The same pattern is used for the cache cost/count mirrors. The synchronous surface is deliberately narrow: pressure snapshots, NSCache lookups/inserts, and lock-backed cache totals. Configuration, disk-cache access, monitoring setup, and handler ownership remain actor-isolated or explicitly run in detached tasks. This design does not imply that arbitrary cache operations may bypass actor isolation merely because NSCache itself is thread-safe.

Runtime Observability

MemoryTab refreshes MemoryViewModel once per second while the settings view is active. It displays total physical memory, the app’s definition of used system memory, the RawCull process footprint, the informational 85-percent threshold, and the kernel-reported pressure level.

SharedMemoryCache also maintains lock-backed current cost and item-count totals for the preview and grid caches. CacheDelegate decrements those mirrors when NSCache evicts an item, which keeps the values shown in settings accurate. There is no separate memory-diagnostics console or TSV-export pipeline in the current source tree.

Validation When Limits Change

Run the cache-policy tests in RawCullTests/ThumbnailProviderTests.swift. They currently cover the production/testing relationship, explicit CacheConfig values, the 16 GB baseline, expansion and tier caps, user maxima, and warning-state rounding. Add fixtures for every new RAM tier, clamp, rounding rule, or pressure branch.

Also retain the concurrency test in RawCullVerifyTestsDataRaceDetectionTests.swift, which samples currentPressureLevel concurrently.

For an operational limit change, capture a diagnostics session with the same representative catalog and workflow before and after the change:

  1. Record idle, initial grid population, sustained scrolling, loupe/preview use, and recovery after induced or observed pressure.
  2. Compare peak process footprint, preview/grid cost and item counts, live limits, and time to first usable grid using Instruments or temporary development instrumentation.
  3. Confirm warning shrinks both live caches without deleting disk data.
  4. Confirm critical clears both RAM caches, preview falls to the 50 MB cap, counters record the event, and .normal restores adaptive limits.
  5. Reject a larger limit if it only raises footprint without improving reuse; reject a smaller limit if it creates repeated cold extraction or visible grid/preview churn.

What To Check When Changing This Area

  • Memory sampling should stay off the main actor; Mach calls run in Task.detached.
  • Do not rely only on the 85 percent UI threshold; the real emergency signal is the kernel pressure event.
  • If cache limits look strange, inspect both user settings and the adaptive tier caps.
  • Treat preview and grid limits separately; a healthy total can hide churn in one cache.
  • The settings display samples state; use Instruments or signposts when a change needs event-level evidence.
  • Critical pressure should free RAM quickly and should not delete disk caches.
  • After recovery, verify both live limits were recalculated from current settings and memory, not merely reset to hard-coded values.

Last modified September 15, 2026: sam3 (7b120c4)