Memory-Mapped State Storage
Overview
AgentFarm supports on-disk, memory-mapped storage for large dense state
representations using NumPy memmap. This dramatically reduces resident
memory usage for big worlds while still allowing fast localized window
reads.
A single configuration controls every memmap-backed structure:
| Memmap target | Toggle | Backing class |
|---|---|---|
| Resource grid | MemmapConfig.use_for_resources |
ResourceManager |
| OBSTACLES, TERRAIN_COST, VISIBILITY | MemmapConfig.use_for_environmental |
EnvironmentalGridManager |
| DAMAGE_HEAT, TRAILS, ALLY_SIGNAL | MemmapConfig.use_for_temporal |
TemporalGridManager |
All three managers share a generic engine,
farm.core.memmap_manager.MemmapManager, which owns memmap lifecycle
(create / window / flush / cleanup) and embeds the OS process id and
optional simulation_id in filenames so concurrent simulations on the
same host do not collide.
Configuration
MemmapConfig lives at SimulationConfig.memmap:
from farm.config import SimulationConfig, MemmapConfig
cfg = SimulationConfig(
memmap=MemmapConfig(
directory="/var/tmp/sims", # None -> system temp
dtype="float32",
mode="w+", # "r+" reuses an existing file
delete_on_close=False, # remove .dat files on Environment.close()
use_for_resources=True,
use_for_environmental=True,
use_for_temporal=True,
),
)
YAML / JSON configs use the same nested layout (memmap: block) and the
existing dot-notation parser also accepts memmap.use_for_resources,
memmap.directory, etc.
What changed
farm/core/memmap_manager.py– genericMemmapManagershared by every memmap-backed grid. Providescreate,get,get_window,flush,close,close_all, plus convenience helpers for in-place updates and decay.farm/core/environment_grids.py–EnvironmentalGridManagerholds(H, W)world layers forOBSTACLES,TERRAIN_COST, andVISIBILITY. Falls back to in-RAM ndarrays when memmap is disabled so callers do not branch on backend.farm/core/temporal_grids.py–TemporalGridManagerholds(H, W)world grids forDAMAGE_HEAT,TRAILS, andALLY_SIGNAL, with per-channelapply_decay()driven by the simulation’sObservationConfig.gamma_*factors.farm/core/resource_manager.py– refactored to composeMemmapManagerinstead of duplicating memmap logic.farm/core/environment.py– instantiates the new grid managers, exposesset_environmental_layer()/deposit_temporal_events(), threads windows from those grids into agent observations, and decays the temporal grids eachupdate()tick. Temporal channels are passed to the observation pipeline via the denseworld_layersslot rather than as sparse event lists, eliminating the dense → sparse → dense round-trip the original implementation paid in everyobserve()call.farm/core/observations.py– tracksworld_driven_channelsper tick so DYNAMIC channels backed by an externally-decayed world grid skip the per-agent decay step (the world grid already decayed at the world level).farm/core/channels.py–TransientEventHandleris now dual-mode: when aworld_layers[<channel_name>]tensor is present it takes the same dense path asWorldLayerHandler; otherwise it falls back to the legacy sparse-event API.farm/config/config.py– addsMemmapConfigand wires it intoSimulationConfig.memmap(withto_dict/from_dictsupport).
Performance notes
Two micro-optimizations make the memmap path competitive with in-RAM storage:
- Plain
ndarrayviews –MemmapManager.create()cachesnp.asarray(memmap)alongside the memmap.get_windowslices the plain view, avoiding the per-call dispatch cost of thenumpy.memmapsubclass while still reusing the same underlying buffer forflush(). - Fast in-bounds path –
get_windowskips thenp.zerosallocation and pad fill for the common case where the requested window is fully inside the grid (most agent observations away from the world edge). It just copies the slice into a fresh ndarray.
TemporalGridManager also tracks a sticky has_any_data flag per
channel. When no events have ever been deposited the per-tick window
read and dense channel write are short-circuited, so simulations that
never use a temporal channel pay nothing for it.
Acceptance criteria mapping (issue #426)
| Criterion | Where verified |
|---|---|
| Environmental grids (OBSTACLES, TERRAIN_COST, VISIBILITY) use memmap when enabled | EnvironmentalGridManager, tests/test_environmental_grids.py, tests/test_memmap_environment_integration.py |
| Agent observations can use memmap-backed global layers | Environment._make_environmental_layer_tensor, tests/test_memmap_environment_integration.py::test_set_environmental_layer_round_trip |
| Temporal channel persistence (DAMAGE_HEAT, TRAILS, ALLY_SIGNAL) | TemporalGridManager, Environment.deposit_temporal_events, tests/test_environmental_grids.py, tests/test_memmap_environment_integration.py::test_temporal_grid_decays_on_environment_update |
| Performance ≤ 1.25× baseline latency for memmap operations | scripts/validate_memmap_acceptance.py reports per-grid microbenchmarks (resources, environmental, temporal) and an end-to-end Environment.observe() benchmark; all four are well under the 1.25× threshold (typically 0.98–1.05×) |
| Compatible with existing tensor operations | Environment._np_window_to_tensor, validation script tensor-compatibility checks |
| Memory usage scales with grid size, not loaded into RAM | Memmap files reported by validation script; tests assert has_memmap and on-disk file presence |
| Validation script covers all new memmap structures | scripts/validate_memmap_acceptance.py reports environmental and temporal acceptance lines |
| Cross-platform compatibility | MemmapManager only uses stdlib + numpy; filenames are sanitized for any FS |
Multiprocess notes
Filenames embed pid and simulation_id so multiple concurrent
processes do not stomp on each other’s files. If you need explicit
read-only sharing across processes, open consumers with mode="r" and
keep a single writer process.
Run the validation script
python scripts/validate_memmap_acceptance.py
It reports PASS/WARN/FAIL per criterion (including the new
environmental and temporal grids) and an overall verdict
(PASS/INCONCLUSIVE/FAIL).