Skip to content

28× faster reset + complete snapshot capture (fixes goto_depth+snapshot SIGSEGV) - #21

Merged
liujonathan24 merged 5 commits into
mainfrom
engine-reset-speedup
Jun 25, 2026
Merged

28× faster reset + complete snapshot capture (fixes goto_depth+snapshot SIGSEGV)#21
liujonathan24 merged 5 commits into
mainfrom
engine-reset-speedup

Conversation

@liujonathan24

Copy link
Copy Markdown
Owner

Summary

Two engine wins, both verified behavior-preserving for the forward game:

1. Fixes the snapshot/restore monster-corruption SIGSEGV

nle_pline_state (pline.c) was calloc'd on the libc heap — outside the per-env arena, so nle_fr_snapshot never captured it — yet it holds _you_buf, an arena pointer. After a restore rewinds the arena, that stale pointer aliases a reused arena offset, and the next You_hear/pline writes its message string over whatever now lives there (observed: a live monster's data field) → SIGSEGV in passivemm during pet combat. Reproduced deterministically via snapshot/restore/reseed branching after modify(goto_depth).

Fixed the whole class: arena-allocate nle_pline_state plus ~20 other lazy per-env *_state structs, and serialize the rl display mirror's cached inventory_ + WIN_MESSAGE last_msg in NetHackRL::save_mirror/load_mirror. These were the remaining per-env state that escaped the snapshot and leaked the previous branch's state through a restore (inv_*/message/glyphs/blstats observation divergence).

2. ~28× faster reset/reroll

RawEngine.start() copytree'd the entire ~3.7MB read-only dat tree into a fresh temp dir on every reset (~29ms, ~85% of reset cost). The engine only ever reads that data O_RDONLY. Added a datadir to nle_settings so read-only prefixes resolve to a shared source dir and only a tiny per-env writable hackdir is created. 35ms → ~1.3ms.

Plus a reusable arena memory-map debug tool (RawEngine.memmap() + docs/game_memory_map.txt) that was instrumental in root-causing the corruption.

Verification (behavior unchanged)

  • Differential snapshot-completeness test (snapshot → reseeded divergent branches → restore → replay fixed line == first replay): 40 seeds × 30 rounds, 0 crashes, 0 divergence.
  • Forward-game observation traces byte-identical pre/post across 30 seeds × 200 steps.
  • Reset/reroll median 1.25ms (was ~35ms). 25 engine tests pass, incl. new test_snapshot_completeness.py.

Submodule

Bumps third_party/NetHack to the fork branch engine-snapshot-completeness (4 commits: datadir split, memmap tool, whole-game map, snapshot completeness).

Known separate follow-up (pre-existing, not a regression)

A deeper crash surfaces only at ≥60 rounds of the aggressive differential pattern (hundreds of snapshot/restore cycles): SIGSEGV in jump_fcontext (coroutine resume to a corrupt context), independent of depth/goto_depth/reset. Confirmed pre-existing (crashes with the mirror change reverted) — was masked before by the round-4 crash. Tracked for separate investigation.

RawEngine.start() previously mkdtemp'd + copytree'd the entire ~3.7MB dat tree
on every reset (~29ms, ~85% of the 35ms reset cost) just to give the engine a
writable hackdir. The dat files are read-only game data the engine only ever
opens O_RDONLY (confirmed via strace), so copying them per reset was pure waste.

Now the binding passes the shared source dat as settings.datadir (read directly,
never copied) and creates only a tiny per-env writable hackdir holding the four
writable templates (record/logfile/xlogfile/perm); the engine writes level/save/
bones/lock files there. The hackdir is reused across resets and scrubbed back to
those templates on each start.

Result: reset/reroll 35ms -> ~1.3ms. Behavior is byte-identical (the C change is
a no-op when datadir is empty; verified identical game traces across 30 seeds x
200 steps old-vs-new, plus 20/20 reuse-vs-fresh trace identity). The remaining
~0.9ms is nle_start's actual game generation.

Bumps the NetHack submodule to 121ddf5 (the datadir prefix split). Adds
test_reset_speedup.py (behavior identity + tiny-hackdir + speed guard) and
updates test_snapshot_multilevel for the templates-now-in-datadir layout.
…nce dump

Binds nle_dbg_memmap as RawEngine.memmap(path): dumps the per-env arena layout
(named buffers by offset, fmon/fobj chains, monster grid with fmon-membership
and data-validity) to a file. A reusable diagnostic for arena-reuse / dangling-
pointer / snapshot-corruption debugging.

Includes docs/arena_memory_map.txt, a reference dump from a normal game showing
the fixed buffer layout (tcap..muse_m, 0..~255KB), the level struct region
(~194-255KB), and the dynamic monster/object/string allocations above it.

Bumps the NetHack submodule to c074f62 (nle_dbg_memmap).
Renames docs/arena_memory_map.txt -> docs/game_memory_map.txt and regenerates it
from the extended nle_dbg_memmap: now leads with the whole-game region table
(nle_ctx_t struct, arena, coroutine stack, rl mirror) before the arena
buffer/chain/grid detail. Bumps the NetHack submodule to dcaaff4.
…ession test

Bumps the NetHack submodule to e7197b4, which makes nle_fr_snapshot capture all
per-env state: arena-allocates nle_pline_state (fixes the dangling-you_buf
SIGSEGV during snapshot/restore/reseed combat) and the ~20 other lazy per-env
*_state structs, and serializes the rl mirror's inventory_ + WIN_MESSAGE
last_msg (fixes inv_*/message observation divergence after a restore).

Adds tests/test_snapshot_completeness.py: a differential invariant test
(snapshot -> divergent reseeded branches -> restore -> replay fixed line == the
first replay). Validated far wider out of band: 40 seeds x 30 rounds, 0 crashes
and 0 divergence; forward-game traces byte-identical pre/post (behavior
unchanged).
@liujonathan24
liujonathan24 force-pushed the engine-reset-speedup branch from 9196321 to d8bf259 Compare June 25, 2026 08:07
@liujonathan24

Copy link
Copy Markdown
Owner Author

Depends on the engine-side PR liujonathan24/NetHack#5 (this PR's submodule pointer 8ee987e is the head of that fork branch, rebased onto the latest fork main 6bb4913). Merge #5 first (or together).

The vision_radius tune knob set u.nv_range unclamped; a large value (e.g. 1e9,
or inf) indexed circle_data[] out of bounds via circle_ptr() -> SIGSEGV in
vision_recalc at game start (and vr*vr overflowed int in the sight-limit path).
Now clamped to [1, MAX_RADIUS]. Found by fuzzing every tune knob with extreme
values; vision_radius was the only one that crashed.
@liujonathan24
liujonathan24 merged commit 46a63f0 into main Jun 25, 2026
1 check passed
liujonathan24 added a commit that referenced this pull request Jun 26, 2026
…#22)

Re-bumps the engine submodule from 1ff9e00 to fork main 71f0be3, which now
includes the include/nleobs.h header sync (NetHack#6). That fix landed on the
fork after PR #21 was merged at 1ff9e00, so harness main was left pointing at
the pre-fix engine where the top-level include/nleobs.h still lacked the
datadir + tune fields (an ABI mismatch for external consumers building against
include/nleobs.h).
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.

1 participant