Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
getUiFps,
getCpuUsage,
getMemoryUsage,
getExtendedMemoryUsage,
getDeviceMaxRefreshRate,
getDeviceCurrentRefreshRate,
} from 'react-native-performance-toolkit'
Expand All @@ -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')
```
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand All @@ -218,18 +238,20 @@ 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
- `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
Expand Down Expand Up @@ -266,7 +288,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

Expand Down
16 changes: 16 additions & 0 deletions example/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
useFpsUi,
useCpuUsage,
useMemoryUsage,
useExtendedMemoryUsage,
useFpsJs,
getDeviceMaxRefreshRate,
getDeviceCurrentRefreshRate,
Expand Down Expand Up @@ -43,6 +44,20 @@ const MemoryUsageCounterJSThread = () => {
return <Text style={styles.stat}>RAM (MB): {formatValue(memoryUsage)}</Text>;
};

const ExtendedMemoryCounterJSThread = () => {
const { memoryUsageMb, residentSizeKb, regionCount } =
useExtendedMemoryUsage();
return (
<>
<Text style={styles.stat}>Ext RAM (MB): {formatValue(memoryUsageMb)}</Text>
<Text style={styles.stat}>
Resident (KB): {formatValue(residentSizeKb)}
</Text>
<Text style={styles.stat}>Regions: {formatValue(regionCount)}</Text>
</>
);
};

function App(): React.JSX.Element {
const blockJSThread = (blockTime: number = 500) => {
console.log(`Blocking JS thread for ${blockTime} milliseconds...`);
Expand Down Expand Up @@ -103,6 +118,7 @@ function App(): React.JSX.Element {
<UIFpsCounterJSThread />
<CpuUsageCounterJSThread />
<MemoryUsageCounterJSThread />
<ExtendedMemoryCounterJSThread />
</View>

<View>
Expand Down
109 changes: 89 additions & 20 deletions ios/HybridPerformanceToolkit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -229,24 +242,24 @@ 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<Int32>.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 {
isMemoryTrackingStarting = true
startMemoryTracking()
}

return memoryBuffer!
return memoryMetricsBuffer!
}

private func startMemoryTracking() {
Expand All @@ -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<Double>.size)
}
}

private func taskVmInfoCountCovers<T>(_ field: KeyPath<task_vm_info_data_t, T>, count: mach_msg_type_number_t) -> Bool {
guard let offset = MemoryLayout<task_vm_info_data_t>.offset(of: field) else {
return false
}
let wordSize = MemoryLayout<natural_t>.size
let requiredCount = mach_msg_type_number_t((offset + MemoryLayout<T>.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<task_vm_info_data_t>.size) / 4
var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<natural_t>.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 {
Expand Down
61 changes: 61 additions & 0 deletions src/hooks/jsThreadHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -43,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(() => {
Expand Down Expand Up @@ -78,3 +126,16 @@ export const useMemoryUsage = () => {
}, [])
return value
}

export const useExtendedMemoryUsage = (): ExtendedMemoryUsage => {
const [value, setValue] = useState<ExtendedMemoryUsage>({
memoryUsageMb: 0,
residentSizeKb: 0,
regionCount: 0,
})
useEffect(() => {
const unsubscribe = onExtendedMemoryChange(setValue)
return unsubscribe
}, [])
return value
}