Pace the low-memory allocation throttle (issue #5482) - #5493
Conversation
After the OS reports memory pressure the VM raises lowMemoryMode and slows allocators so the collector can catch up. The throttle parked EVERY legacy-path allocation for one millisecond, which is not backpressure but a hard ceiling of about 1000 allocations/second per thread. An allocation-heavy loader dropped from seconds to hours: no crash, no log line, just an app that stops making progress. It could only ever bite iOS. Nothing else raises lowMemoryMode -- the simulator on a large-RAM host essentially never delivers a memory warning and Android has no equivalent path -- so the same code read as "works everywhere but the device", which is exactly how it was reported. Parking is now capped at one per thread per CN1_LOW_MEMORY_PARK_INTERVAL_MS (10ms), bounding the cost at that duty cycle however fast a thread allocates. Waiting out a collector that has actually stopped the world is a safepoint rather than a throttle, so threadBlockedByGC is still honored every time. Measured on the reporter's workload, iPhone 17 Pro simulator, Release build, with memory warnings emulated at 20ms: never completes (300s in, 53% done and degrading 1.1s -> 3.8s per 1000 words) before, 4s after -- the same 4s the run takes with no memory pressure at all. Peak RSS 410MB -> 450MB, the working set the throttle was buying back. Tests: LowMemoryThrottleIntegrationTest translates and runs an allocation-dense load twice, with and without simulated warnings, and asserts the pacing invariant (parks <= elapsed/interval + slack) rather than wall time, so the budget scales with the runner. Verified to FAIL on the pre-fix VM: 15756 parks over 19851ms against a budget of 4070. Memory pressure comes from a new CN1_SIMULATE_MEMORY_WARNING_MS test hook, since nothing off iOS can otherwise reach this state; CN1_LOG_LOWMEM_PARKS reports the counters and gates them so a shipping build pays nothing. vm/tests: 404 tests green. Gauntlet: all tortures byte-identical to the host JVM, GcStress + MtStress green in cooperative, forced-signal and sustained memory-warning modes. (TaggedSync does not link on macOS/arm64 on master either -- java_lang_Thread_sleep___long is undefined in the clean target -- which aborts run-gauntlet.sh before the stress rounds; those were run directly.) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR updates ParparVM’s low-memory allocation throttle to provide paced backpressure (instead of a fixed 1ms sleep on every legacy allocation), adds per-thread pacing state, and introduces an integration test plus runtime hooks to reproduce/assert the behavior off iOS.
Changes:
- Pace low-memory throttling to at most one 1ms park per thread per
CN1_LOW_MEMORY_PARK_INTERVAL_MS, while still honoringthreadBlockedByGCwaits as safepoints. - Add low-memory throttle diagnostics (
CN1_LOG_LOWMEM_PARKS) and a CI test hook to simulate sustained memory warnings (CN1_SIMULATE_MEMORY_WARNING_MS). - Add a clean-target integration test that translates/builds/runs an allocation-dense workload and asserts a pacing invariant rather than wall-clock time.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| vm/tests/src/test/resources/com/codename1/tools/translator/LowMemoryThrottleApp.java | Allocation-dense workload used by the clean-target integration test to exercise legacy-path allocations. |
| vm/tests/src/test/java/com/codename1/tools/translator/LowMemoryThrottleIntegrationTest.java | Translates/builds/runs the workload and asserts that low-memory parks are paced under sustained warnings. |
| vm/ByteCodeTranslator/src/nativeMethods.m | Initializes the new per-thread pacing stamp (lowMemoryParkStampMs) since ThreadLocalData is malloc’d. |
| vm/ByteCodeTranslator/src/cn1_globals.m | Implements paced throttling, adds monotonic time helper, adds diagnostics counters, and adds the memory-warning simulation hook. |
| vm/ByteCodeTranslator/src/cn1_globals.h | Adds lowMemoryParkStampMs to ThreadLocalData and defines CN1_LOW_MEMORY_PARK_INTERVAL_MS. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
✅ ByteCodeTranslator Quality ReportTest & Coverage
Benchmark Results
Static Analysis
Generated automatically by the PR CI workflow. |
✅ Continuous Quality ReportTest & Coverage
Static Analysis
Generated automatically by the PR CI workflow. |
Cloudflare Preview
|
|
Compared 146 screenshots: 146 matched. Benchmark ResultsDetailed Performance Metrics
|
|
Compared 146 screenshots: 146 matched. Benchmark ResultsDetailed Performance Metrics
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
vm/ByteCodeTranslator/src/cn1_globals.m:294
lowMemoryModeis now an_Atomic JAVA_BOOLEAN, but this test-hook loop writes it using plain assignment, which defaults to a seq_cst atomic store. Since the allocator side explicitly usesmemory_order_relaxed, it’s better to make this store relaxed as well to avoid an unnecessary global ordering barrier (especially when simulating warnings at 1ms cadence).
lowMemoryMode = JAVA_TRUE;
…struct field Review follow-ups. lowMemoryMode is now _Atomic. It was a plain JAVA_BOOLEAN written from the UI thread in didReceiveMemoryWarning, cleared by the collector and read by every allocating thread -- a formal data race that predates this PR and that the CN1_SIMULATE_MEMORY_WARNING_MS hook would have widened. The allocation-path read is an explicit relaxed load: the flag carries no ordering relationship, seeing a raise one allocation late costs nothing, and seq_cst would put an acquire barrier on the legacy allocation path. cn1MonotonicMillis on Windows now uses cn1_win_compat's cn1_monotonic_micros (QueryPerformanceCounter) instead of gettimeofday. gettimeofday is the wall clock, so an NTP step would either suppress the throttle for the length of the jump or park on every allocation until the clock caught up -- exactly the invariant the pacing depends on. The park stamp moves from a ThreadLocalData field to a __thread long, matching the other per-thread GC state in this file: zero-initialized per thread, so the malloc'd-not-zeroed init in getThreadLocalData is no longer needed, and the header contributes only a #define rather than changing a struct layout that every generated translation unit compiles against. vm/tests: 407 green. GcStress + MtStress green in cooperative, forced-signal and sustained memory-warning modes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fa54239 to
77d6c28
Compare
|
Compared 147 screenshots: 147 matched. |
|
Compared 147 screenshots: 147 matched. |
|
Compared 146 screenshots: 146 matched. Benchmark ResultsDetailed Performance Metrics
|
|
Compared 181 screenshots: 181 matched. |
|
Compared 148 screenshots: 148 matched. Benchmark Results
Detailed Performance Metrics
|
|
Compared 149 screenshots: 149 matched. Benchmark Results
Build and Run Timing
Detailed Performance Metrics
|
|
Compared 144 screenshots: 144 matched. |
|
Compared 217 screenshots: 217 matched. |
|
Compared 143 screenshots: 143 matched. Benchmark Results
Build and Run Timing
Detailed Performance Metrics
|
The problem
After the OS reports memory pressure the VM raises
lowMemoryModeand slows allocators so the collector can catch up. The throttle parked every legacy-path allocation for a fixed millisecond:That is not backpressure, it is a hard ceiling of about 1000 allocations/second per thread — three orders of magnitude under the un-throttled rate. A loader that allocates a few hundred thousand buffers goes from seconds to hours. There is no crash and no log line; the app simply stops making progress.
lowMemoryModeis raised indidReceiveMemoryWarning(CodenameOne_GLViewController.m:4000) and cleared only when a collection cycle completes (nativeMethods.m:1807), so under sustained pressure — which is what an allocation-heavy load on a memory-constrained device produces — it is effectively pinned on.Why it looked impossible to reproduce. Nothing but iOS raises
lowMemoryMode. The simulator on a large-RAM host essentially never delivers a memory warning, and Android has no equivalent path. The same code reads as "works on the simulator and Android, hangs on the device", which is exactly how #5482 was reported. Everything we ran against the reporter's own reproducer — Release build, real data, low-free-memory (CN1_FAKE_FREE_MB) and forced-signal-GC variants — passed in about 4 seconds, because none of them could reach this state.Reproduction
The reporter's reproducer, iPhone 17 Pro simulator, Release build, with memory-warning delivery emulated:
Peak RSS 410MB -> 450MB: the working set the per-allocation park was buying back.
The fix
Parking is capped at one per thread per
CN1_LOW_MEMORY_PARK_INTERVAL_MS(10ms), so the worst case a thread can lose is that duty cycle no matter how fast it allocates. Waiting out a collector that has actually stopped the world is a safepoint rather than a throttle, sothreadBlockedByGCis honored every time, unchanged.The pacing stamp is per thread (
ThreadLocalData.lowMemoryParkStampMs) and monotonic, and is initialized explicitly ingetThreadLocalData—ThreadLocalDatais malloc'd, not zeroed.Tests
LowMemoryThrottleIntegrationTesttranslates an allocation-dense load, builds it through the clean target and runs it twice: once with no pressure (asserting the throttle never engages) and once under sustained warnings. It asserts the pacing invariant —parks <= elapsed / interval + slack— rather than wall-clock time, so the budget scales with the runner instead of assuming a machine speed.RESULT=is compared against the same program on the host JVM so the throttle cannot be "fixed" by dropping work.Verified to fail on the pre-fix VM:
and to pass on this branch:
throttledAllocations=12312 parks=1 elapsedMs=4.Two supporting hooks, both no-ops unless their environment variable is set:
CN1_SIMULATE_MEMORY_WARNING_MS=<ms>raiseslowMemoryModeat a cadence, standing in for sustaineddidReceiveMemoryWarningdelivery. Nothing off iOS can otherwise reach this state, so without it the path is untestable on CI.CN1_LOG_LOWMEM_PARKSreports[LOWMEM] parks=P throttledAllocations=Tat exit, and gates the counters so a shipping build pays nothing for them (they sit on the legacy allocation path, which is hot during exactly the pressure this responds to).Verification
vm/tests: 404 tests, 0 failures.GcStress+MtStressgreen in cooperative, forced-signal and sustained-memory-warning modes (the last one also exercises the new hook against the collector).TaggedSyncdoes not link on macOS/arm64 on master either (java_lang_Thread_sleep___longundefined in the clean target), which abortsrun-gauntlet.shbefore its stress rounds. Confirmed identical on a pristine checkout; the stress rounds above were run directly.CI: the Alpine/musl failure is not from this PR
build + run suite (musl, Alpine x64)fails with a gcc internal compiler error --SSA corruptionincoalesce_ssa_name, on the abnormal setjmp edges ParparVM's exception handling emits -- while compiling a generated file this PR does not touch:The identical ICE -- same file, same line, same function -- reproduces on the unrelated
first-class-healthbranch (run 30506992421). It is a master-wide break: this workflow last ran green on master atf9810b020, which is beforead2c1952cc, the base of both branches. Needs its own fix; it is not a signal on this change.Note on #5482
This is a separate defect from the bounds-check fix in #5485, and explains why that PR changed nothing for the reporter: his symptom is a stall, not a bad read.
🤖 Generated with Claude Code