Repository navigation
mir: raise when MIR runs out of memory or address space (#1215) - #1399
Merged
Merged
Conversation
Under a cap on the address space (ulimit -v, RLIMIT_AS), the MIR-compiled model that did not fit ended the process. MIR answers a failed allocation with exit(1), from its default error function or from its growable arrays, and a failed code mapping with a NULL code address most of its callers write through. c2mir's preprocessor writes through three plain mallocs unchecked. MirJit now hands each context an allocator, a code allocator wrapping MIR's default one, and an error function. While compile() has a call into MIR under way they longjmp back to it, and it throws. A refused mapping other than ENOMEM gets its own message. The reservation MIR_init2 asks for first may still fail quietly, since MIR then maps page by page. A context MIR failed in is abandoned rather than finished: MIR does not keep its state whole through an error that returns, and MIR_finish crashed on a half-made function and on the inliner's flags. Its code mappings are unmapped; its heap is left allocated. c2mir is now finished as soon as it has made the module. That halves what a live context holds (fceri_fyn: 894 MB of heap to 421) and leaves compile + run + destroy time unchanged. Two local carries: mir.c exposes its default code allocator, and c2mir.c reports its three plain mallocs' failures through MIR's error function. CMake defines MIR_CODE_RESERVE_SIZE PUBLIC, for the code allocator to tell the reservation by its size. A test-only binding, _mir_jit_compile, fails one allocation or mapping of a compile; the tests fail each in turn. third_party/mir/README.md.bngsim is now the one home of the reservation's rationale; the copies in CMakeLists.txt, the tests and the changelog point at it.
…#1215) MIR's README line about setjmp/longjmp is about programs MIR runs, not about an embedder's error function; MIR.md asks only that the error function never return.
elyfmiller
added a commit
to elyfmiller/bngsim
that referenced
this pull request
Oct 30, 2026
Main's lanl#1399 (lanl#1215) adds two MIR local carries, in mir.c and c2mir/c2mir.c, where this branch adds one, in mir-gen-x86_64.c (lanl#1216). Each rewrote the "stock upstream" wording for its own, so VENDOR.json, test_mir_vendoring.py, MIR_VENDORING.md and third_party/mir's README.md.bngsim now list all three, and the +mir-439a90a2 fragment takes main's text.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #1215 (PR #1327), from that PR's review.
What was wrong on main
#1327 made each MIR context reserve 32 MB of address space instead of 128, so a cap on the address space (
ulimit -v,RLIMIT_AS) holds more live MIR-compiled models. It left what happens at the cap: MIR ends the process.get_last_code_holderas a NULL code address, and most of its ~20 callers write through it. On main, refusing the mapping for a generated function's code (with the reservation refused, as it is at the cap) is a SIGSEGV.exit(1), or, inside a growable array, tomir_varr_error, which callsexit(1)itself.#ifrecords with plainmallocand writes through the result unchecked.Nothing in bngsim could catch any of these.
The fix
MirJit(include/bngsim/mir_jit.hpp) hands each MIR context:mir_alloc) that, whilecompile()has a call into MIR under way, reports a null instead of returning it;MirCodeMaps) that wraps MIR's default one and reports a failed mapping, except the reservationMIR_init2asks for first, whose refusal MIR handles by mapping page by page;mir_error).All three
longjmpback toMirJit::mir_guarded(MIR.md asks only that an error function never return), andcompile()throws aRuntimeError.deny_execmem) gets its own message, without the cap advice.A context MIR failed in is abandoned, not finished. The first version finished it, and an ASan sweep crashed
MIR_finishin two places:new_func_arrhad added to the module before filling in its fields;item->data = (void *) 1flags, whichremove_itemfrees.MIR does not keep its state whole through an error that returns, and patching each such spot is open-ended. So
MirJitunmaps the context's code (nothing can reach it) and leaves its heap allocated. See "Left alone".c2mir is finished as soon as it has made the module, instead of when the model is destroyed. Its parse of the source was most of what a live context held. Live heap per MIR-compiled simulator, main → this PR: simple_decay 1.94 → 0.96 MB, egfr_simple_seed 8.6 → 5.5 MB, fceri_fyn 894 → 421 MB (the cc backend's
.soholds about 25 MB). The freeing moves from destruction to the end of the compile, so compile + run + destroy is unchanged (fceri_fyn 1,906 → 1,915 ms, median of 3), and the first run of a large model starts about 120 ms later.Two local carries, recorded in
VENDOR.jsonandscripts/MIR_VENDORING.md. Neither is reported upstream yet, and upstream masterdc2c763fstill has the old code.mir.c: a one-line_MIR_get_default_code_alloc, soMirJitwraps MIR's default code allocator rather than copying its platform code (MAP_JITand the per-thread write protection on Apple Silicon,VirtualAllocon Windows).c2mir/c2mir.c: NULL checks on the three plainmallocs, through c2mir'salloc_error, soc2mir_compilereturns 0 andMirJitraises.CMake now defines
MIR_CODE_RESERVE_SIZEPUBLIC onbngsim_mir(0 on Windows and 32-bit), because the code allocator tells the reservation apart by its size.Docs.
third_party/mir/README.md.bngsimis now the one home of the reservation's rationale and numbers. TheCMakeLists.txtcomment, both test files' comments and the1215.fixed.mdfragment point at it, or keep only what they need.+mir-439a90a2.changed.mdno longer says the tree is stock upstream, or that each context reserves 128 MB.Evidence
A test-only binding,
_bngsim_core._mir_jit_compile(source, fail_allocation, fail_mapping), fails one allocation or code mapping of a compile. The counting allocator it uses is a separate table, so a production compile pays nothing for it.RuntimeError, 0 ASan errors, and the thread compiles again after eachRuntimeError, 0 ASan errorsRuntimeErrorAddress space after a failed compile (VSZ): +0.00 MB each. A reservation left mapped would add 32.
Heap a failed compile keeps (
mstatsbytes in use): see "Left alone".Compile time, MIR, macOS arm64, median of 3, main → this PR, compile + run + destroy: simple_decay 4.4 → 4.5 ms, egfr_simple_seed 25.0 → 25.1 ms, fceri_fyn 1,906 → 1,915 ms.
Full suite on a MIR build (macOS arm64,
-n 6): 16,296 passed, 453 skipped, 1 failed:test_run_tests_sh.py::test_the_script_itself_runs_walk_up_dependent_tests_green, which fails in any/private/tmpworktree, main's too. (An earlier run also failedtest_cache_cli.py::TestPrune::test_an_entry_is_never_selected_twiceonce under load. That test reads file access times, touches nothing here, and passed 3 of 3 alone and under-n 6.)mir.yml's test list, run withBNGSIM_CODEGEN_JIT=mirandBNGSIM_SKIP_AUDIT=strict: 1,667 passed, 2 skipped (both declared).Mutations. Each of these fails exactly one of the three runtime tests:
Dropping a c2mir check fails the new static test and the checksum anchor.
Review
Round 1 found no CRITICAL or HIGH defects, and confirmed real ODE, steady-state and SSA runs end to end: a mapping refusal raises, a model that was already live is unaffected, and a retry once mappings are allowed again gives bit-identical results. It found:
1 << 40overflowed the binding's 32-bitlongunder MSVC. The hook arguments and counters are nowlong long.longcounter that could wrap to -1 and inject a spurious failure. Counting now runs only under the test hook.mir_allocatedread a thread-local before its null check; it now reads it after.Round 2 (the changes above) found no CRITICAL or HIGH defects.
Early
c2mir_finishholds up.tests/datanet plus fceri_fyn) compile clean under ASan, followed by symbol lookups, module output and moves.The new errno classification holds up. A DYLD
mmapinterposer gives:macOS's hardened runtime without the allow-jit entitlement refuses
MAP_JITwith EINVAL.It found four LOW items, all fixed:
mallocs surfaced as "c2mir failed to compile … no memory", without the cap message. The carry now reports through MIR's error function as an allocation error, asc2mir_callocdoes. Failing each suchmallocunder ASan now reports out of memory: 173 of 173 on simple_decay, 486 of 486 on egfr_simple_seed..soholds about 25 MB" was wrong. It's removed._mir_jit_compiletakes afail_mapping_errno, and a test asserts EACCES gets the policy message and ENOMEM the cap message. Making the branch always ENOMEM fails it.Round 2 also saw a clean compile's allocation count move by one with heap layout under ASan. So the sweep probe now asserts only that the thread compiles again, not that it does so in the same number of allocations.
Round 3 (the round-2 fixes) was clean.
mallocunder ASan reported out of memory 173/173 and 486/486, and left the thread's failure target and locale as they were.fail_mapping_errnois portable and resets after each call.test_mir_vendoring.py.Left alone
mstatsbytes in use per failed compile on fceri_fyn: about 390 MB for a refused code mapping (the cap case), and 225–483 MB for a failure while c2mir is still parsing. simple_decay keeps 0.2–1.7 MB and egfr_simple_seed 4–6.5 MB. Reclaiming it needs one of two things, and both are a design call:frees of context-allocated blocks routed through it. Every compile would pay for that in time and memory.default_mem_unmapcallsVirtualFree(addr, len, MEM_RELEASE)with a non-zero length, which Windows rejects. If so, code pages are never freed on Windows, on main as on this branch.vm.max_map_count:_MIR_set_codeignoresmprotect's result. A Linux process past its map count would write to a read-only page. That is a different limit, and a cap on the address space comes first in practice.Tests
python/tests/test_mir_equivalence.py(MIR builds; run bymir.yml):RLIMIT_ASa little above the process's size, with 0 to 64 MB of headroom.python/tests/test_mir_vendoring.py:mallocof a struct inc2mir.cis checked;MIR_CODE_RESERVE_SIZEis PUBLIC;ci/test_floors.env: the skip ceiling rises from 225 to 230, by the five new tests that a build without MIR skips. ubuntu skips 218 on main and 223 with them.