From f0608ba9b3ab39b68c0d7571680bd8ffb29520da Mon Sep 17 00:00:00 2001 From: Maksim Smatrou Date: Thu, 16 Jul 2026 17:42:12 +0200 Subject: [PATCH 1/2] feat(ios): add getExtendedMemoryUsage() exposing resident size and region count --- README.md | 24 ++++++- ios/HybridPerformanceToolkit.swift | 109 +++++++++++++++++++++++------ src/hooks/jsThreadHooks.ts | 35 +++++++++ 3 files changed, 146 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 89f1bea..44e8091 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ import { getUiFps, getCpuUsage, getMemoryUsage, + getExtendedMemoryUsage, getDeviceMaxRefreshRate, getDeviceCurrentRefreshRate, } from 'react-native-performance-toolkit' @@ -43,6 +44,7 @@ console.log('JS FPS:', getJsFps()) console.log('UI FPS:', getUiFps()) console.log('CPU Usage:', getCpuUsage()) console.log('Memory Usage:', getMemoryUsage()) +console.log('Extended Memory Usage:', getExtendedMemoryUsage()) console.log('Max Refresh Rate:', getDeviceMaxRefreshRate(), 'Hz') console.log('Current Refresh Rate:', getDeviceCurrentRefreshRate(), 'Hz') ``` @@ -169,6 +171,23 @@ console.log('CPU Usage:', getValueFromBuffer(cpuUsageBuffer)) console.log('Memory Usage:', getValueFromBuffer(memoryUsageBuffer)) ``` +On iOS, `getMemoryUsageBuffer()` returns a 24-byte buffer so advanced consumers can also read resident size and VM region count without an extra native call: + +| Offset | Type | Field | Unit | +| ------ | --------- | ---------------- | ----- | +| 0 | `Int32` | `phys_footprint` | MB | +| 8 | `Float64` | `resident_size` | KB | +| 16 | `Float64` | `region_count` | count | + +Offset 0 stays compatible with `getMemoryUsage()`. Prefer `getExtendedMemoryUsage()` unless you need raw buffer access (e.g. from a worklet). Android currently keeps the 4-byte PSS buffer; extended fields read as `0`. + +```tsx +import { getExtendedMemoryUsage } from 'react-native-performance-toolkit' + +const { memoryUsageMb, residentSizeKb, regionCount } = + getExtendedMemoryUsage() +``` + ### Access from worklets (advanced usage) > **Note:** This requires `react-native-reanimated` and `react-native-worklets` to be installed. @@ -210,6 +229,7 @@ const updateFps = useCallback(() => { - `getUiFps(): number` - Returns current UI FPS (0-30/60/90/120/...) - `getCpuUsage(): number` - Returns CPU usage percentage in Linux format. Returns `0` on the first call (CPU usage is a rate and requires a previous sample to compute a delta); subsequent calls return real values. - `getMemoryUsage(): number` - Returns memory usage in megabytes (MB). + - `getExtendedMemoryUsage(): { memoryUsageMb: number, residentSizeKb: number, regionCount: number }` - Returns extended memory metrics. `memoryUsageMb` is `phys_footprint` on iOS and PSS on Android. On iOS, the additional fields contain `task_vm_info` resident size (KB) and region count; on Android they are `0`. - `getDeviceMaxRefreshRate(): number` - Returns device's maximum supported refresh rate (e.g., 120 Hz on ProMotion devices) - `getDeviceCurrentRefreshRate(): number` - Returns device's current active refresh rate (may be lower than max on adaptive refresh rate displays) @@ -229,7 +249,7 @@ const updateFps = useCallback(() => { - `getJsFpsBuffer(): ArrayBuffer` - Returns ArrayBuffer with JS FPS data - `getUiFpsBuffer(): ArrayBuffer` - Returns ArrayBuffer with UI FPS data - `getCpuUsageBuffer(): ArrayBuffer` - Returns ArrayBuffer with CPU usage data - - `getMemoryUsageBuffer(): ArrayBuffer` - Returns ArrayBuffer with memory usage data + - `getMemoryUsageBuffer(): ArrayBuffer` - Returns ArrayBuffer with memory usage data. On iOS this is 24 bytes (`phys_footprint` MB + `resident_size` KB + `region_count`); on Android it remains 4 bytes (PSS MB). - **Advanced (Nitro Modules)** - `BoxedJsFpsTracking` - Direct boxed Nitro module instance for worklet usage @@ -266,7 +286,7 @@ Import from `react-native-performance-toolkit/reanimated`: On Android, the library is reading values from virtual files like `/proc/stat` for CPU usage and `/proc/smaps_rollup` for memory usage. This is very low overhead and doesn't require any additional permissions. -On iOS, the library is reading values from `task_vm_info`/`rusage` direct kernel call. This is also extremely low overhead. +On iOS, the library is reading values from `task_vm_info`/`rusage` direct kernel call. This is also extremely low overhead. The memory buffer exposes `phys_footprint` (primary), plus `resident_size` and `region_count` for deeper diagnostics. ### Device Refresh Rate diff --git a/ios/HybridPerformanceToolkit.swift b/ios/HybridPerformanceToolkit.swift index 77e3e86..18095f6 100755 --- a/ios/HybridPerformanceToolkit.swift +++ b/ios/HybridPerformanceToolkit.swift @@ -46,8 +46,21 @@ class HybridPerformanceToolkit: HybridPerformanceToolkitSpec { private static let CPU_COLLECTION_INTERVAL: CFTimeInterval = 0.5 // Memory tracking + // Shared backing buffer for getMemoryUsage() and getExtendedMemoryUsage(). + // The basic API reads only offset 0; collecting extended fields adds no + // additional task_info call. + // Buffer layout (little-endian): + // offset 0 : Int32 phys_footprint (MB) -- primary, jetsam-relevant + // offset 8 : Float64 resident_size (KB) -- resident secondary + // offset 16 : Float64 region_count (count) -- address-space diagnostic + // virtual_size is intentionally not collected (reserved address space is + // not a useful memory-usage signal). + private static let MEMORY_BUFFER_SIZE = 24 + private static let MEMORY_PHYS_FOOTPRINT_MB_OFFSET = 0 + private static let MEMORY_RESIDENT_SIZE_KB_OFFSET = 8 + private static let MEMORY_REGION_COUNT_OFFSET = 16 private var memoryTimer: Timer? - private var memoryBuffer: ArrayBuffer? + private var memoryMetricsBuffer: ArrayBuffer? private var isMemoryTrackingStarting = false private lazy var maxDeviceFps: Double = { @@ -229,16 +242,16 @@ class HybridPerformanceToolkit: HybridPerformanceToolkitSpec { return cpuPercentage } - // MARK: - Memory Usage Buffer + // MARK: - Memory Metrics Buffer func getMemoryUsageBuffer() throws -> ArrayBuffer { - if memoryBuffer == nil { - memoryBuffer = ArrayBuffer.allocate(size: MemoryLayout.size) - // Populate synchronously so the very first read returns a real value rather than 0. + if memoryMetricsBuffer == nil { + memoryMetricsBuffer = ArrayBuffer.allocate(size: Self.MEMORY_BUFFER_SIZE) + memset(memoryMetricsBuffer!.data, 0, Self.MEMORY_BUFFER_SIZE) + // Populate synchronously so the very first read returns real values rather than 0. // Memory is an instantaneous measurement (no delta required), so this is safe to do // on the calling thread; it matches the work the periodic updater does every 500ms. - let initialRam = Int32(collectUsedRam()) - memoryBuffer!.data.withMemoryRebound(to: Int32.self, capacity: 1) { $0.pointee = initialRam } + updateMemoryMetricsBuffer() } if memoryTimer == nil && !isMemoryTrackingStarting { @@ -246,7 +259,7 @@ class HybridPerformanceToolkit: HybridPerformanceToolkitSpec { startMemoryTracking() } - return memoryBuffer! + return memoryMetricsBuffer! } private func startMemoryTracking() { @@ -263,38 +276,94 @@ class HybridPerformanceToolkit: HybridPerformanceToolkitSpec { } self.memoryTimer = Timer.scheduledTimer(withTimeInterval: Self.MEMORY_UPDATE_INTERVAL, repeats: true) { [weak self] _ in - self?.updateMemoryBuffer() + self?.updateMemoryMetricsBuffer() } self.isMemoryTrackingStarting = false } } - private func updateMemoryBuffer() { - guard let buffer = memoryBuffer else { return } + private struct TaskVmInfoSample { + let physFootprintMb: Double? + let residentSizeKb: Double? + let regionCount: Double? + } + + private func updateMemoryMetricsBuffer() { + guard let buffer = memoryMetricsBuffer else { return } - let ramValue = collectUsedRam() + let sample = collectTaskVmInfo() - buffer.data.withMemoryRebound(to: Int32.self, capacity: 1) { $0.pointee = Int32(ramValue) } + buffer.data.advanced(by: Self.MEMORY_PHYS_FOOTPRINT_MB_OFFSET) + .withMemoryRebound(to: Int32.self, capacity: 1) { + $0.pointee = Int32(sample.physFootprintMb ?? 0.0) + } + writeDouble(buffer, offset: Self.MEMORY_RESIDENT_SIZE_KB_OFFSET, value: sample.residentSizeKb ?? 0.0) + writeDouble(buffer, offset: Self.MEMORY_REGION_COUNT_OFFSET, value: sample.regionCount ?? 0.0) + } + + private func writeDouble(_ buffer: ArrayBuffer, offset: Int, value: Double) { + var mutableValue = value + withUnsafeBytes(of: &mutableValue) { bytes in + memcpy(buffer.data.advanced(by: offset), bytes.baseAddress!, MemoryLayout.size) + } + } + + private func taskVmInfoCountCovers(_ field: KeyPath, count: mach_msg_type_number_t) -> Bool { + guard let offset = MemoryLayout.offset(of: field) else { + return false + } + let wordSize = MemoryLayout.size + let requiredCount = mach_msg_type_number_t((offset + MemoryLayout.size + wordSize - 1) / wordSize) + return count >= requiredCount } - private func collectUsedRam() -> Double { - // Use task_vm_info to get phys_footprint, which matches Xcode's memory gauge - // This excludes shared memory (frameworks, dylibs) and shows actual app footprint + private func collectTaskVmInfo() -> TaskVmInfoSample { + // TASK_VM_INFO gives phys_footprint plus resident size and region count. + // Swift does not import the TASK_VM_INFO_COUNT macro, so compute the same + // natural_t word count and verify the returned revision covers each late + // field before reading it. virtual_size is intentionally not read: it + // measures reserved-but-uncommitted address space and is useless as a + // memory-usage signal. var info = task_vm_info_data_t() - var count = mach_msg_type_number_t(MemoryLayout.size) / 4 + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) let result = withUnsafeMutablePointer(to: &info) { - $0.withMemoryRebound(to: integer_t.self, capacity: 1) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count) } } guard result == KERN_SUCCESS else { - return 0.0 + return TaskVmInfoSample(physFootprintMb: nil, residentSizeKb: nil, regionCount: nil) + } + + let physFootprintMb: Double? + if taskVmInfoCountCovers(\task_vm_info_data_t.phys_footprint, count: count) { + physFootprintMb = Double(info.phys_footprint) / 1_048_576.0 + } else { + physFootprintMb = nil + } + + let residentSizeKb: Double? + if taskVmInfoCountCovers(\task_vm_info_data_t.resident_size, count: count) { + residentSizeKb = Double(info.resident_size) / 1024.0 + } else { + residentSizeKb = nil + } + + let regionCount: Double? + if taskVmInfoCountCovers(\task_vm_info_data_t.region_count, count: count) { + regionCount = Double(info.region_count) + } else { + regionCount = nil } - return Double(info.phys_footprint) / 1_048_576.0 + return TaskVmInfoSample( + physFootprintMb: physFootprintMb, + residentSizeKb: residentSizeKb, + regionCount: regionCount + ) } func getDeviceMaxRefreshRate() throws -> Double { diff --git a/src/hooks/jsThreadHooks.ts b/src/hooks/jsThreadHooks.ts index 453d11c..8f7b6bd 100644 --- a/src/hooks/jsThreadHooks.ts +++ b/src/hooks/jsThreadHooks.ts @@ -13,6 +13,41 @@ const getValueFromBuffer = (buffer: ArrayBuffer) => { return view.getInt32(0, true) // true = littleEndian } +export type ExtendedMemoryUsage = { + /** Primary memory usage in MB: phys_footprint on iOS and PSS on Android. */ + memoryUsageMb: number + /** + * Resident set size in KB. Populated on iOS from `task_vm_info.resident_size`. + * Returns `0` on platforms that only expose the 4-byte primary memory buffer. + */ + residentSizeKb: number + /** + * VM region count. Populated on iOS from `task_vm_info.region_count`. + * Returns `0` on platforms that only expose the 4-byte primary memory buffer. + */ + regionCount: number +} + +/** + * Read the extended iOS memory buffer layout: + * offset 0 : int32 phys_footprint (MB) + * offset 8 : float64 resident_size (KB) + * offset 16 : float64 region_count (count) + * + * On Android (and older library versions) the buffer is only 4 bytes, so the extra + * fields are returned as `0`. `getMemoryUsage()` remains the primary API and + * continues to return the Int32 at offset 0. + */ +export const getExtendedMemoryUsage = (): ExtendedMemoryUsage => { + const buffer = getMemoryUsageBuffer() + const view = new DataView(buffer) + return { + memoryUsageMb: view.byteLength >= 4 ? view.getInt32(0, true) : 0, + residentSizeKb: view.byteLength >= 16 ? view.getFloat64(8, true) : 0, + regionCount: view.byteLength >= 24 ? view.getFloat64(16, true) : 0, + } +} + export const getJsFps = () => getValueFromBuffer(getJsFpsBuffer()) export const getUiFps = () => getValueFromBuffer(getUiFpsBuffer()) export const getCpuUsage = () => getValueFromBuffer(getCpuUsageBuffer()) From 8f595a6349e11967e158dde4e854600e9dae2232 Mon Sep 17 00:00:00 2001 From: Maksim Smatrou Date: Fri, 17 Jul 2026 00:40:47 +0200 Subject: [PATCH 2/2] feat: add extended memory usage hook and subscription --- README.md | 2 ++ example/App.tsx | 16 ++++++++++++++++ src/hooks/jsThreadHooks.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/README.md b/README.md index 44e8091..991a986 100644 --- a/README.md +++ b/README.md @@ -238,12 +238,14 @@ const updateFps = useCallback(() => { - `onFpsUiChange(callback: (fps: number) => void): () => void` - Subscribe to UI FPS changes - `onCpuChange(callback: (value: number) => void): () => void` - Subscribe to CPU usage changes - `onMemoryChange(callback: (value: number) => void): () => void` - Subscribe to memory usage changes + - `onExtendedMemoryChange(callback: (value: { memoryUsageMb: number, residentSizeKb: number, regionCount: number }) => void): () => void` - Subscribe to extended memory usage changes - **React Hooks (JS Thread)** - `useFpsJs(): number` - Hook that returns current JS FPS - `useFpsUi(): number` - Hook that returns current UI FPS - `useCpuUsage(): number` - Hook that returns current CPU usage - `useMemoryUsage(): number` - Hook that returns current memory usage + - `useExtendedMemoryUsage(): { memoryUsageMb: number, residentSizeKb: number, regionCount: number }` - Hook that returns current extended memory usage - **Buffer-based API** - `getJsFpsBuffer(): ArrayBuffer` - Returns ArrayBuffer with JS FPS data diff --git a/example/App.tsx b/example/App.tsx index e5fe4d7..33e6143 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -4,6 +4,7 @@ import { useFpsUi, useCpuUsage, useMemoryUsage, + useExtendedMemoryUsage, useFpsJs, getDeviceMaxRefreshRate, getDeviceCurrentRefreshRate, @@ -43,6 +44,20 @@ const MemoryUsageCounterJSThread = () => { return RAM (MB): {formatValue(memoryUsage)}; }; +const ExtendedMemoryCounterJSThread = () => { + const { memoryUsageMb, residentSizeKb, regionCount } = + useExtendedMemoryUsage(); + return ( + <> + Ext RAM (MB): {formatValue(memoryUsageMb)} + + Resident (KB): {formatValue(residentSizeKb)} + + Regions: {formatValue(regionCount)} + + ); +}; + function App(): React.JSX.Element { const blockJSThread = (blockTime: number = 500) => { console.log(`Blocking JS thread for ${blockTime} milliseconds...`); @@ -103,6 +118,7 @@ function App(): React.JSX.Element { + diff --git a/src/hooks/jsThreadHooks.ts b/src/hooks/jsThreadHooks.ts index 8f7b6bd..5716ee1 100644 --- a/src/hooks/jsThreadHooks.ts +++ b/src/hooks/jsThreadHooks.ts @@ -78,6 +78,19 @@ export const onFpsUiChange = prepareOnChange(getUiFpsBuffer) export const onCpuChange = prepareOnChange(getCpuUsageBuffer) export const onMemoryChange = prepareOnChange(getMemoryUsageBuffer) +export const onExtendedMemoryChange = ( + callback: (value: ExtendedMemoryUsage) => void, + intervalMs: number = 1000 +) => { + const intervalId = setInterval(() => { + callback(getExtendedMemoryUsage()) + }, intervalMs) + + return () => { + clearInterval(intervalId) + } +} + export const useFpsJs = () => { const [value, setValue] = useState(0) useEffect(() => { @@ -113,3 +126,16 @@ export const useMemoryUsage = () => { }, []) return value } + +export const useExtendedMemoryUsage = (): ExtendedMemoryUsage => { + const [value, setValue] = useState({ + memoryUsageMb: 0, + residentSizeKb: 0, + regionCount: 0, + }) + useEffect(() => { + const unsubscribe = onExtendedMemoryChange(setValue) + return unsubscribe + }, []) + return value +}