Skip to content

Commit cf3c7bb

Browse files
committed
add multiGet real benchmark
1 parent 1ae49c7 commit cf3c7bb

3 files changed

Lines changed: 267 additions & 11 deletions

File tree

src/components/TestToolMenu.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import useWaitForNavigation from '@hooks/useWaitForNavigation';
1111
import {revokeMultifactorAuthenticationCredentials} from '@libs/actions/MultifactorAuthentication';
1212
import {isUsingStagingApi} from '@libs/ApiUtils';
1313
import Navigation from '@libs/Navigation/Navigation';
14+
import MultiGetBenchmark from '@pages/Debug/MultiGetBenchmark';
1415
import SQLiteBenchmark from '@pages/Debug/SQLiteBenchmark';
1516
import {setShouldFailAllRequests, setShouldForceOffline, setShouldSimulatePoorConnection} from '@userActions/Network';
1617
import {expireSessionWithDelay, invalidateAuthToken, invalidateCredentials} from '@userActions/Session';
@@ -200,6 +201,7 @@ function TestToolMenu() {
200201
</TestToolRow>
201202
<SoftKillTestToolRow />
202203
<TestCrash />
204+
<MultiGetBenchmark />
203205
<SQLiteBenchmark />
204206
</>
205207
);
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
/**
2+
* MultiGet Real Data Benchmark
3+
*
4+
* Measures multiGet performance on real OnyxDB data (not synthetic).
5+
* Use this to get before/after numbers when changing SQLiteProvider.multiGet.
6+
*
7+
* Workflow:
8+
* 1. Run "Measure current" → records baseline
9+
* 2. Change the multiGet implementation in SQLiteProvider
10+
* 3. Rebuild app
11+
* 4. Run "Measure current" again → compare to baseline
12+
*
13+
* Access via Test Tools Modal.
14+
*/
15+
import Clipboard from '@react-native-clipboard/clipboard';
16+
import React, {useCallback, useState} from 'react';
17+
import {Platform, StyleSheet, View} from 'react-native';
18+
// eslint-disable-next-line no-restricted-imports
19+
import storage from 'react-native-onyx/dist/storage';
20+
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
21+
import Text from '@components/Text';
22+
23+
const RUNS = 40;
24+
const LOOPS = 6;
25+
const MIN_COLLECTION_SIZE = 200; // only benchmark collections with enough keys to be meaningful
26+
const PAUSE_BETWEEN_COLLECTIONS_MS = 200;
27+
28+
const localStyles = StyleSheet.create({
29+
title: {fontSize: 16, fontWeight: 'bold', marginTop: 20, marginBottom: 2},
30+
sub: {fontSize: 11, color: '#666', marginBottom: 12},
31+
btn: {backgroundColor: '#0366d6', padding: 12, borderRadius: 8, alignItems: 'center', marginBottom: 8},
32+
btnOff: {backgroundColor: '#999'},
33+
btnTxt: {color: '#fff', fontWeight: '600', fontSize: 14},
34+
status: {fontSize: 11, color: '#666', marginBottom: 12, fontStyle: 'italic'},
35+
group: {marginBottom: 12, borderWidth: 1, borderColor: '#ddd', borderRadius: 6, padding: 10},
36+
collectionTitle: {fontSize: 12, fontWeight: '600', marginBottom: 4},
37+
row: {flexDirection: 'row', paddingVertical: 2},
38+
c: {flex: 1, fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace'},
39+
cWide: {flex: 2, fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace'},
40+
hRow: {flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#ddd', paddingBottom: 3, marginBottom: 4},
41+
hTxt: {fontWeight: '600', fontSize: 10},
42+
});
43+
44+
type CollectionResult = {
45+
prefix: string;
46+
keyCount: number;
47+
median: number;
48+
min: number;
49+
max: number;
50+
};
51+
52+
// eslint-disable-next-line no-promise-executor-return
53+
const pause = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
54+
55+
56+
/** Extract collection prefix from a key like "reportActions_12345" → "reportActions_" */
57+
function getPrefix(key: string): string {
58+
const match = key.match(/^([a-zA-Z]+_)/);
59+
return match ? match[1] : key;
60+
}
61+
62+
async function measureCollection(keys: string[]): Promise<{median: number; min: number; max: number}> {
63+
// 3 warmup runs
64+
await storage.multiGet(keys);
65+
await storage.multiGet(keys);
66+
await storage.multiGet(keys);
67+
68+
const loopMedians: number[] = [];
69+
let overallMin = Infinity;
70+
let overallMax = 0;
71+
72+
for (let loop = 0; loop < LOOPS; loop++) {
73+
const times: number[] = [];
74+
for (let i = 0; i < RUNS; i++) {
75+
const start = performance.now();
76+
// eslint-disable-next-line no-await-in-loop
77+
await storage.multiGet(keys);
78+
times.push(performance.now() - start);
79+
}
80+
times.sort((a, b) => a - b);
81+
const loopMedian = times.at(Math.floor(times.length / 2)) ?? 0;
82+
loopMedians.push(loopMedian);
83+
overallMin = Math.min(overallMin, times.at(0) ?? 0);
84+
overallMax = Math.max(overallMax, times.at(-1) ?? 0);
85+
// eslint-disable-next-line no-await-in-loop
86+
await pause(100);
87+
}
88+
89+
loopMedians.sort((a, b) => a - b);
90+
return {
91+
median: loopMedians.at(Math.floor(loopMedians.length / 2)) ?? 0,
92+
min: overallMin,
93+
max: overallMax,
94+
};
95+
}
96+
97+
function MultiGetBenchmark() {
98+
const [results, setResults] = useState<CollectionResult[]>([]);
99+
const [baseline, setBaseline] = useState<CollectionResult[] | null>(null);
100+
const [running, setRunning] = useState(false);
101+
const [status, setStatus] = useState('Ready — loads real OnyxDB collections');
102+
103+
const runMeasurement = useCallback(
104+
async (saveAsBaseline: boolean) => {
105+
if (Platform.OS === 'web') {
106+
setStatus('Only runs on iOS/Android');
107+
return;
108+
}
109+
setRunning(true);
110+
setResults([]);
111+
112+
try {
113+
setStatus('Loading all keys from OnyxDB...');
114+
const allKeys = await storage.getAllKeys();
115+
116+
// Group keys by collection prefix
117+
const prefixMap = new Map<string, string[]>();
118+
for (const key of allKeys) {
119+
const prefix = getPrefix(key);
120+
const existing = prefixMap.get(prefix) ?? [];
121+
if (!prefixMap.has(prefix)) {
122+
prefixMap.set(prefix, existing);
123+
}
124+
existing.push(key);
125+
}
126+
127+
// Only measure collections large enough to matter
128+
const collections = Array.from(prefixMap.entries())
129+
.filter(([, keys]) => keys.length >= MIN_COLLECTION_SIZE)
130+
.sort(([, a], [, b]) => b.length - a.length); // largest first
131+
132+
if (collections.length === 0) {
133+
setStatus(`No collections with ${MIN_COLLECTION_SIZE}+ keys found. Try with a loaded account.`);
134+
setRunning(false);
135+
return;
136+
}
137+
138+
setStatus(`Found ${collections.length} collections. Benchmarking...`);
139+
const collectionResults: CollectionResult[] = [];
140+
141+
for (const [prefix, keys] of collections) {
142+
setStatus(`Measuring ${prefix} (${keys.length} keys)...`);
143+
// eslint-disable-next-line no-await-in-loop
144+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
145+
// eslint-disable-next-line no-await-in-loop
146+
await pause(PAUSE_BETWEEN_COLLECTIONS_MS);
147+
148+
// eslint-disable-next-line no-await-in-loop
149+
const r = await measureCollection(keys);
150+
collectionResults.push({prefix, keyCount: keys.length, ...r});
151+
setResults([...collectionResults]);
152+
}
153+
154+
if (saveAsBaseline) {
155+
setBaseline(collectionResults);
156+
setStatus(`Baseline saved! ${collections.length} collections measured.`);
157+
} else {
158+
setStatus(`Done! ${collections.length} collections measured.`);
159+
}
160+
} catch (e) {
161+
const msg = e instanceof Error ? e.message : String(e);
162+
setStatus(`Error: ${msg}`);
163+
// eslint-disable-next-line no-console
164+
console.error('MultiGet benchmark error:', e);
165+
} finally {
166+
setRunning(false);
167+
}
168+
},
169+
[],
170+
);
171+
172+
const copyResults = useCallback(() => {
173+
const output = JSON.stringify(
174+
{
175+
platform: Platform.OS,
176+
timestamp: new Date().toISOString(),
177+
config: {runs: RUNS, minCollectionSize: MIN_COLLECTION_SIZE},
178+
baseline: baseline ?? [],
179+
current: results,
180+
},
181+
null,
182+
2,
183+
);
184+
Clipboard.setString(output);
185+
setStatus('Copied to clipboard!');
186+
}, [baseline, results]);
187+
188+
const baselineMap = new Map(baseline?.map((r) => [r.prefix, r]) ?? []);
189+
190+
return (
191+
<View>
192+
<Text style={localStyles.title}>multiGet — Real Data Benchmark</Text>
193+
<Text style={localStyles.sub}>
194+
Measures Storage.multiGet on real OnyxDB collections.{'\n'}
195+
{LOOPS} loops x {RUNS} runs, median of medians. Collections with {MIN_COLLECTION_SIZE}+ keys only.
196+
</Text>
197+
198+
<PressableWithFeedback
199+
accessibilityRole="button"
200+
accessibilityLabel="Save as baseline"
201+
style={[localStyles.btn, {backgroundColor: '#6f42c1'}, running && localStyles.btnOff]}
202+
onPress={() => runMeasurement(true)}
203+
disabled={running}
204+
>
205+
<Text style={localStyles.btnTxt}>{running ? 'Running...' : 'Save as Baseline'}</Text>
206+
</PressableWithFeedback>
207+
208+
<PressableWithFeedback
209+
accessibilityRole="button"
210+
accessibilityLabel="Measure current"
211+
style={[localStyles.btn, running && localStyles.btnOff]}
212+
onPress={() => runMeasurement(false)}
213+
disabled={running}
214+
>
215+
<Text style={localStyles.btnTxt}>{running ? 'Running...' : 'Measure Current'}</Text>
216+
</PressableWithFeedback>
217+
218+
{results.length > 0 && !running && (
219+
<PressableWithFeedback
220+
accessibilityRole="button"
221+
accessibilityLabel="Copy results"
222+
style={[localStyles.btn, {backgroundColor: '#28a745'}]}
223+
onPress={copyResults}
224+
>
225+
<Text style={localStyles.btnTxt}>Copy Results</Text>
226+
</PressableWithFeedback>
227+
)}
228+
229+
<Text style={localStyles.status}>{status}</Text>
230+
231+
{results.length > 0 && (
232+
<View style={localStyles.group}>
233+
<View style={localStyles.hRow}>
234+
<Text style={[localStyles.cWide, localStyles.hTxt]}>Collection</Text>
235+
<Text style={[localStyles.c, localStyles.hTxt]}>Keys</Text>
236+
<Text style={[localStyles.c, localStyles.hTxt]}>Median</Text>
237+
<Text style={[localStyles.c, localStyles.hTxt]}>vs baseline</Text>
238+
</View>
239+
{results.map((r) => {
240+
const base = baselineMap.get(r.prefix);
241+
const diff = base ? ((r.median - base.median) / base.median) * 100 : null;
242+
const diffStr = diff == null ? '—' : `${diff > 0 ? '+' : ''}${diff.toFixed(1)}%`;
243+
// eslint-disable-next-line no-nested-ternary
244+
const clr = diff == null ? '#666' : diff < -5 ? '#2d8a4e' : diff > 5 ? '#d32f2f' : '#666';
245+
return (
246+
<View
247+
key={r.prefix}
248+
style={localStyles.row}
249+
>
250+
<Text style={localStyles.cWide}>{r.prefix}</Text>
251+
<Text style={localStyles.c}>{r.keyCount}</Text>
252+
<Text style={localStyles.c}>{r.median.toFixed(2)}ms</Text>
253+
<Text style={[localStyles.c, {color: clr}]}>{diffStr}</Text>
254+
</View>
255+
);
256+
})}
257+
</View>
258+
)}
259+
</View>
260+
);
261+
}
262+
263+
MultiGetBenchmark.displayName = 'MultiGetBenchmark';
264+
265+
export default MultiGetBenchmark;

src/pages/Debug/SQLiteBenchmark.tsx

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -181,16 +181,6 @@ function makeStrategies(db: NitroSQLiteConnection, keys: string[], prefix: strin
181181
// eslint-disable-next-line no-promise-executor-return
182182
const pause = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
183183

184-
/** Force GC if available (Hermes exposes this) so it doesn't fire randomly during measurement */
185-
function forceGC() {
186-
// @ts-expect-error -- HermesInternal is a global on Hermes engine
187-
if (typeof HermesInternal === 'undefined' || typeof HermesInternal.collectGarbage !== 'function') {
188-
return;
189-
}
190-
// @ts-expect-error -- not in TS types
191-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
192-
HermesInternal?.collectGarbage();
193-
}
194184

195185
async function runStrategyAsync(fn: StrategyFn, runs: number): Promise<{bestOf: number; median: number; min: number; max: number}> {
196186
// 3 warmup
@@ -200,7 +190,6 @@ async function runStrategyAsync(fn: StrategyFn, runs: number): Promise<{bestOf:
200190

201191
const times: number[] = [];
202192
for (let i = 0; i < runs; i++) {
203-
forceGC();
204193
const start = performance.now();
205194
// eslint-disable-next-line no-await-in-loop
206195
await fn();

0 commit comments

Comments
 (0)