Skip to content

Commit 143f2b7

Browse files
fix(memory): platform-aware override floor so mid-size models load on high-RAM Android
The user's original bug: a ~3.7GB model refused on a 12GB phone. Root cause: the override survival floor (1200MB) is calibrated for iOS jetsam, but a 12GB Android phone exposes only ~4.5GB physical availMem, so 4500-3700=800 < 1200 → refused. Fix (the SAFE version, not the reverted swap-credit): a platform-aware floor. Android backs the OS and other apps with zram/LMK (foreground app killed last), so the override reserves a smaller physical margin (KV growth, 700MB) instead of iOS's full jetsam buffer. Crucially this stays PHYSICAL-based — the dirty model's full footprint is still subtracted from real availMem, so an oversized dirty model (e.g. 5.2GB LiteRT on ~4.5GB physical → negative) is still REFUSED. That's the OOM guard my earlier swap-credit fix lacked (it credited swap toward a dirty model that can't be swapped → loaded → OOM; reverted). Result: ~3.7GB models load on high-RAM Android (800>700); 5.2GB still refused; iOS unchanged (800<1200 jetsam floor holds). Tests cover all three. NEEDS on-device verification — [MEM-SM] logs the real numbers; the 700MB margin may need device tuning. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ed0cca1 commit 143f2b7

3 files changed

Lines changed: 79 additions & 3 deletions

File tree

__tests__/unit/services/modelResidency.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,56 @@ describe('ModelResidencyManager', () => {
783783
expect(unloadSmall).toHaveBeenCalledTimes(1); // we tried (freed everything first)
784784
expect(fits).toBe(false); // but real physics still refuses
785785
});
786+
787+
// Platform-aware floor: Android backs the OS/other apps with zram, so the override reserves a
788+
// smaller physical margin (KV growth) than iOS's full jetsam floor. This is what lets a
789+
// ~3.7GB model load on a 12GB phone (physical availMem ~4.5GB) instead of the iOS-calibrated
790+
// 1200 floor refusing it — WITHOUT the swap-credit mistake (the dirty footprint is still
791+
// subtracted from real physical, so an oversized dirty model still goes negative → refused).
792+
describe('platform-aware override floor', () => {
793+
const RN = require('react-native');
794+
const originalOS = RN.Platform.OS;
795+
afterEach(() => { RN.Platform.OS = originalOS; });
796+
797+
it('ANDROID: a ~3.7GB dirty model that leaves ~800MB physical LOADS (was refused by the iOS 1200 floor)', async () => {
798+
RN.Platform.OS = 'android';
799+
modelResidencyManager.setBudgetOverrideMB(null);
800+
jest.spyOn(hardwareService, 'refreshMemoryInfo').mockResolvedValue(undefined as never);
801+
jest.spyOn(hardwareService, 'getTotalMemoryGB').mockReturnValue(12);
802+
jest.spyOn(hardwareService, 'getAvailableMemoryGB').mockReturnValue(4.5); // ~4500MB physical
803+
const { fits } = await modelResidencyManager.makeRoomFor(
804+
{ key: 'text', type: 'text', sizeMB: 3700, dirtyMemory: true }, // 4500-3700=800 > 700 android floor
805+
{ override: true },
806+
);
807+
expect(fits).toBe(true); // loads on Android (800 > 700) — the user's 12GB-phone case
808+
});
809+
810+
it('ANDROID: an oversized dirty model that exceeds PHYSICAL is still refused (the OOM guard, no swap credit)', async () => {
811+
RN.Platform.OS = 'android';
812+
modelResidencyManager.setBudgetOverrideMB(null);
813+
jest.spyOn(hardwareService, 'refreshMemoryInfo').mockResolvedValue(undefined as never);
814+
jest.spyOn(hardwareService, 'getTotalMemoryGB').mockReturnValue(12);
815+
jest.spyOn(hardwareService, 'getAvailableMemoryGB').mockReturnValue(4.5);
816+
const { fits } = await modelResidencyManager.makeRoomFor(
817+
{ key: 'text', type: 'text', sizeMB: 5235, dirtyMemory: true }, // 4500-5235 = negative → refuse (the E4B OOM case)
818+
{ override: true },
819+
);
820+
expect(fits).toBe(false); // refused — a 5.2GB dirty model can't fit ~4.5GB physical (would OOM)
821+
});
822+
823+
it('iOS: the same ~3.7GB dirty model is REFUSED (full jetsam floor stands — no user swap)', async () => {
824+
RN.Platform.OS = 'ios';
825+
modelResidencyManager.setBudgetOverrideMB(null);
826+
jest.spyOn(hardwareService, 'refreshMemoryInfo').mockResolvedValue(undefined as never);
827+
jest.spyOn(hardwareService, 'getTotalMemoryGB').mockReturnValue(12);
828+
jest.spyOn(hardwareService, 'getAvailableMemoryGB').mockReturnValue(4.5);
829+
const { fits } = await modelResidencyManager.makeRoomFor(
830+
{ key: 'text', type: 'text', sizeMB: 3700, dirtyMemory: true }, // 4500-3700=800 < 1200 iOS floor
831+
{ override: true },
832+
);
833+
expect(fits).toBe(false); // iOS unchanged — 800 < 1200 jetsam floor
834+
});
835+
});
786836
});
787837

788838
describe('session override memory (approve Load Anyway once per model)', () => {

src/services/memoryBudget.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,27 @@ export const AGGRESSIVE_RESERVE_MB = 800;
5757
* (e.g. many background apps have eaten the baseline).
5858
*/
5959
export const OVERRIDE_SURVIVAL_FLOOR_MB = 1200;
60+
/**
61+
* Android's override floor is LOWER than iOS's because the physics differ:
62+
* - iOS: no user swap; exceeding os_proc_available_memory is an uncatchable jetsam SIGKILL, so
63+
* we hold the full 1200MB physical reserve.
64+
* - Android: zram/swap + the low-memory killer back the OS and OTHER apps (their pages compress
65+
* to zram / background is killed) — the foreground app is killed LAST. The model's own dirty
66+
* (GPU) pages still need PHYSICAL RAM (this check subtracts the full dirty footprint from real
67+
* physical availMem, so an oversized dirty model — e.g. a 5.2GB LiteRT on ~4.5GB physical —
68+
* still goes negative and is refused), but the SYSTEM survives on swap, so we only reserve a
69+
* physical margin for the model's KV-cache growth, not a full jetsam buffer. This is what lets
70+
* a ~3.7GB model load on a 12GB phone (physical availMem ~4.5GB) instead of being refused by
71+
* the iOS-calibrated 1200 floor, WITHOUT the swap-credit mistake that let an oversized dirty
72+
* model load and OOM. NEEDS on-device tuning/verification ([MEM-SM] logs the real numbers).
73+
*/
74+
export const ANDROID_OVERRIDE_SURVIVAL_FLOOR_MB = 700;
75+
/** The override survival floor for the current platform (data-driven, not a scattered branch). */
76+
export function overrideSurvivalFloorMB(platform: Plat = Platform.OS): number {
77+
return platform === 'android'
78+
? ANDROID_OVERRIDE_SURVIVAL_FLOOR_MB
79+
: OVERRIDE_SURVIVAL_FLOOR_MB;
80+
}
6081

6182
type Plat = 'ios' | 'android' | string;
6283

src/services/modelResidency/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
Resident,
1919
ResidentType,
2020
} from './policy';
21-
import { LoadPolicy, OVERRIDE_SURVIVAL_FLOOR_MB } from '../memoryBudget';
21+
import { LoadPolicy, overrideSurvivalFloorMB } from '../memoryBudget';
2222

2323
type UnloadFn = () => Promise<void>;
2424

@@ -395,9 +395,14 @@ class ModelResidencyManager {
395395
);
396396
const incomingDirtyMB = spec.dirtyMemory ? spec.sizeMB : 0;
397397
const postLoadFreeMB = realAvailMB - incomingDirtyMB;
398-
if (postLoadFreeMB < OVERRIDE_SURVIVAL_FLOOR_MB) {
398+
// Physical-based (NO swap credit): the dirty model's own footprint is subtracted from real
399+
// physical availMem, so an oversized dirty model still goes negative and is refused (the
400+
// OOM guard). The FLOOR is platform-aware: Android backs the OS/other apps with zram so it
401+
// reserves only a KV-growth margin; iOS holds the full jetsam reserve.
402+
const floorMB = overrideSurvivalFloorMB();
403+
if (postLoadFreeMB < floorMB) {
399404
logger.log(
400-
`[MEM-SM] makeRoomFor ${spec.key} REFUSED even under override - real post-evict free ~${postLoadFreeMB}MB < survival floor ${OVERRIDE_SURVIVAL_FLOOR_MB}MB`,
405+
`[MEM-SM] makeRoomFor ${spec.key} REFUSED even under override - real post-evict free ~${postLoadFreeMB}MB < survival floor ${floorMB}MB`,
401406
);
402407
return { evicted: plan.evict.map(e => e.key), fits: false };
403408
}

0 commit comments

Comments
 (0)