From 682eddbfc9c4e06241a560b491cd1c5864f1ee19 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:11:08 +0300 Subject: [PATCH 1/7] Adapt BiBOP GC policy to survivor-heavy allocation --- vm/ByteCodeTranslator/src/cn1_globals.h | 52 ++- vm/ByteCodeTranslator/src/cn1_globals.m | 332 ++++++++++++++++-- vm/ByteCodeTranslator/src/nativeMethods.m | 12 + vm/benchmarks/README.md | 21 ++ vm/benchmarks/run-bibop-adaptive.sh | 127 +++++++ .../src/com/bench/BiBopAdaptive.java | 120 +++++++ 6 files changed, 629 insertions(+), 35 deletions(-) create mode 100755 vm/benchmarks/run-bibop-adaptive.sh create mode 100644 vm/benchmarks/src/com/bench/BiBopAdaptive.java diff --git a/vm/ByteCodeTranslator/src/cn1_globals.h b/vm/ByteCodeTranslator/src/cn1_globals.h index acc597eae06..9437b6192f0 100644 --- a/vm/ByteCodeTranslator/src/cn1_globals.h +++ b/vm/ByteCodeTranslator/src/cn1_globals.h @@ -1024,6 +1024,11 @@ extern const char* volatile cn1LastNamSetter; // diagnosis: last bracket toucher #define finishedNativeAllocations() do { threadStateData->nativeAllocationMode = JAVA_FALSE; cn1LastNamSetter = 0; } while(0) #endif +// Shared by ThreadLocalData's adaptive state and the page allocator below. +#ifndef CN1_BIBOP_NUM_CLASSES +#define CN1_BIBOP_NUM_CLASSES 15 +#endif + // handles the stack used for print stack trace and GC struct ThreadLocalData { JAVA_LONG threadId; @@ -1089,7 +1094,6 @@ struct ThreadLocalData { // 0 == not yet computed (lazily initialized once per thread on first use). JAVA_LONG nativeStackLimit; -#ifndef CN1_DISABLE_DEATOMIC_BYTES // LEVER A (perf-tier1): per-thread, plain-add accumulator for BiBOP allocation // volume. Replaces the per-object atomic_fetch_add on the global bibopBytesSinceGc // (which an uncontended single thread still pays as an arm64 exclusive-monitor RMW, @@ -1098,7 +1102,13 @@ struct ThreadLocalData { // within (nthreads * page) -- negligible vs the 24MB trigger, and the trigger is a // pure heuristic with NO correctness role (see CN1_BIBOP_FLUSH_BYTES). JAVA_LONG bibopBytesLocal; -#endif + // Runtime policy state. These are part of the one production collector; the + // compile-time collector variants are QA baselines only. + JAVA_LONG bibopEpochBytes; + int bibopObservedGcEpoch; + int bibopHighThroughputUntilEpoch; + int bibopBypassSeen[CN1_BIBOP_NUM_CLASSES]; + int bibopBypassRemaining[CN1_BIBOP_NUM_CLASSES]; #ifdef CN1_ON_DEVICE_DEBUG // Per-frame pointer to a stack-allocated array of void* addresses, one per @@ -1270,7 +1280,6 @@ const int currentCodenameOneCallStackOffset = threadStateData->callStackOffset; #define CN1_BIBOP_ADOPTED (-4) #endif // Slot sizes (16-aligned); a size maps to the smallest class >= size. -#define CN1_BIBOP_NUM_CLASSES 15 // Compile-time size -> class-index. With a constant `sz` (sizeof(...)) clang // folds the whole chain to an int literal (or -1 for oversized => fast path // dead-code-eliminated, slow path only). @@ -1281,6 +1290,7 @@ const int currentCodenameOneCallStackOffset = threadStateData->callStackOffset; typedef struct CN1BibopPage { struct CN1BibopPage* _Atomic nextAll; // append-only global registry chain + struct CN1BibopPage* nextFresh[2]; // alternating per-GC fresh-page stack links struct CN1BibopPage* nextPool; // FREE/PARTIAL pool / SWEEP stack link int classIndex; int slotSize; @@ -1325,12 +1335,24 @@ typedef struct CN1BibopPage { // idempotent across parallel markers) int gcGraceEpoch; // upper bound on survivor epochs as of the last // full walk (GC-thread only) + _Atomic int gcFreshEpoch[2]; // queued once on each alternating epoch stack } CN1BibopPage; // Per-thread current page per size class; defined in cn1_globals.m. Touched only // by the owning thread (alloc) and by that same thread on death. extern __thread CN1BibopPage* bibopCurrent[CN1_BIBOP_NUM_CLASSES]; extern _Atomic long bibopBytesSinceGc; +extern _Atomic long bibopGcTriggerBytes; +extern _Atomic int bibopBypassGeneration[CN1_BIBOP_NUM_CLASSES]; +extern CN1BibopPage* _Atomic bibopFreshPages[2]; +extern _Atomic long cn1BibopHighThroughputPromotions; +extern _Atomic long cn1BibopBypassActivations; +extern _Atomic long cn1BibopBypassAllocations; +extern _Atomic long cn1BibopFreshPagesScanned; +extern _Atomic long cn1BibopBeltRuns; +extern _Atomic long cn1BibopAdoptedRescanSkips; +extern int currentGcMarkValue; +extern void cn1BibopNoteFreshAllocation(CN1BibopPage* page); #ifndef CN1_BIBOP_NO_FASTSWEEP // Called from monitorEnter (any thread) when a monitor (CN1ThreadData) is freshly // attached to a heap object. If the object is a BiBOP slot it bumps a global live-monitor @@ -1349,13 +1371,16 @@ extern long long totalAllocations; // CN1_BIBOP_ACCOUNT_BYTES is called once per allocation (inline fast path AND the // .m slow path); CN1_BIBOP_FLUSH_BYTES is called once per page-acquire (slow path) // and at thread death. The global bibopBytesSinceGc is read only by the GC-trigger -// heuristic (cn1BibopMaybeGc) and reset to 0 by the sweep -- it has NO liveness/ +// heuristic (cn1BibopMaybeGc) and exchanged to 0 at GC start -- it has NO liveness/ // correctness role -- so deferring the per-thread total into it via plain adds and // flushing it in bulk is safe; only the trigger cadence shifts (by < nthreads*page, // negligible vs the 24MB trigger, and already racy today). The bump cursor / mark // publication ordering is UNCHANGED (those are the GC-visible fields; see report). #ifndef CN1_DISABLE_DEATOMIC_BYTES -#define CN1_BIBOP_ACCOUNT_BYTES(ts, n) do { (ts)->bibopBytesLocal += (JAVA_LONG)(n); } while(0) +#define CN1_BIBOP_ACCOUNT_BYTES(ts, n) do { \ + (ts)->bibopBytesLocal += (JAVA_LONG)(n); \ + (ts)->bibopEpochBytes += (JAVA_LONG)(n); \ +} while(0) // Flush the per-thread byte accumulator AND, in the same bulk step, the // isHighFrequencyGC heuristic counters (allocationsSinceLastGC/totalAllocations) -- // which used to be two global stores per object on the hot path. Coarsening them to @@ -1369,6 +1394,7 @@ extern long long totalAllocations; (ts)->bibopBytesLocal = 0; } } while(0) #else #define CN1_BIBOP_ACCOUNT_BYTES(ts, n) do { \ + (ts)->bibopEpochBytes += (JAVA_LONG)(n); \ atomic_fetch_add_explicit(&bibopBytesSinceGc, (n), memory_order_relaxed); \ allocationsSinceLastGC += (n); totalAllocations += (n); } while(0) #define CN1_BIBOP_FLUSH_BYTES(ts) do {} while(0) @@ -1378,6 +1404,9 @@ extern long long totalAllocations; // ineligible / oversized) -> caller falls back to __NEW_X / codenameOneGcMalloc. static inline JAVA_OBJECT cn1BibopFastAlloc(CODENAME_ONE_THREAD_STATE, int size, struct clazz* parent, int ci) { if(ci < 0) return (JAVA_OBJECT)0; // oversized: folded away for big types + if(__builtin_expect(threadStateData->bibopBypassRemaining[ci] > 0, 0)) { + return (JAVA_OBJECT)0; // cn1BibopAlloc consumes the legacy-bypass budget + } // EVERY allocation path must register the class BEFORE the object publishes -- // including this inline bump. That completes the invariant the GC mark guard // depends on: a resolved (current) slot whose class pointer is NOT in the @@ -1434,6 +1463,10 @@ static inline JAVA_OBJECT cn1BibopFastAlloc(CODENAME_ONE_THREAD_STATE, int size, #endif __atomic_store_n(&o->__codenameOneGcMark, -1, __ATOMIC_RELEASE); atomic_store_explicit(&p->bumpIndex, bi + 1, memory_order_release); + if(__builtin_expect(atomic_load_explicit(&p->gcFreshEpoch[currentGcMarkValue & 1], memory_order_relaxed) + != currentGcMarkValue, 0)) { + cn1BibopNoteFreshAllocation(p); + } #ifndef CN1_BIBOP_NO_FASTSWEEP // Mark the page dirty so the O(1) sweep never treats a page that still has // fresh mark==-1 (grace-candidate) slots as homogeneous. Single plain store @@ -1471,6 +1504,9 @@ static inline JAVA_OBJECT cn1BibopFastAlloc(CODENAME_ONE_THREAD_STATE, int size, // body zero is elided. static inline JAVA_OBJECT cn1BibopFastAllocNoZero(CODENAME_ONE_THREAD_STATE, int size, struct clazz* parent, int ci) { if(ci < 0) return (JAVA_OBJECT)0; // oversized: folded away for big types + if(__builtin_expect(threadStateData->bibopBypassRemaining[ci] > 0, 0)) { + return (JAVA_OBJECT)0; // cn1BibopAlloc consumes the legacy-bypass budget + } CN1_CLAZZ_REGISTER(parent); // see cn1BibopFastAlloc: every alloc path registers CN1BibopPage* p = bibopCurrent[ci]; if(__builtin_expect(p != (CN1BibopPage*)0 && p->freeList == (void*)0 && @@ -1514,6 +1550,10 @@ static inline JAVA_OBJECT cn1BibopFastAllocNoZero(CODENAME_ONE_THREAD_STATE, int #endif __atomic_store_n(&o->__codenameOneGcMark, -1, __ATOMIC_RELEASE); atomic_store_explicit(&p->bumpIndex, bi + 1, memory_order_release); + if(__builtin_expect(atomic_load_explicit(&p->gcFreshEpoch[currentGcMarkValue & 1], memory_order_relaxed) + != currentGcMarkValue, 0)) { + cn1BibopNoteFreshAllocation(p); + } #ifndef CN1_BIBOP_NO_FASTSWEEP p->gcAllocedSinceSweep = JAVA_TRUE; #endif @@ -1528,7 +1568,7 @@ static inline JAVA_OBJECT cn1BibopFastAllocNoZero(CODENAME_ONE_THREAD_STATE, int // X. The static initializer is invoked only when the class isn't initialised yet // (the bump fast path can be reached for a class whose hasn't run, // because bibopCurrent[] is shared across all classes of the same size class). -#ifndef CN1_DISABLE_INLINE_ALLOC +#if !defined(CN1_DISABLE_INLINE_ALLOC) && !defined(CN1_DISABLE_BIBOP) #define CN1_FAST_NEW(X) ({ \ if(__builtin_expect(!class__##X.initialized, 0)) __STATIC_INITIALIZER_##X(threadStateData); \ JAVA_OBJECT __cn1fo = cn1BibopFastAlloc(threadStateData, sizeof(struct obj__##X), &class__##X, CN1_BIBOP_CIDX(sizeof(struct obj__##X))); \ diff --git a/vm/ByteCodeTranslator/src/cn1_globals.m b/vm/ByteCodeTranslator/src/cn1_globals.m index e9920268d76..704d6780fed 100644 --- a/vm/ByteCodeTranslator/src/cn1_globals.m +++ b/vm/ByteCodeTranslator/src/cn1_globals.m @@ -940,6 +940,7 @@ static void cn1DrainDeadThreadPending() { // Forward (tentative) declaration -- the real definition is below near the worklist; // the belt pass in codenameOneGCMark forces it to trigger the full BiBOP rescan. static JAVA_BOOLEAN gcMarkWorklistOverflow; +static JAVA_BOOLEAN gcMarkOverflowSeen = JAVA_FALSE; #ifndef CN1_DISABLE_BIBOP // Forward declarations -- defined below; the grace-subtree pass in codenameOneGCMark // walks the page registry and its slots before their definitions. @@ -985,6 +986,9 @@ static long cn1SatbTake(JAVA_OBJECT** out) { } void cn1RefreshFreeMemCache(void); // defined near cn1BibopMaybeGc; drives the dynamic pacing cap +#ifndef CN1_DISABLE_BIBOP +void cn1BibopBeginGcCycle(void); +#endif #ifdef CN1_CONSERVATIVE_GC_ROOTS // Immortal object registry (defined near the clazz registry below): objects removed @@ -998,6 +1002,10 @@ static long cn1SatbTake(JAVA_OBJECT** out) { void codenameOneGCMark() { currentGcMarkValue++; + gcMarkOverflowSeen = JAVA_FALSE; +#ifndef CN1_DISABLE_BIBOP + cn1BibopBeginGcCycle(); +#endif cn1RefreshFreeMemCache(); // snapshot free RAM once per cycle for the dynamic pacing cap // Bump the force-mark pass epoch so the force-visited side table's prior-cycle entries // read as not-visited (relocated from the old per-object __codenameOneReferenceCount). @@ -1251,19 +1259,21 @@ void codenameOneGCMark() { // through reference fields, so we need an explicit drain pass before sweep runs. gcMarkDrain(d); +#if CN1_ADOPT_POLICY != 0 && !defined(CN1_DISABLE_BIBOP) + // Make already-matured slots visible in the legacy table before any safety + // rescan. The page rescan can then skip them instead of invoking every adopted + // object's mark function twice. + cn1DrainAdoptBuffer(); +#endif + // NOTE: the SATB log is drained + gcSatbActive cleared AFTER the grace pass and belt // below, so the insertion/deletion barriers stay armed through them -- a mutator that // links an object into a fresh grace object DURING those phases still gets it logged // and marked, closing the residual window. - // Belt pass -- guaranteed drain completeness before sweep. gcMarkDrain triggers the - // BiBOP page rescan only on a worklist OVERFLOW; if a marked object ever had its mark - // function skipped, its reachable children go untraversed and are swept while live -- - // the intermittent Linux crash (a marked Component.BGPainter whose owning Component, - // reached only through this$0, was freed). Force one full rescan + drain to a fixpoint - // unconditionally so EVERY marked object's mark function runs and all reachable children - // are marked. gcMarkDrain re-pushes each marked slot and loops until a pass marks nothing - // new -> O(reachable) and idempotent; recovers any marked-but-untraversed subtree. + // The overflow belt is retained as a correctness backstop, but runs only when a + // worklist push was actually dropped. Normal cycles already drain every pushed + // object and must not pay a second O(reachable) traversal. #ifndef CN1_DISABLE_BIBOP // Grace-subtree marking (CORRECTNESS): a fresh BiBOP object (gcMark==-1) survives this // cycle via grace, and the sweep promotes it to live (gcMark=V, cn1BibopSweep) or pools @@ -1271,27 +1281,35 @@ void codenameOneGCMark() { // fresh, not-yet-linked object is left unmarked and swept. When a mutator later links // that fresh object into the live graph, next cycle it is drained and marks the now // dangling child -> the intermittent Property->Double / container->content crash. Drain - // every grace object here so a surviving grace object's subtree survives WITH it. - // parentCls==0 skips a mid-construction memset-elided slot (its class isn't published - // yet); such an object is reached again next cycle once fully built. + // every fresh NON-LEAF object here so a surviving grace object's subtree survives + // WITH it. Primitive arrays and other leaf classes have no subtree and are left to + // the sweep's normal one-cycle grace. The alternating dirty-page stacks prevent a + // page allocated into during this mark from corrupting the stack being consumed. { - CN1BibopPage* gp = atomic_load_explicit(&bibopAllPages, memory_order_acquire); - while(gp != 0) { - int gn = atomic_load_explicit(&gp->bumpIndex, memory_order_acquire); - for(int gi = 0 ; gi < gn ; gi++) { - JAVA_OBJECT go = cn1BibopSlot(gp, gi); - if(__atomic_load_n(&go->__codenameOneGcMark, __ATOMIC_ACQUIRE) == -1 - && go->__codenameOneParentClsReference != 0) { - gcMarkObject(d, go, JAVA_FALSE); + for(int lane = 0 ; lane < 2 ; lane++) { + CN1BibopPage* gp = atomic_exchange_explicit(&bibopFreshPages[lane], + (CN1BibopPage*)0, + memory_order_acquire); + while(gp != 0) { + atomic_fetch_add_explicit(&cn1BibopFreshPagesScanned, 1, + memory_order_relaxed); + int gn = atomic_load_explicit(&gp->bumpIndex, memory_order_acquire); + for(int gi = 0 ; gi < gn ; gi++) { + JAVA_OBJECT go = cn1BibopSlot(gp, gi); + if(__atomic_load_n(&go->__codenameOneGcMark, __ATOMIC_ACQUIRE) == -1 + && go->__codenameOneParentClsReference != 0 + && go->__codenameOneParentClsReference->markFunction != 0) { + gcMarkObject(d, go, JAVA_FALSE); + } } + gp = gp->nextFresh[lane]; } - gp = atomic_load_explicit(&gp->nextAll, memory_order_acquire); } gcMarkDrain(d); } #endif - { + if(gcMarkOverflowSeen) { long __beltBefore = gcMarkNewObjectCount; #ifdef CN1_BIBOP_VALIDATE gcBeltDiagActive = 1; @@ -1301,6 +1319,9 @@ void codenameOneGCMark() { // this phase, so a "mark nothing new" fixpoint can livelock against ongoing // allocation (observed hanging/breaking FusedTest). A single pass is bounded and // safe; residual incompleteness is handled by the drain-gap fix, not by looping. +#ifndef CN1_DISABLE_BIBOP + atomic_fetch_add_explicit(&cn1BibopBeltRuns, 1, memory_order_relaxed); +#endif gcMarkWorklistOverflow = JAVA_TRUE; // force the BiBOP page-rescan path on gcMarkDrain(d); #ifdef CN1_BIBOP_VALIDATE @@ -1824,6 +1845,21 @@ JAVA_INT java_lang_System_identityHashCode___java_lang_Object_R_int(CODENAME_ONE #ifndef CN1_BIBOP_GC_TRIGGER_BYTES #define CN1_BIBOP_GC_TRIGGER_BYTES (24*1024*1024) #endif +#ifndef CN1_BIBOP_GC_MAX_TRIGGER_BYTES +#define CN1_BIBOP_GC_MAX_TRIGGER_BYTES (192*1024*1024) +#endif +#ifndef CN1_BIBOP_HIGH_THROUGHPUT_BYTES +#define CN1_BIBOP_HIGH_THROUGHPUT_BYTES (8*1024*1024) +#endif +#ifndef CN1_BIBOP_BYPASS_ALLOCATIONS +#define CN1_BIBOP_BYPASS_ALLOCATIONS 65536 +#endif +#ifndef CN1_BIBOP_BYPASS_MIN_SLOTS +#define CN1_BIBOP_BYPASS_MIN_SLOTS 4096 +#endif +#ifndef CN1_BIBOP_BYPASS_SURVIVAL_PERCENT +#define CN1_BIBOP_BYPASS_SURVIVAL_PERCENT 25 +#endif // Header mark sentinel for a slot sitting on a page free-list (distinct from // -1 "fresh", and from any real epoch >= 1). The free-list link is stored in // the slot's first pointer word (the __codenameOneParentClsReference slot), @@ -1848,6 +1884,7 @@ JAVA_INT java_lang_System_identityHashCode___java_lang_Object_R_int(CODENAME_ONE // struct CN1BibopPage is defined in cn1_globals.h (shared with the inlined bump). static CN1BibopPage* _Atomic bibopAllPages = 0; // registry head (atomic) +CN1BibopPage* _Atomic bibopFreshPages[2]; // alternating epoch stacks static _Atomic long long bibopAllPagesCount = 0; // grow-only registration count static CN1BibopPage* bibopFreePool = 0; // bibopMutex static CN1BibopPage* bibopPartialPool[CN1_BIBOP_NUM_CLASSES]; // bibopMutex @@ -1856,6 +1893,22 @@ JAVA_INT java_lang_System_identityHashCode___java_lang_Object_R_int(CODENAME_ONE static pthread_once_t bibopOnce = PTHREAD_ONCE_INIT; // Non-static: also read/written by the inlined bump fast path (cn1_globals.h). _Atomic long bibopBytesSinceGc = 0; +_Atomic long bibopGcTriggerBytes = CN1_BIBOP_GC_TRIGGER_BYTES; +_Atomic int bibopBypassGeneration[CN1_BIBOP_NUM_CLASSES]; +static long bibopCycleAllocatedBytes = 0; +static long bibopLastCycleOccupiedBytes = 0; +static long bibopLastCycleLiveBytes = 0; +static long bibopLastCycleReclaimedBytes = 0; +static int bibopHighSurvivalStreak[CN1_BIBOP_NUM_CLASSES]; + +// QA instrumentation only. The adaptive policy itself is always enabled. +_Atomic long cn1BibopHighThroughputPromotions = 0; +_Atomic long cn1BibopBypassActivations = 0; +_Atomic long cn1BibopBypassAllocations = 0; +_Atomic long cn1BibopFreshPagesScanned = 0; +_Atomic long cn1BibopBeltRuns = 0; +_Atomic long cn1BibopAdoptedRescanSkips = 0; +static int bibopTriggerHighSurvivalStreak = 0; // (The old global BiBOP-monitor count that suppressed the O(1) all-dead reclaim // for EVERY page while ANY monitor existed is gone: java.lang.System.LOCK is a @@ -1884,6 +1937,8 @@ static void cn1BibopDoInit() { } for(int i = 0 ; i < CN1_BIBOP_NUM_CLASSES ; i++) { bibopPartialPool[i] = 0; + atomic_store_explicit(&bibopBypassGeneration[i], 0, memory_order_relaxed); + bibopHighSurvivalStreak[i] = 0; } } @@ -1900,6 +1955,8 @@ static void cn1BibopFormatPage(CN1BibopPage* p, int ci) { p->freeList = 0; p->freeCount = 0; p->owned = JAVA_FALSE; + p->nextFresh[0] = 0; + p->nextFresh[1] = 0; #ifndef CN1_BIBOP_NO_FASTSWEEP p->gcAllocedSinceSweep = JAVA_FALSE; p->gcNeedsReclaim = JAVA_FALSE; @@ -1908,6 +1965,38 @@ static void cn1BibopFormatPage(CN1BibopPage* p, int ci) { atomic_store_explicit(&p->gcLastMarkedEpoch, 0, memory_order_relaxed); p->gcGraceEpoch = 0; #endif + atomic_store_explicit(&p->gcFreshEpoch[0], 0, memory_order_relaxed); + atomic_store_explicit(&p->gcFreshEpoch[1], 0, memory_order_relaxed); +} + +// Queue a page only on its first allocation in a GC epoch. The grace pass can +// now walk pages that actually received fresh objects instead of rescanning the +// grow-only registry on every collection. +void cn1BibopNoteFreshAllocation(CN1BibopPage* page) { + int epoch = currentGcMarkValue; + int lane = epoch & 1; + int seen = atomic_load_explicit(&page->gcFreshEpoch[lane], memory_order_relaxed); + while(seen != epoch) { + if(atomic_compare_exchange_weak_explicit(&page->gcFreshEpoch[lane], &seen, epoch, + memory_order_acq_rel, + memory_order_relaxed)) { + CN1BibopPage* head = atomic_load_explicit(&bibopFreshPages[lane], memory_order_relaxed); + do { + page->nextFresh[lane] = head; + } while(!atomic_compare_exchange_weak_explicit(&bibopFreshPages[lane], &head, page, + memory_order_release, + memory_order_relaxed)); + return; + } + } +} + +void cn1BibopBeginGcCycle(void) { + // Charge allocations racing this mark to the NEXT cycle. The old sweep-end + // store lost those bytes and could delay a collection indefinitely under a + // sustained allocator. + bibopCycleAllocatedBytes = atomic_exchange_explicit(&bibopBytesSinceGc, 0, + memory_order_acq_rel); } // Raw 64KB page memory comes from large arenas -- one posix_memalign per @@ -1990,8 +2079,8 @@ static inline JAVA_OBJECT cn1BibopSlot(CN1BibopPage* p, int i) { // GC exceeds this hard cap does the mutator wait for the collector to catch up, // bounding RSS. When the collector keeps up (bytes stays near the trigger) this // never waits. Disable with -DCN1_BIBOP_NO_PACING for A/B. -#ifndef CN1_BIBOP_GC_HARD_CAP -#define CN1_BIBOP_GC_HARD_CAP (CN1_BIBOP_GC_TRIGGER_BYTES * 3) +#ifndef CN1_BIBOP_GC_HARD_CAP_MULTIPLIER +#define CN1_BIBOP_GC_HARD_CAP_MULTIPLIER 3 #endif // A thread with more than this many legacy allocations since the last GC (heapAllocationSize, // reset each cycle) is treated as high-throughput and gets the deeper pacing headroom below. @@ -2016,8 +2105,34 @@ void cn1RefreshFreeMemCache(void) { // thread we most want to keep responsive -- gets double headroom; it must never be throttled // unless memory is genuinely tight. Never returns LESS than the old static cap, so no workload // gets a tighter bound than before. -DCN1_BIBOP_NO_PACING still disables pacing entirely for A/B. +static void cn1BibopUpdateThreadPolicy(CODENAME_ONE_THREAD_STATE) { + int epoch = currentGcMarkValue; + if(threadStateData->bibopObservedGcEpoch != epoch) { + threadStateData->bibopObservedGcEpoch = epoch; + threadStateData->bibopEpochBytes = 0; + } + if(threadStateData->bibopEpochBytes >= CN1_BIBOP_HIGH_THROUGHPUT_BYTES && + threadStateData->bibopHighThroughputUntilEpoch < epoch + 2) { + threadStateData->bibopHighThroughputUntilEpoch = epoch + 2; + atomic_fetch_add_explicit(&cn1BibopHighThroughputPromotions, 1, + memory_order_relaxed); + } + // Survivor-heavy size classes publish a new generation at sweep. Threads + // consume that signal only on their rare page-acquire path, then route a + // bounded allocation sample through the legacy collector before reprobing. + for(int ci = 0 ; ci < CN1_BIBOP_NUM_CLASSES ; ci++) { + int generation = atomic_load_explicit(&bibopBypassGeneration[ci], + memory_order_relaxed); + if(threadStateData->bibopBypassSeen[ci] != generation) { + threadStateData->bibopBypassSeen[ci] = generation; + threadStateData->bibopBypassRemaining[ci] = CN1_BIBOP_BYPASS_ALLOCATIONS; + } + } +} + static long cn1BibopPacingCap(CODENAME_ONE_THREAD_STATE) { - long base = (long)CN1_BIBOP_GC_HARD_CAP; // old static cap (3x trigger) + long trigger = atomic_load_explicit(&bibopGcTriggerBytes, memory_order_relaxed); + long base = trigger * CN1_BIBOP_GC_HARD_CAP_MULTIPLIER; long fm = atomic_load_explicit(&cn1CachedFreeMem, memory_order_relaxed); long cap = fm / 8; // baseline: 1/8 of available RAM of slack if(cap < base) cap = base; // never tighter than before @@ -2029,6 +2144,7 @@ static long cn1BibopPacingCap(CODENAME_ONE_THREAD_STATE) { // bounded by real available memory (get_free_memory now reports reclaimable pages), so RSS // stays safe and the collector reclaims the transient churn. if(isEdt(threadStateData->threadId) + || threadStateData->bibopHighThroughputUntilEpoch >= currentGcMarkValue || threadStateData->heapAllocationSize > CN1_BIBOP_HIGH_THROUGHPUT_ALLOCS) { long hi = fm / 2; if(hi > cap) cap = hi; @@ -2040,6 +2156,7 @@ static void cn1BibopMaybeGc(CODENAME_ONE_THREAD_STATE) { // LEVER A: flush this thread's plain-add byte accumulator into the global atomic // (once per page-acquire). No-op unless -DCN1_DEATOMIC_BYTES. CN1_BIBOP_FLUSH_BYTES(threadStateData); + cn1BibopUpdateThreadPolicy(threadStateData); if(constantPoolObjects == 0) { return; } @@ -2069,8 +2186,9 @@ static void cn1BibopMaybeGc(CODENAME_ONE_THREAD_STATE) { } threadStateData->threadActive = JAVA_TRUE; } + long __gcTrigger = atomic_load_explicit(&bibopGcTriggerBytes, memory_order_relaxed); if(!gcCurrentlyRunning && - atomic_load_explicit(&bibopBytesSinceGc, memory_order_relaxed) > CN1_BIBOP_GC_TRIGGER_BYTES) { + atomic_load_explicit(&bibopBytesSinceGc, memory_order_relaxed) > __gcTrigger) { // save/restore: we may already be INSIDE a caller's native-allocation // bracket (reachable here under CN1_CONSERVATIVE_GC_ROOTS) JAVA_BOOLEAN wasNam = threadStateData->nativeAllocationMode; @@ -2171,6 +2289,8 @@ static inline void cn1BibopInitSlot(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT o, in __atomic_store_n(&o->__codenameOneGcMark, -1, __ATOMIC_RELEASE); } +#endif /* CN1_DISABLE_BIBOP */ + #ifdef CN1_CONSERVATIVE_GC_ROOTS // ============================ Exact clazz registry ============================ // The conservative scan/drain can hand gcMarkObject a pointer to a FREED object @@ -2327,6 +2447,8 @@ void cn1GcRegisterImmortalObj(JAVA_OBJECT o) { static JAVA_BOOLEAN cn1SweepRemoving = JAVA_FALSE; #endif // CN1_CONSERVATIVE_GC_ROOTS +#ifndef CN1_DISABLE_BIBOP + // Allocate a small non-array object from the per-thread page for its size class. // Returns 0 only if pages cannot be obtained (caller falls back to the heap). static JAVA_OBJECT cn1BibopAlloc(CODENAME_ONE_THREAD_STATE, int size, struct clazz* parent) { @@ -2336,6 +2458,14 @@ static JAVA_OBJECT cn1BibopAlloc(CODENAME_ONE_THREAD_STATE, int size, struct cla if(ci < 0) { return 0; } + if(threadStateData->bibopBypassRemaining[ci] > 0) { + threadStateData->bibopBypassRemaining[ci]--; +#ifdef CN1_GC_INSTRUMENT + atomic_fetch_add_explicit(&cn1BibopBypassAllocations, 1, + memory_order_relaxed); +#endif + return 0; + } CN1BibopPage* p = bibopCurrent[ci]; JAVA_OBJECT o = 0; for(;;) { @@ -2353,6 +2483,10 @@ static JAVA_OBJECT cn1BibopAlloc(CODENAME_ONE_THREAD_STATE, int size, struct cla // publish the new cursor with release AFTER the slot (incl. its // mark) is fully initialized. atomic_store_explicit(&p->bumpIndex, bi + 1, memory_order_release); + if(atomic_load_explicit(&p->gcFreshEpoch[currentGcMarkValue & 1], memory_order_relaxed) + != currentGcMarkValue) { + cn1BibopNoteFreshAllocation(p); + } #ifndef CN1_BIBOP_NO_FASTSWEEP p->gcAllocedSinceSweep = JAVA_TRUE; #endif @@ -2370,6 +2504,10 @@ static JAVA_OBJECT cn1BibopAlloc(CODENAME_ONE_THREAD_STATE, int size, struct cla } // free-list slot path cn1BibopInitSlot(threadStateData, o, size, parent); + if(atomic_load_explicit(&p->gcFreshEpoch[currentGcMarkValue & 1], memory_order_relaxed) + != currentGcMarkValue) { + cn1BibopNoteFreshAllocation(p); + } #ifndef CN1_BIBOP_NO_FASTSWEEP p->gcAllocedSinceSweep = JAVA_TRUE; #endif @@ -2377,6 +2515,8 @@ static JAVA_OBJECT cn1BibopAlloc(CODENAME_ONE_THREAD_STATE, int size, struct cla return o; } +#endif /* CN1_DISABLE_BIBOP */ + // ---- Monitor side table (relocated __codenameOneThreadData out of the object header) ---- // The lazily-attached per-object monitor (CN1ThreadData*) is NULL on virtually every // object, so storing it in every header wasted 8 bytes/object. It now lives in an @@ -2438,6 +2578,8 @@ void cn1MonitorDataSet(JAVA_OBJECT o, void* data) { return r; } +#ifndef CN1_DISABLE_BIBOP + // Run finalizer + free monitor for a dead page slot (does NOT free() the slot; // the slot is recycled into the page free-list by the caller). Mirrors // freeAndFinalize / codenameOneGcFree minus the free(). @@ -2501,12 +2643,99 @@ void cn1BibopNoteMonitorAttached(JAVA_OBJECT obj) { void cn1BibopNoteNativePeer(JAVA_OBJECT obj) { (void)obj; } #endif +static void cn1BibopAdaptAfterSweep(long occupiedBytes, long liveBytes, + long reclaimedBytes, + long* classSlots, long* classLive) { + bibopLastCycleOccupiedBytes = occupiedBytes; + bibopLastCycleLiveBytes = liveBytes; + bibopLastCycleReclaimedBytes = reclaimedBytes; + + if(occupiedBytes >= (2 * 1024 * 1024)) { + int survival = (int)((liveBytes * 100) / occupiedBytes); + long oldTrigger = atomic_load_explicit(&bibopGcTriggerBytes, + memory_order_relaxed); + long newTrigger = oldTrigger; + if(lowMemoryMode) { + bibopTriggerHighSurvivalStreak = 0; + newTrigger = CN1_BIBOP_GC_TRIGGER_BYTES; + } else if(survival >= CN1_BIBOP_BYPASS_SURVIVAL_PERCENT) { + bibopTriggerHighSurvivalStreak++; + if(bibopTriggerHighSurvivalStreak >= 2) { + long ceiling = CN1_BIBOP_GC_MAX_TRIGGER_BYTES; + long freeMem = atomic_load_explicit(&cn1CachedFreeMem, + memory_order_relaxed); + if(freeMem > 0 && freeMem / 8 < ceiling) ceiling = freeMem / 8; + if(ceiling < CN1_BIBOP_GC_TRIGGER_BYTES) { + ceiling = CN1_BIBOP_GC_TRIGGER_BYTES; + } + newTrigger = oldTrigger * 2; + if(newTrigger > ceiling) newTrigger = ceiling; + bibopTriggerHighSurvivalStreak = 0; + } + } else if(survival <= 20) { + bibopTriggerHighSurvivalStreak = 0; + if(oldTrigger > CN1_BIBOP_GC_TRIGGER_BYTES) { + newTrigger = oldTrigger / 2; + if(newTrigger < CN1_BIBOP_GC_TRIGGER_BYTES) { + newTrigger = CN1_BIBOP_GC_TRIGGER_BYTES; + } + } + } + if(newTrigger != oldTrigger) { + atomic_store_explicit(&bibopGcTriggerBytes, newTrigger, + memory_order_relaxed); + } + } + + for(int ci = 0 ; ci < CN1_BIBOP_NUM_CLASSES ; ci++) { + if(classSlots[ci] < CN1_BIBOP_BYPASS_MIN_SLOTS) { + continue; + } + int survival = (int)((classLive[ci] * 100) / classSlots[ci]); + if(survival >= CN1_BIBOP_BYPASS_SURVIVAL_PERCENT) { + if(++bibopHighSurvivalStreak[ci] >= 2) { + atomic_fetch_add_explicit(&bibopBypassGeneration[ci], 1, + memory_order_relaxed); + atomic_fetch_add_explicit(&cn1BibopBypassActivations, 1, + memory_order_relaxed); + bibopHighSurvivalStreak[ci] = 0; + } + } else if(survival <= 20) { + bibopHighSurvivalStreak[ci] = 0; + } + } +#ifdef CN1_GC_INSTRUMENT + fprintf(stderr, + "[BIBOP-ADAPT] epoch=%d allocatedMB=%.1f triggerMB=%.1f occupiedMB=%.1f " + "liveMB=%.1f reclaimedMB=%.1f promotions=%ld bypass=%ld bypassAllocs=%ld freshPages=%ld " + "beltRuns=%ld adoptedSkips=%ld\n", + currentGcMarkValue, + bibopCycleAllocatedBytes / (1024.0 * 1024.0), + atomic_load_explicit(&bibopGcTriggerBytes, memory_order_relaxed) / + (1024.0 * 1024.0), + bibopLastCycleOccupiedBytes / (1024.0 * 1024.0), + bibopLastCycleLiveBytes / (1024.0 * 1024.0), + bibopLastCycleReclaimedBytes / (1024.0 * 1024.0), + atomic_load_explicit(&cn1BibopHighThroughputPromotions, memory_order_relaxed), + atomic_load_explicit(&cn1BibopBypassActivations, memory_order_relaxed), + atomic_load_explicit(&cn1BibopBypassAllocations, memory_order_relaxed), + atomic_load_explicit(&cn1BibopFreshPagesScanned, memory_order_relaxed), + atomic_load_explicit(&cn1BibopBeltRuns, memory_order_relaxed), + atomic_load_explicit(&cn1BibopAdoptedRescanSkips, memory_order_relaxed)); +#endif +} + // Sweep all retired pages. Runs on the GC thread AFTER mark completes; the // pages it processes are off the SWEEP stack (owner==0), so no mutator is // allocating into them and no marking is in flight -> plain header access. static void cn1BibopSweep(CODENAME_ONE_THREAD_STATE) { CN1BibopPage* list = atomic_exchange_explicit(&bibopSweepStack, (CN1BibopPage*)0, memory_order_acquire); int V = currentGcMarkValue; // stable during the sweep (mark done, not yet incremented) + long occupiedBytes = 0; + long liveBytes = 0; + long reclaimedBytes = 0; + long classSlots[CN1_BIBOP_NUM_CLASSES] = {0}; + long classLive[CN1_BIBOP_NUM_CLASSES] = {0}; #ifndef CN1_BIBOP_NO_FASTSWEEP // Snapshot once: if ANY BiBOP object currently carries a monitor, suppress the O(1) // all-dead shortcut this whole sweep so dead monitored slots are full-walked and @@ -2531,6 +2760,7 @@ static void cn1BibopSweep(CODENAME_ONE_THREAD_STATE) { abort(); } #endif + int n = atomic_load_explicit(&page->bumpIndex, memory_order_acquire); #ifndef CN1_BIBOP_NO_FASTSWEEP // ---- O(1) page decision (no per-slot walk). ------------------------------- // A page is HOMOGENEOUS when every occupied slot is a dead-or-graced object @@ -2562,6 +2792,8 @@ static void cn1BibopSweep(CODENAME_ONE_THREAD_STATE) { page->nextPool = bibopPartialPool[page->classIndex]; bibopPartialPool[page->classIndex] = page; pthread_mutex_unlock(&bibopMutex); + occupiedBytes += (long)n * page->slotSize; + classSlots[page->classIndex] += n; continue; } else if(!page->gcHasMonitors) { // AGED PAST GRACE (even the youngest survivor at gcGraceEpoch < V-1 is @@ -2578,6 +2810,9 @@ static void cn1BibopSweep(CODENAME_ONE_THREAD_STATE) { page->nextPool = bibopFreePool; bibopFreePool = page; pthread_mutex_unlock(&bibopMutex); + occupiedBytes += (long)n * page->slotSize; + reclaimedBytes += (long)n * page->slotSize; + classSlots[page->classIndex] += n; continue; } // else: all-dead but a BiBOP monitor is live -> fall through to the full walk @@ -2588,24 +2823,26 @@ static void cn1BibopSweep(CODENAME_ONE_THREAD_STATE) { // slot i < n the header stores (parentCls / heapPosition / mark) that // preceded that release are visible to this walk. Relaxed could observe a // freshly-bumped slot with a garbage header. - int n = atomic_load_explicit(&page->bumpIndex, memory_order_acquire); + int oldFreeCount = page->freeCount; void* fl = 0; int freeCount = 0; int liveCount = 0; + int policyLiveCount = 0; #ifndef CN1_BIBOP_NO_FASTSWEEP JAVA_BOOLEAN needsReclaim = JAVA_FALSE; #endif for(int i = 0 ; i < n ; i++) { JAVA_OBJECT o = cn1BibopSlot(page, i); + int m = o->__codenameOneGcMark; // MATURED (adopted) slot: its lifecycle belongs to the legacy mark/sweep now. // Skip it entirely (no double-clearing) -- BiBOP counts it as occupied/live so // the page isn't reclaimed. The legacy sweep flips it back to -3 on death, and a // LATER BiBOP sweep of this page then reclaims it as a normal dead slot. if(o->__heapPosition == CN1_BIBOP_ADOPTED) { liveCount++; + if(m == V) policyLiveCount++; continue; } - int m = o->__codenameOneGcMark; if(m == CN1_BIBOP_FREE_MARK) { *(void**)o = fl; fl = o; freeCount++; } else if(m == -1) { @@ -2628,6 +2865,7 @@ static void cn1BibopSweep(CODENAME_ONE_THREAD_STATE) { *(void**)o = fl; fl = o; freeCount++; } else { liveCount++; + if(m == V) policyLiveCount++; #ifndef CN1_BIBOP_NO_FASTSWEEP // parentCls==0 guard mirrors the mark==-1 grace branch above: a // memset-elided object can be published only once every field is @@ -2641,6 +2879,12 @@ static void cn1BibopSweep(CODENAME_ONE_THREAD_STATE) { } page->freeList = fl; page->freeCount = freeCount; + int sampledSlots = n - oldFreeCount; + occupiedBytes += (long)sampledSlots * page->slotSize; + liveBytes += (long)policyLiveCount * page->slotSize; + reclaimedBytes += (long)(sampledSlots - liveCount) * page->slotSize; + classSlots[page->classIndex] += sampledSlots; + classLive[page->classIndex] += policyLiveCount; #ifndef CN1_BIBOP_NO_FASTSWEEP // The monitor (CN1ThreadData) no longer lives in the object header, so the // per-slot "has a monitor" test is gone. Conservatively flag any page that still @@ -2666,7 +2910,8 @@ static void cn1BibopSweep(CODENAME_ONE_THREAD_STATE) { } pthread_mutex_unlock(&bibopMutex); } - atomic_store_explicit(&bibopBytesSinceGc, 0, memory_order_relaxed); + cn1BibopAdaptAfterSweep(occupiedBytes, liveBytes, reclaimedBytes, + classSlots, classLive); } // (The overflow-rescan helpers cn1BibopRescanStart / cn1BibopRescanStep live @@ -2691,6 +2936,16 @@ void cn1BibopRetireThreadPages() { } #endif /* CN1_DISABLE_BIBOP */ +#ifdef CN1_DISABLE_BIBOP +// The legacy collector still uses the shared free-memory snapshot during mark. +// Keep its QA build self-contained even though it has no BiBOP pacing policy. +_Atomic long cn1CachedFreeMem = 0; +void cn1RefreshFreeMemCache(void) { + atomic_store_explicit(&cn1CachedFreeMem, cn1_available_memory(), + memory_order_relaxed); +} +#endif + #ifdef CN1_CONSERVATIVE_GC_ROOTS // ========================================================================= // PHASE 3b: conservative native-C-stack scanning AS A REAL GC ROOT SOURCE. @@ -3759,6 +4014,7 @@ static void gcMarkFlushLocal(struct gcMarkLocalBuffer* lb) { for(int i = 0 ; i < lb->count ; i++) { if(gcMarkWorklistTop >= CN1_GC_MARK_WORKLIST_SIZE) { gcMarkWorklistOverflow = JAVA_TRUE; + gcMarkOverflowSeen = JAVA_TRUE; break; } gcMarkWorklist[gcMarkWorklistTop] = lb->entries[i]; @@ -3787,6 +4043,7 @@ static inline void gcMarkWorklistPush(JAVA_OBJECT obj, JAVA_BOOLEAN force) { // Serial path: identical to the original single-threaded push. if(gcMarkWorklistTop >= CN1_GC_MARK_WORKLIST_SIZE) { gcMarkWorklistOverflow = JAVA_TRUE; + gcMarkOverflowSeen = JAVA_TRUE; return; } gcMarkWorklist[gcMarkWorklistTop].obj = obj; @@ -4391,6 +4648,13 @@ static JAVA_BOOLEAN cn1BibopRescanStep() { JAVA_OBJECT o = cn1BibopSlot(p, bibopRescanSlot); bibopRescanSlot++; int m = __atomic_load_n(&o->__codenameOneGcMark, __ATOMIC_ACQUIRE); + if(m == currentGcMarkValue && o->__heapPosition == CN1_BIBOP_ADOPTED) { + // Overflow setup drains the adoption buffer into allObjectsInHeap; + // the legacy half of this same rescan owns this object now. + atomic_fetch_add_explicit(&cn1BibopAdoptedRescanSkips, 1, + memory_order_relaxed); + continue; + } if(m == currentGcMarkValue && o->__codenameOneParentClsReference->markFunction != 0) { gcMarkWorklistPush(o, JAVA_FALSE); } @@ -4477,14 +4741,24 @@ static void gcMarkDrain(CODENAME_ONE_THREAD_STATE) { #endif } } - int total = currentSizeOfAllObjectsInHeap; #ifndef CN1_DISABLE_BIBOP +#if CN1_ADOPT_POLICY != 0 + // A rescan drain can mature more descendants. Register each batch before + // the next page-rescan step so every adopted slot skipped by that step is + // already owned by the legacy half of the same fixed-point scan. + if(gcMarkWorklistOverflow || bibopActive) { + cn1DrainAdoptBuffer(); + } +#endif // First time we observe an overflow, start also rescanning page slots. if(gcMarkWorklistOverflow && !bibopActive) { bibopActive = JAVA_TRUE; bibopDone = JAVA_FALSE; cn1BibopRescanStart(); } +#endif + int total = currentSizeOfAllObjectsInHeap; +#ifndef CN1_DISABLE_BIBOP JAVA_BOOLEAN scanDone = (rescanCursor >= total) && bibopDone; #else JAVA_BOOLEAN scanDone = (rescanCursor >= total); diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index 1b97405fa82..ac127a8fccd 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -1643,6 +1643,18 @@ JAVA_INT java_lang_Object_hashCode___R_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJEC // read by the inlined alloc fast path (cn1BibopFastAlloc) before any setter // runs -- garbage-nonzero silently disables the fast path for the thread. i->bibopBytesLocal = 0; + i->bibopEpochBytes = 0; + i->bibopObservedGcEpoch = currentGcMarkValue; + i->bibopHighThroughputUntilEpoch = 0; + for(int __bi = 0 ; __bi < CN1_BIBOP_NUM_CLASSES ; __bi++) { +#ifndef CN1_DISABLE_BIBOP + i->bibopBypassSeen[__bi] = atomic_load_explicit(&bibopBypassGeneration[__bi], + memory_order_relaxed); +#else + i->bibopBypassSeen[__bi] = 0; +#endif + i->bibopBypassRemaining[__bi] = 0; + } i->nativeAllocationMode = JAVA_FALSE; // dead-thread pending-migration queue state (single-writer allObjectsInHeap) i->gcDeadNext = 0; diff --git a/vm/benchmarks/README.md b/vm/benchmarks/README.md index f3a7af1d7a7..973066a7f9f 100644 --- a/vm/benchmarks/README.md +++ b/vm/benchmarks/README.md @@ -24,6 +24,9 @@ CN1_BENCH_CFLAGS="" ./run-benchmark.sh # without ThinLTO (debug shape) ./run-gauntlet.sh # the correctness gate: all tortures byte-identical # + GC stress in cooperative AND forced-signal modes + +./run-bibop-adaptive.sh # issue-5425 retained-small-array correctness, + # adaptive-policy, wall-time, and peak-RSS gate ``` Requirements: Maven and clang on `PATH` (gcc also works: @@ -63,6 +66,24 @@ measurement runner (`BENCH rep ns= checksum=` lines): | ThreadChurn | thread lifecycle | | GcStress / MtStress | allocation storms, single- and multi-threaded, in cooperative and forced-signal (`CN1_GC_SIGNAL_STOP=1`) stop modes | +## Adaptive BiBOP regression gate + +`run-bibop-adaptive.sh` reproduces the allocator shape from issue 5425 at its +reported scale: 560,000 retained small `byte[]` values, temporary key arrays, +and continued churn across several completed GC epochs. The Java workload checks +every sampled retained array after each collection and asserts a checksum produced +by the host JVM. The harness additionally requires runtime evidence that: + +- the BiBOP-only allocator thread graduated to the high-throughput pacing tier; +- the 24 MiB baseline trigger grew under sustained survival; +- a survivor-heavy size class activated the bounded legacy bypass/reprobe path; +- grace marking used the fresh-page set rather than the grow-only page registry. + +It then measures best wall time and per-process peak RSS for the production +adaptive collector against the legacy collector and a no-pacing diagnostic build. +Those compile-time variants are QA controls only; applications ship one collector +with the adaptive behavior enabled, not user-selectable GC flags. + `ClinitThrow` is a standalone liveness reproducer (not byte-identical to the host JVM by design — ParparVM's initialization-failure semantics differ): a throwing `` must release the class-init monitor so other threads diff --git a/vm/benchmarks/run-bibop-adaptive.sh b/vm/benchmarks/run-bibop-adaptive.sh new file mode 100755 index 00000000000..f57e69129f6 --- /dev/null +++ b/vm/benchmarks/run-bibop-adaptive.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# Correctness + policy validation + performance/RAM A/B for the sustained +# survivor-heavy small-array shape. Collector flags below are QA controls only; +# production always uses the single adaptive BiBOP behavior. +set -euo pipefail +cd "$(dirname "$0")" + +ROUNDS="${1:-3}" +LTO="${CN1_BENCH_LTO--flto=thin}" +mkdir -p target/bibop-adaptive + +build() { + local name="$1" + shift + CN1_BENCH_CFLAGS="$LTO $*" ./translate-and-build.sh BiBopAdaptive \ + "target/bibop-adaptive/$name" >/dev/null + echo "built $name" +} + +build adaptive-instrument -DCN1_GC_INSTRUMENT +build adaptive +build legacy -DCN1_DISABLE_BIBOP +build no-pacing -DCN1_BIBOP_NO_PACING + +python3 - "$ROUNDS" <<'PY' +import json +import os +import re +import subprocess +import sys + +rounds = int(sys.argv[1]) +root = os.path.join("target", "bibop-adaptive") +bins = {name: os.path.join(root, name) for name in + ("adaptive", "legacy", "no-pacing")} + +diag = subprocess.run([os.path.join(root, "adaptive-instrument")], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) +if diag.returncode != 0: + sys.stderr.buffer.write(diag.stderr) + raise SystemExit("instrumented adaptive workload failed") +lines = diag.stderr.decode("utf-8", "replace").splitlines() +adapt = [line for line in lines if line.startswith("[BIBOP-ADAPT]")] +if not adapt: + raise SystemExit("no BiBOP adaptive diagnostics were emitted") + +def maximum(field): + values = [] + for line in adapt: + match = re.search(r"\b%s=([0-9.]+)" % re.escape(field), line) + if match: + values.append(float(match.group(1))) + return max(values) if values else 0.0 + +def last(field): + values = [] + for line in adapt: + match = re.search(r"\b%s=([0-9.]+)" % re.escape(field), line) + if match: + values.append(float(match.group(1))) + return values[-1] if values else 0.0 + +checks = { + "thread throughput promotion": maximum("promotions") >= 1, + "dynamic trigger growth": maximum("triggerMB") > 24.0, + "dynamic trigger contraction": last("triggerMB") <= 24.0, + "survivor legacy bypass": maximum("bypass") >= 1, + "legacy bypass allocations": maximum("bypassAllocs") >= 1, + "fresh-page grace scan": maximum("freshPages") >= 1, + "no unconditional mark belt": maximum("beltRuns") == 0, +} +failed = [name for name, ok in checks.items() if not ok] +if failed: + print("last adaptive diagnostics:", file=sys.stderr) + for line in adapt[-8:]: + print(line, file=sys.stderr) + raise SystemExit("adaptive policy checks failed: " + ", ".join(failed)) + +# Run each measurement in a fresh Python process. This makes RUSAGE_CHILDREN's +# ru_maxrss a per-binary value instead of the cumulative-max proxy used by the old +# adoption A/B script. +helper = r''' +import json, platform, resource, subprocess, sys, time +t0 = time.monotonic() +p = subprocess.run([sys.argv[1]], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +elapsed = time.monotonic() - t0 +rss = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss +if platform.system() != "Darwin": + rss *= 1024 +print(json.dumps({"returncode": p.returncode, "seconds": elapsed, "rss": rss})) +''' + +samples = {name: [] for name in bins} +for round_no in range(rounds): + for name, path in bins.items(): + measured = subprocess.run([sys.executable, "-c", helper, path], + stdout=subprocess.PIPE, text=True, check=True) + sample = json.loads(measured.stdout) + if sample["returncode"] != 0: + raise SystemExit("%s benchmark run failed" % name) + samples[name].append(sample) + print("round %d/%d" % (round_no + 1, rounds), flush=True) + +best = {name: min(s["seconds"] for s in values) + for name, values in samples.items()} +peak = {name: max(s["rss"] for s in values) + for name, values in samples.items()} + +print("\n%-14s %10s %14s" % ("variant", "best sec", "peak RSS MB")) +for name in ("adaptive", "legacy", "no-pacing"): + print("%-14s %10.3f %14.1f" % + (name, best[name], peak[name] / (1024.0 * 1024.0))) +print("adaptive/legacy: time %.2fx, RSS %.2fx" % + (best["adaptive"] / best["legacy"], peak["adaptive"] / float(peak["legacy"]))) + +# Generous gates catch real regressions without turning normal scheduler noise into +# churn. The adaptive collector must remain competitive with the working legacy +# collector and its page retention must stay within a bounded additive allowance. +if best["adaptive"] > best["legacy"] * 1.35: + raise SystemExit("adaptive collector is more than 35% slower than legacy") +rss_limit = max(peak["legacy"] * 1.50, + peak["legacy"] + 64 * 1024 * 1024) +if peak["adaptive"] > rss_limit: + raise SystemExit("adaptive collector peak RSS exceeds legacy regression bound") + +print("BIBOP ADAPTIVE BENCHMARK GREEN (correctness + policy + perf/RAM)") +PY diff --git a/vm/benchmarks/src/com/bench/BiBopAdaptive.java b/vm/benchmarks/src/com/bench/BiBopAdaptive.java new file mode 100644 index 00000000000..e87c8818304 --- /dev/null +++ b/vm/benchmarks/src/com/bench/BiBopAdaptive.java @@ -0,0 +1,120 @@ +package com.bench; + +/** + * Sustained small-array allocation with a large retained set. This deliberately + * models the issue-5425 shape: a temporary key-sized byte[] and a retained + * compressed-value-sized byte[] per definition. Unlike GcStress, it runs long + * enough for the allocator and collector to observe more than one survival + * epoch, then verifies the retained data after every collection. + */ +public final class BiBopAdaptive { + private static final int RETAINED = 560000; + private static final int CHURN_PER_PHASE = 360000; + private static byte[] escape; + private static byte[][] retainedRoot; + + private static void pause(long millis) throws InterruptedException { + long deadline = System.currentTimeMillis() + millis; + for (;;) { + long remaining = deadline - System.currentTimeMillis(); + if (remaining <= 0) { + return; + } + // Native signals may interrupt usleep on some targets; retry until + // wall-clock time proves the concurrent collector got its window. + Thread.sleep(remaining); + } + } + + private static long fill(byte[] value, int seed) { + long checksum = 0; + for (int i = 0; i < value.length; i++) { + value[i] = (byte)(seed * 31 + i * 17); + checksum += value[i]; + } + return checksum; + } + + private static long verify(byte[][] retained) { + long checksum = 0; + for (int i = 0; i < retained.length; i += 97) { + byte[] value = retained[i]; + if (value == null || value.length != 16) { + throw new RuntimeException("retained array lost at " + i); + } + for (int j = 0; j < value.length; j++) { + byte expected = (byte)(i * 31 + j * 17); + if (value[j] != expected) { + throw new RuntimeException("retained array corrupted at " + i + "/" + j); + } + checksum += value[j]; + } + } + return checksum; + } + + private static long churn(int phase) { + long checksum = 0; + for (int i = 0; i < CHURN_PER_PHASE; i++) { + byte[] temporaryKey = new byte[8]; + checksum += fill(temporaryKey, i ^ phase); + if ((i & 8191) == 0) { + escape = temporaryKey; + } + } + return checksum; + } + + public static void main(String[] args) throws Exception { + // ParparVM deliberately delays the first GC cycle for two seconds to avoid + // startup interference. Start it before measuring the sustained phase and + // wait past that one-time delay so every explicit collection below is real. + System.gc(); + pause(2200); + byte[][] retained = new byte[RETAINED][]; + retainedRoot = retained; + long checksum = 0; + for (int i = 0; i < retained.length; i++) { + byte[] temporaryKey = new byte[8]; + checksum += fill(temporaryKey, i ^ 0x55aa); + byte[] compressed = new byte[16]; + checksum += fill(compressed, i); + retained[i] = compressed; + } + + // Multiple epochs are essential: one-cycle grace must not be mistaken for + // a survivor-heavy workload, and both trigger growth and legacy bypass use + // a consecutive-sample decision. + for (int phase = 0; phase < 5; phase++) { + checksum += churn(phase); + System.gc(); + pause(200); + checksum += verify(retained); + } + + // Keep the retained set observable through the final checksum, then drop it + // and make sure the collector can return to a churn-heavy/reclaiming phase. + checksum += verify(retained); + retained = null; + retainedRoot = null; + for (int phase = 5; phase < 8; phase++) { + checksum += churn(phase); + System.gc(); + pause(200); + } + // The collector is concurrent and System.gc() is intentionally non-blocking. + // Keep the process alive long enough for the final requested cycles and their + // diagnostics to finish before the native benchmark main exits. + pause(3000); + if (escape == null || escape.length != 8) { + throw new RuntimeException("escape sink lost"); + } + // Host-JVM oracle for this deterministic workload. Every collector variant + // must reach the identical value; a mismatch exits nonzero before timing is + // considered. + if (checksum != -18515648L) { + throw new RuntimeException("checksum mismatch " + checksum); + } + System.out.println("BIBOP_ADAPTIVE_OK checksum=" + checksum); + } +} From 8fb68ab3863a607f946b81b76b3e492c40a2f332 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:14:34 +0300 Subject: [PATCH 2/7] Add benchmark copyright header --- .../src/com/bench/BiBopAdaptive.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/vm/benchmarks/src/com/bench/BiBopAdaptive.java b/vm/benchmarks/src/com/bench/BiBopAdaptive.java index e87c8818304..7f202b6518b 100644 --- a/vm/benchmarks/src/com/bench/BiBopAdaptive.java +++ b/vm/benchmarks/src/com/bench/BiBopAdaptive.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.bench; /** From c5345e625c4a7f805629238c843bdcbb7c84573c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:01:12 +0300 Subject: [PATCH 3/7] Fix adaptive GC epoch races --- vm/ByteCodeTranslator/src/cn1_globals.h | 19 +++++++----- vm/ByteCodeTranslator/src/cn1_globals.m | 35 +++++++++++++---------- vm/ByteCodeTranslator/src/nativeMethods.m | 7 ++++- 3 files changed, 38 insertions(+), 23 deletions(-) diff --git a/vm/ByteCodeTranslator/src/cn1_globals.h b/vm/ByteCodeTranslator/src/cn1_globals.h index 9437b6192f0..2a1474fcd7e 100644 --- a/vm/ByteCodeTranslator/src/cn1_globals.h +++ b/vm/ByteCodeTranslator/src/cn1_globals.h @@ -1343,6 +1343,9 @@ typedef struct CN1BibopPage { extern __thread CN1BibopPage* bibopCurrent[CN1_BIBOP_NUM_CLASSES]; extern _Atomic long bibopBytesSinceGc; extern _Atomic long bibopGcTriggerBytes; +// Atomic mirror of currentGcMarkValue for mutator-side adaptive/fresh-page +// decisions. currentGcMarkValue itself remains owned by the GC/mark threads. +extern _Atomic int bibopGcEpoch; extern _Atomic int bibopBypassGeneration[CN1_BIBOP_NUM_CLASSES]; extern CN1BibopPage* _Atomic bibopFreshPages[2]; extern _Atomic long cn1BibopHighThroughputPromotions; @@ -1352,7 +1355,7 @@ extern _Atomic long cn1BibopFreshPagesScanned; extern _Atomic long cn1BibopBeltRuns; extern _Atomic long cn1BibopAdoptedRescanSkips; extern int currentGcMarkValue; -extern void cn1BibopNoteFreshAllocation(CN1BibopPage* page); +extern void cn1BibopNoteFreshAllocation(CN1BibopPage* page, int epoch); #ifndef CN1_BIBOP_NO_FASTSWEEP // Called from monitorEnter (any thread) when a monitor (CN1ThreadData) is freshly // attached to a heap object. If the object is a BiBOP slot it bumps a global live-monitor @@ -1463,9 +1466,10 @@ static inline JAVA_OBJECT cn1BibopFastAlloc(CODENAME_ONE_THREAD_STATE, int size, #endif __atomic_store_n(&o->__codenameOneGcMark, -1, __ATOMIC_RELEASE); atomic_store_explicit(&p->bumpIndex, bi + 1, memory_order_release); - if(__builtin_expect(atomic_load_explicit(&p->gcFreshEpoch[currentGcMarkValue & 1], memory_order_relaxed) - != currentGcMarkValue, 0)) { - cn1BibopNoteFreshAllocation(p); + int __cn1FreshEpoch = atomic_load_explicit(&bibopGcEpoch, memory_order_relaxed); + if(__builtin_expect(atomic_load_explicit(&p->gcFreshEpoch[__cn1FreshEpoch & 1], memory_order_relaxed) + != __cn1FreshEpoch, 0)) { + cn1BibopNoteFreshAllocation(p, __cn1FreshEpoch); } #ifndef CN1_BIBOP_NO_FASTSWEEP // Mark the page dirty so the O(1) sweep never treats a page that still has @@ -1550,9 +1554,10 @@ static inline JAVA_OBJECT cn1BibopFastAllocNoZero(CODENAME_ONE_THREAD_STATE, int #endif __atomic_store_n(&o->__codenameOneGcMark, -1, __ATOMIC_RELEASE); atomic_store_explicit(&p->bumpIndex, bi + 1, memory_order_release); - if(__builtin_expect(atomic_load_explicit(&p->gcFreshEpoch[currentGcMarkValue & 1], memory_order_relaxed) - != currentGcMarkValue, 0)) { - cn1BibopNoteFreshAllocation(p); + int __cn1FreshEpoch = atomic_load_explicit(&bibopGcEpoch, memory_order_relaxed); + if(__builtin_expect(atomic_load_explicit(&p->gcFreshEpoch[__cn1FreshEpoch & 1], memory_order_relaxed) + != __cn1FreshEpoch, 0)) { + cn1BibopNoteFreshAllocation(p, __cn1FreshEpoch); } #ifndef CN1_BIBOP_NO_FASTSWEEP p->gcAllocedSinceSweep = JAVA_TRUE; diff --git a/vm/ByteCodeTranslator/src/cn1_globals.m b/vm/ByteCodeTranslator/src/cn1_globals.m index 704d6780fed..d3aadb92c2b 100644 --- a/vm/ByteCodeTranslator/src/cn1_globals.m +++ b/vm/ByteCodeTranslator/src/cn1_globals.m @@ -940,7 +940,7 @@ static void cn1DrainDeadThreadPending() { // Forward (tentative) declaration -- the real definition is below near the worklist; // the belt pass in codenameOneGCMark forces it to trigger the full BiBOP rescan. static JAVA_BOOLEAN gcMarkWorklistOverflow; -static JAVA_BOOLEAN gcMarkOverflowSeen = JAVA_FALSE; +static _Atomic JAVA_BOOLEAN gcMarkOverflowSeen = JAVA_FALSE; #ifndef CN1_DISABLE_BIBOP // Forward declarations -- defined below; the grace-subtree pass in codenameOneGCMark // walks the page registry and its slots before their definitions. @@ -1002,7 +1002,7 @@ static long cn1SatbTake(JAVA_OBJECT** out) { void codenameOneGCMark() { currentGcMarkValue++; - gcMarkOverflowSeen = JAVA_FALSE; + atomic_store_explicit(&gcMarkOverflowSeen, JAVA_FALSE, memory_order_relaxed); #ifndef CN1_DISABLE_BIBOP cn1BibopBeginGcCycle(); #endif @@ -1309,7 +1309,7 @@ void codenameOneGCMark() { } #endif - if(gcMarkOverflowSeen) { + if(atomic_load_explicit(&gcMarkOverflowSeen, memory_order_acquire)) { long __beltBefore = gcMarkNewObjectCount; #ifdef CN1_BIBOP_VALIDATE gcBeltDiagActive = 1; @@ -1894,6 +1894,7 @@ JAVA_INT java_lang_System_identityHashCode___java_lang_Object_R_int(CODENAME_ONE // Non-static: also read/written by the inlined bump fast path (cn1_globals.h). _Atomic long bibopBytesSinceGc = 0; _Atomic long bibopGcTriggerBytes = CN1_BIBOP_GC_TRIGGER_BYTES; +_Atomic int bibopGcEpoch = 1; _Atomic int bibopBypassGeneration[CN1_BIBOP_NUM_CLASSES]; static long bibopCycleAllocatedBytes = 0; static long bibopLastCycleOccupiedBytes = 0; @@ -1972,8 +1973,7 @@ static void cn1BibopFormatPage(CN1BibopPage* p, int ci) { // Queue a page only on its first allocation in a GC epoch. The grace pass can // now walk pages that actually received fresh objects instead of rescanning the // grow-only registry on every collection. -void cn1BibopNoteFreshAllocation(CN1BibopPage* page) { - int epoch = currentGcMarkValue; +void cn1BibopNoteFreshAllocation(CN1BibopPage* page, int epoch) { int lane = epoch & 1; int seen = atomic_load_explicit(&page->gcFreshEpoch[lane], memory_order_relaxed); while(seen != epoch) { @@ -1992,6 +1992,9 @@ void cn1BibopNoteFreshAllocation(CN1BibopPage* page) { } void cn1BibopBeginGcCycle(void) { + // Publish the new GC-owned epoch separately for mutators. They must never + // read currentGcMarkValue while the collector increments it concurrently. + atomic_store_explicit(&bibopGcEpoch, currentGcMarkValue, memory_order_relaxed); // Charge allocations racing this mark to the NEXT cycle. The old sweep-end // store lost those bytes and could delay a collection indefinitely under a // sustained allocator. @@ -2106,7 +2109,7 @@ void cn1RefreshFreeMemCache(void) { // unless memory is genuinely tight. Never returns LESS than the old static cap, so no workload // gets a tighter bound than before. -DCN1_BIBOP_NO_PACING still disables pacing entirely for A/B. static void cn1BibopUpdateThreadPolicy(CODENAME_ONE_THREAD_STATE) { - int epoch = currentGcMarkValue; + int epoch = atomic_load_explicit(&bibopGcEpoch, memory_order_relaxed); if(threadStateData->bibopObservedGcEpoch != epoch) { threadStateData->bibopObservedGcEpoch = epoch; threadStateData->bibopEpochBytes = 0; @@ -2144,7 +2147,7 @@ static long cn1BibopPacingCap(CODENAME_ONE_THREAD_STATE) { // bounded by real available memory (get_free_memory now reports reclaimable pages), so RSS // stays safe and the collector reclaims the transient churn. if(isEdt(threadStateData->threadId) - || threadStateData->bibopHighThroughputUntilEpoch >= currentGcMarkValue + || threadStateData->bibopHighThroughputUntilEpoch >= threadStateData->bibopObservedGcEpoch || threadStateData->heapAllocationSize > CN1_BIBOP_HIGH_THROUGHPUT_ALLOCS) { long hi = fm / 2; if(hi > cap) cap = hi; @@ -2483,9 +2486,10 @@ static JAVA_OBJECT cn1BibopAlloc(CODENAME_ONE_THREAD_STATE, int size, struct cla // publish the new cursor with release AFTER the slot (incl. its // mark) is fully initialized. atomic_store_explicit(&p->bumpIndex, bi + 1, memory_order_release); - if(atomic_load_explicit(&p->gcFreshEpoch[currentGcMarkValue & 1], memory_order_relaxed) - != currentGcMarkValue) { - cn1BibopNoteFreshAllocation(p); + int epoch = atomic_load_explicit(&bibopGcEpoch, memory_order_relaxed); + if(atomic_load_explicit(&p->gcFreshEpoch[epoch & 1], memory_order_relaxed) + != epoch) { + cn1BibopNoteFreshAllocation(p, epoch); } #ifndef CN1_BIBOP_NO_FASTSWEEP p->gcAllocedSinceSweep = JAVA_TRUE; @@ -2504,9 +2508,10 @@ static JAVA_OBJECT cn1BibopAlloc(CODENAME_ONE_THREAD_STATE, int size, struct cla } // free-list slot path cn1BibopInitSlot(threadStateData, o, size, parent); - if(atomic_load_explicit(&p->gcFreshEpoch[currentGcMarkValue & 1], memory_order_relaxed) - != currentGcMarkValue) { - cn1BibopNoteFreshAllocation(p); + int epoch = atomic_load_explicit(&bibopGcEpoch, memory_order_relaxed); + if(atomic_load_explicit(&p->gcFreshEpoch[epoch & 1], memory_order_relaxed) + != epoch) { + cn1BibopNoteFreshAllocation(p, epoch); } #ifndef CN1_BIBOP_NO_FASTSWEEP p->gcAllocedSinceSweep = JAVA_TRUE; @@ -4014,7 +4019,7 @@ static void gcMarkFlushLocal(struct gcMarkLocalBuffer* lb) { for(int i = 0 ; i < lb->count ; i++) { if(gcMarkWorklistTop >= CN1_GC_MARK_WORKLIST_SIZE) { gcMarkWorklistOverflow = JAVA_TRUE; - gcMarkOverflowSeen = JAVA_TRUE; + atomic_store_explicit(&gcMarkOverflowSeen, JAVA_TRUE, memory_order_release); break; } gcMarkWorklist[gcMarkWorklistTop] = lb->entries[i]; @@ -4043,7 +4048,7 @@ static inline void gcMarkWorklistPush(JAVA_OBJECT obj, JAVA_BOOLEAN force) { // Serial path: identical to the original single-threaded push. if(gcMarkWorklistTop >= CN1_GC_MARK_WORKLIST_SIZE) { gcMarkWorklistOverflow = JAVA_TRUE; - gcMarkOverflowSeen = JAVA_TRUE; + atomic_store_explicit(&gcMarkOverflowSeen, JAVA_TRUE, memory_order_release); return; } gcMarkWorklist[gcMarkWorklistTop].obj = obj; diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index ac127a8fccd..df7bc23fba9 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -1644,7 +1644,12 @@ JAVA_INT java_lang_Object_hashCode___R_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJEC // runs -- garbage-nonzero silently disables the fast path for the thread. i->bibopBytesLocal = 0; i->bibopEpochBytes = 0; - i->bibopObservedGcEpoch = currentGcMarkValue; +#ifndef CN1_DISABLE_BIBOP + i->bibopObservedGcEpoch = atomic_load_explicit(&bibopGcEpoch, + memory_order_relaxed); +#else + i->bibopObservedGcEpoch = 0; +#endif i->bibopHighThroughputUntilEpoch = 0; for(int __bi = 0 ; __bi < CN1_BIBOP_NUM_CLASSES ; __bi++) { #ifndef CN1_DISABLE_BIBOP From 009cd172f047cbb03429af33cdc555dd33657159 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:13:49 +0300 Subject: [PATCH 4/7] Remove production GC diagnostics overhead --- vm/ByteCodeTranslator/src/cn1_globals.h | 4 ++++ vm/ByteCodeTranslator/src/cn1_globals.m | 15 +++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/vm/ByteCodeTranslator/src/cn1_globals.h b/vm/ByteCodeTranslator/src/cn1_globals.h index 2a1474fcd7e..5bce7e03c6b 100644 --- a/vm/ByteCodeTranslator/src/cn1_globals.h +++ b/vm/ByteCodeTranslator/src/cn1_globals.h @@ -1348,12 +1348,16 @@ extern _Atomic long bibopGcTriggerBytes; extern _Atomic int bibopGcEpoch; extern _Atomic int bibopBypassGeneration[CN1_BIBOP_NUM_CLASSES]; extern CN1BibopPage* _Atomic bibopFreshPages[2]; +#if defined(CN1_GC_INSTRUMENT) && !defined(CN1_DISABLE_BIBOP) +// QA-only diagnostics. Production builds contain neither the counters nor +// their atomic updates. extern _Atomic long cn1BibopHighThroughputPromotions; extern _Atomic long cn1BibopBypassActivations; extern _Atomic long cn1BibopBypassAllocations; extern _Atomic long cn1BibopFreshPagesScanned; extern _Atomic long cn1BibopBeltRuns; extern _Atomic long cn1BibopAdoptedRescanSkips; +#endif extern int currentGcMarkValue; extern void cn1BibopNoteFreshAllocation(CN1BibopPage* page, int epoch); #ifndef CN1_BIBOP_NO_FASTSWEEP diff --git a/vm/ByteCodeTranslator/src/cn1_globals.m b/vm/ByteCodeTranslator/src/cn1_globals.m index d3aadb92c2b..888cbf05598 100644 --- a/vm/ByteCodeTranslator/src/cn1_globals.m +++ b/vm/ByteCodeTranslator/src/cn1_globals.m @@ -1291,8 +1291,10 @@ void codenameOneGCMark() { (CN1BibopPage*)0, memory_order_acquire); while(gp != 0) { +#ifdef CN1_GC_INSTRUMENT atomic_fetch_add_explicit(&cn1BibopFreshPagesScanned, 1, memory_order_relaxed); +#endif int gn = atomic_load_explicit(&gp->bumpIndex, memory_order_acquire); for(int gi = 0 ; gi < gn ; gi++) { JAVA_OBJECT go = cn1BibopSlot(gp, gi); @@ -1319,7 +1321,7 @@ void codenameOneGCMark() { // this phase, so a "mark nothing new" fixpoint can livelock against ongoing // allocation (observed hanging/breaking FusedTest). A single pass is bounded and // safe; residual incompleteness is handled by the drain-gap fix, not by looping. -#ifndef CN1_DISABLE_BIBOP +#if defined(CN1_GC_INSTRUMENT) && !defined(CN1_DISABLE_BIBOP) atomic_fetch_add_explicit(&cn1BibopBeltRuns, 1, memory_order_relaxed); #endif gcMarkWorklistOverflow = JAVA_TRUE; // force the BiBOP page-rescan path on @@ -1902,13 +1904,16 @@ JAVA_INT java_lang_System_identityHashCode___java_lang_Object_R_int(CODENAME_ONE static long bibopLastCycleReclaimedBytes = 0; static int bibopHighSurvivalStreak[CN1_BIBOP_NUM_CLASSES]; -// QA instrumentation only. The adaptive policy itself is always enabled. +// QA instrumentation only. The adaptive policy itself is always enabled; +// production builds contain neither these counters nor their atomic RMWs. +#ifdef CN1_GC_INSTRUMENT _Atomic long cn1BibopHighThroughputPromotions = 0; _Atomic long cn1BibopBypassActivations = 0; _Atomic long cn1BibopBypassAllocations = 0; _Atomic long cn1BibopFreshPagesScanned = 0; _Atomic long cn1BibopBeltRuns = 0; _Atomic long cn1BibopAdoptedRescanSkips = 0; +#endif static int bibopTriggerHighSurvivalStreak = 0; // (The old global BiBOP-monitor count that suppressed the O(1) all-dead reclaim @@ -2117,8 +2122,10 @@ static void cn1BibopUpdateThreadPolicy(CODENAME_ONE_THREAD_STATE) { if(threadStateData->bibopEpochBytes >= CN1_BIBOP_HIGH_THROUGHPUT_BYTES && threadStateData->bibopHighThroughputUntilEpoch < epoch + 2) { threadStateData->bibopHighThroughputUntilEpoch = epoch + 2; +#ifdef CN1_GC_INSTRUMENT atomic_fetch_add_explicit(&cn1BibopHighThroughputPromotions, 1, memory_order_relaxed); +#endif } // Survivor-heavy size classes publish a new generation at sweep. Threads // consume that signal only on their rare page-acquire path, then route a @@ -2701,8 +2708,10 @@ static void cn1BibopAdaptAfterSweep(long occupiedBytes, long liveBytes, if(++bibopHighSurvivalStreak[ci] >= 2) { atomic_fetch_add_explicit(&bibopBypassGeneration[ci], 1, memory_order_relaxed); +#ifdef CN1_GC_INSTRUMENT atomic_fetch_add_explicit(&cn1BibopBypassActivations, 1, memory_order_relaxed); +#endif bibopHighSurvivalStreak[ci] = 0; } } else if(survival <= 20) { @@ -4656,8 +4665,10 @@ static JAVA_BOOLEAN cn1BibopRescanStep() { if(m == currentGcMarkValue && o->__heapPosition == CN1_BIBOP_ADOPTED) { // Overflow setup drains the adoption buffer into allObjectsInHeap; // the legacy half of this same rescan owns this object now. +#ifdef CN1_GC_INSTRUMENT atomic_fetch_add_explicit(&cn1BibopAdoptedRescanSkips, 1, memory_order_relaxed); +#endif continue; } if(m == currentGcMarkValue && o->__codenameOneParentClsReference->markFunction != 0) { From 1b12f10d6ab2e58b7fce9ed411f9b783b286d965 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:11:52 +0300 Subject: [PATCH 5/7] Wait for BrowserComponent compositor before capture --- .../tests/BrowserComponentScreenshotTest.java | 85 ++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BrowserComponentScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BrowserComponentScreenshotTest.java index 5ed099d418d..cff48137a12 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BrowserComponentScreenshotTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BrowserComponentScreenshotTest.java @@ -26,11 +26,15 @@ import com.codename1.ui.CN; import com.codename1.ui.Display; import com.codename1.ui.Form; +import com.codename1.ui.Image; import com.codename1.ui.layouts.BorderLayout; import com.codename1.ui.util.UITimer; import com.codename1.util.SuccessCallback; public class BrowserComponentScreenshotTest extends BaseTest { + private static final int VISUAL_RETRY_MS = 250; + private static final int VISUAL_TIMEOUT_MS = 12000; + private BrowserComponent browser; private boolean loaded; private Runnable readyRunnable; @@ -100,10 +104,89 @@ public void onSucess(BrowserComponent.JSRef result) { return; } - UITimer.timer(2000, false, form, readyRunnable); + if (isHtml5()) { + UITimer.timer(2000, false, form, readyRunnable); + } else { + // DOM readiness and even WebKit's first meaningful paint do not + // guarantee that the native peer has reached the Metal surface. + // Verify the exact screen image that will be emitted instead of + // relying on a fixed delay and then taking an unrelated capture. + awaitRenderedBrowserFrame(0); + } readyRunnable = null; } + private void awaitRenderedBrowserFrame(final int waitedMs) { + browser.repaint(); + form.repaint(); + markCaptureStarted(); + Display.getInstance().screenshot(screen -> { + if (screen == null) { + fail("BrowserComponent screen capture returned null"); + return; + } + if (containsRenderedBrowserContent(screen)) { + Cn1ssDeviceRunnerHelper.emitImage(screen, "BrowserComponent", this::done); + return; + } + screen.dispose(); + if (waitedMs >= VISUAL_TIMEOUT_MS) { + fail("BrowserComponent DOM loaded, but its native peer was not composited into the screen capture"); + return; + } + UITimer.timer(VISUAL_RETRY_MS, false, form, + () -> awaitRenderedBrowserFrame(waitedMs + VISUAL_RETRY_MS)); + }); + } + + private boolean containsRenderedBrowserContent(Image screen) { + int screenWidth = screen.getWidth(); + int screenHeight = screen.getHeight(); + int displayWidth = Display.getInstance().getDisplayWidth(); + int displayHeight = Display.getInstance().getDisplayHeight(); + if (screenWidth <= 0 || screenHeight <= 0 || displayWidth <= 0 || displayHeight <= 0 + || browser.getWidth() <= 0 || browser.getHeight() <= 0) { + return false; + } + + double scaleX = screenWidth / (double) displayWidth; + double scaleY = screenHeight / (double) displayHeight; + int insetX = Math.max(1, (int) Math.round(8 * scaleX)); + int insetY = Math.max(1, (int) Math.round(8 * scaleY)); + int left = Math.max(0, (int) Math.round(browser.getAbsoluteX() * scaleX) + insetX); + int right = Math.min(screenWidth, + (int) Math.round((browser.getAbsoluteX() + browser.getWidth()) * scaleX) - insetX); + int top = Math.max(0, (int) Math.round(browser.getAbsoluteY() * scaleY) + insetY); + int contentBandHeight = Math.min(browser.getHeight() - 16, 180); + int bottom = Math.min(screenHeight, top + Math.max(1, + (int) Math.round(contentBandHeight * scaleY))); + if (left >= right || top >= bottom) { + return false; + } + + int[] rgb = screen.getRGB(); + int requiredBrightPixels = Math.max(32, (right - left) / 20); + int brightPixels = 0; + for (int y = top; y < bottom; y++) { + int rowOffset = y * screenWidth; + for (int x = left; x < right; x++) { + int color = rgb[rowOffset + x]; + int r = (color >> 16) & 0xff; + int g = (color >> 8) & 0xff; + int b = color & 0xff; + // The local fixture contains white and cyan text on a dark + // background. The black uncomposited peer contains neither. + if ((r > 160 && g > 160 && b > 160) + || (g > 120 && b > 160 && b > r + 30)) { + if (++brightPixels >= requiredBrightPixels) { + return true; + } + } + } + } + return false; + } + private static String buildHtml() { return "" + "