Skip to content

Commit e41db7c

Browse files
authored
Implement iOS startup time tracing (#2355)
* Fix scripts to work on macOS * Implement iOS startup time tracing * chmod Startup * Revert "chmod Startup" This reverts commit 9ce4445. * Move .logarchive to TMPDIR * Add comment about host/device time * Add warmup iteration and kill app after each run
1 parent 3e0eafa commit e41db7c

File tree

3 files changed

+194
-10
lines changed

3 files changed

+194
-10
lines changed

src/scenarios/init.sh

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
#!/bin/sh
2+
13
print_usage() {
24
echo "***Please run this script as SOURCE. ex: . ./init.sh***"
35
echo "This script sets up PYTHONPATH and determines which dotnet to use."
@@ -32,17 +34,17 @@ export PYTHONPATH=$PYTHONPATH:$scriptPath:$absolutePath
3234
echo "PYTHONPATH=$PYTHONPATH"
3335

3436
# Parse arguments
35-
if [ "$1" == "-help" ] || [ "$1" == "-h" ]
37+
if [ "$1" = "-help" ] || [ "$1" = "-h" ]
3638
then
3739
print_usage
3840
elif [ "$#" -gt 2 ]
3941
then
4042
print_usage
41-
elif [ "$1" == "-dotnetdir" ] && [ "$2" != "" ]
43+
elif [ "$1" = "-dotnetdir" ] && [ "$2" != "" ]
4244
then
4345
dotnetDirectory="$2"
4446
setup_env $dotnetDirectory
45-
elif [ "$1" == "-channel" ] && [ "$2" != "" ]
47+
elif [ "$1" = "-channel" ] && [ "$2" != "" ]
4648
then
4749
channel="$2"
4850
download_dotnet $channel

src/scenarios/shared/runner.py

Lines changed: 187 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88
import glob
99
import re
1010
import time
11+
from datetime import datetime
12+
import json
1113

1214
from logging import exception, getLogger
1315
from collections import namedtuple
1416
from argparse import ArgumentParser
1517
from argparse import RawTextHelpFormatter
1618
from io import StringIO
17-
from shutil import move
19+
from shutil import move, rmtree
1820
from shared.crossgen import CrossgenArguments
1921
from shared.startup import StartupWrapper
2022
from shared.util import publishedexe, pythoncommand, appfolder, xharnesscommand
@@ -57,12 +59,12 @@ def parseargs(self):
5759

5860
# parse only command
5961
parseonlyparser = subparsers.add_parser(const.DEVICESTARTUP,
60-
description='measure time to main for Android apps')
62+
description='measure time to startup for Android/iOS apps')
6163
parseonlyparser.add_argument('--device-type', choices=['android','ios'],type=str.lower,help='Device type for testing', dest='devicetype')
6264
parseonlyparser.add_argument('--package-path', help='Location of test application', dest='packagepath')
63-
parseonlyparser.add_argument('--package-name', help='Classname of application', dest='packagename')
65+
parseonlyparser.add_argument('--package-name', help='Classname (Android) or Bundle ID (iOS) of application', dest='packagename')
6466
parseonlyparser.add_argument('--startup-iterations', help='Startups to run (1+)', type=int, default=10, dest='startupiterations')
65-
parseonlyparser.add_argument('--disable-animations', help='Disable Android device animations', action='store_true', dest='animationsdisabled')
67+
parseonlyparser.add_argument('--disable-animations', help='Disable Android device animations, does nothing on iOS.', action='store_true', dest='animationsdisabled')
6668
self.add_common_arguments(parseonlyparser)
6769

6870
# inner loop command
@@ -306,7 +308,7 @@ def run(self):
306308
startup.runtests(self.traits)
307309

308310

309-
elif self.testtype == const.DEVICESTARTUP:
311+
elif self.testtype == const.DEVICESTARTUP and self.devicetype == 'android':
310312
# ADB Key Event corresponding numbers: https://gist.github.com/arjunv/2bbcca9a1a1c127749f8dcb6d36fb0bc
311313
# Regex used to split the response from starting the activity and saving each value
312314
#Example:
@@ -327,7 +329,7 @@ def run(self):
327329
getLogger().info("Removed: " + os.path.join(const.TRACEDIR, file))
328330
os.remove(file)
329331

330-
cmdline = xharnesscommand() + [self.devicetype, 'state', '--adb']
332+
cmdline = xharnesscommand() + ['android', 'state', '--adb']
331333
adb = RunCommand(cmdline, verbose=True)
332334
adb.run()
333335

@@ -427,7 +429,7 @@ def run(self):
427429
getLogger().info(f"Animation values successfully set to {animationValue}.")
428430

429431
installCmd = xharnesscommand() + [
430-
self.devicetype,
432+
'android',
431433
'install',
432434
'--app', self.packagepath,
433435
'--package-name',
@@ -588,6 +590,184 @@ def run(self):
588590
self.traits.add_traits(overwrite=True, apptorun="app", startupmetric=const.STARTUP_DEVICETIMETOMAIN, tracefolder='PerfTest/', tracename='runoutput.trace', scenarioname=self.scenarioname)
589591
startup.parsetraces(self.traits)
590592

593+
elif self.testtype == const.DEVICESTARTUP and self.devicetype == 'ios':
594+
595+
getLogger().info("Clearing potential previous run nettraces")
596+
for file in glob.glob(os.path.join(const.TRACEDIR, 'PerfTest', 'runoutput.trace')):
597+
if exists(file):
598+
getLogger().info("Removed: " + os.path.join(const.TRACEDIR, file))
599+
os.remove(file)
600+
601+
if not exists(const.TMPDIR):
602+
os.mkdir(const.TMPDIR)
603+
604+
getLogger().info("Clearing potential previous run *.logarchive")
605+
for logarchive in glob.glob(os.path.join(const.TMPDIR, '*.logarchive')):
606+
if exists(logarchive):
607+
getLogger().info("Removed: " + os.path.join(const.TMPDIR, logarchive))
608+
rmtree(logarchive)
609+
610+
getLogger().info("Checking device state.")
611+
cmdline = xharnesscommand() + ['apple', 'state']
612+
adb = RunCommand(cmdline, verbose=True)
613+
adb.run()
614+
615+
getLogger().info("Installing app on device.")
616+
installCmd = xharnesscommand() + [
617+
'apple',
618+
'install',
619+
'--app', self.packagepath,
620+
'--target', 'ios-device',
621+
'-o',
622+
const.TRACEDIR,
623+
'-v'
624+
]
625+
RunCommand(installCmd, verbose=True).run()
626+
getLogger().info("Completed install.")
627+
628+
allResults = []
629+
for i in range(self.startupiterations + 1): # adding one iteration to account for the warmup iteration
630+
getLogger().info("Waiting 10 secs to ensure we're not getting confused with previous app run.")
631+
time.sleep(10)
632+
633+
getLogger().info(f"Collect startup data for iteration {i}.")
634+
runCmdTimestamp = datetime.now()
635+
runCmd = xharnesscommand() + [
636+
'apple',
637+
'mlaunch',
638+
'--',
639+
f'--launchdevbundleid={self.packagename}',
640+
]
641+
runCmdCommand = RunCommand(runCmd, verbose=True)
642+
runCmdCommand.run()
643+
app_pid_search = re.search("Launched application.*with pid (?P<app_pid>\d+)", runCmdCommand.stdout)
644+
app_pid = int(app_pid_search.group('app_pid'))
645+
646+
logarchive_filename = os.path.join(const.TMPDIR, f'iteration{i}.logarchive')
647+
getLogger().info(f"Waiting 5 secs to ensure app with PID {app_pid} is fully started.")
648+
time.sleep(5)
649+
collectCmd = [
650+
'sudo',
651+
'log',
652+
'collect',
653+
'--device',
654+
'--start', runCmdTimestamp.strftime("%Y-%m-%d %H:%M:%S"),
655+
'--output', logarchive_filename,
656+
]
657+
RunCommand(collectCmd, verbose=True).run()
658+
659+
getLogger().info(f"Kill app with PID {app_pid}.")
660+
killCmd = xharnesscommand() + [
661+
'apple',
662+
'mlaunch',
663+
'--',
664+
f'--killdev={app_pid}',
665+
]
666+
killCmdCommand = RunCommand(killCmd, verbose=True)
667+
killCmdCommand.run()
668+
669+
# Process Data
670+
671+
# There are four watchdog events from SpringBoard during an application startup:
672+
#
673+
# [application<net.dot.maui>:770] [realTime] Now monitoring resource allowance of 20.00s (at refreshInterval -1.00s)
674+
# [application<net.dot.maui>:770] [realTime] Stopped monitoring.
675+
# [application<net.dot.maui>:770] [realTime] Now monitoring resource allowance of 19.28s (at refreshInterval -1.00s)
676+
# [application<net.dot.maui>:770] [realTime] Stopped monitoring.
677+
#
678+
# The first two are monitoring the time it takes the OS to create the process, load .dylibs and call into the app's main()
679+
# The second two are monitoring the time it takes the app to draw the first frame of UI from main()
680+
#
681+
# An app has 20 seconds to complete this sequence or the OS will kill the app.
682+
# We collect these log events to do our measurements.
683+
684+
logShowCmd = [
685+
'log',
686+
'show',
687+
'--predicate', '(process == "SpringBoard") && (category == "Watchdog")',
688+
'--info',
689+
'--style', 'ndjson',
690+
logarchive_filename,
691+
]
692+
logShowCmdCommand = RunCommand(logShowCmd, verbose=True)
693+
logShowCmdCommand.run()
694+
695+
events = []
696+
for line in logShowCmdCommand.stdout.splitlines():
697+
try:
698+
lineData = json.loads(line)
699+
if 'Now monitoring resource allowance' in lineData['eventMessage'] or 'Stopped monitoring' in lineData['eventMessage']:
700+
events.append (lineData)
701+
except:
702+
break
703+
704+
# the startup measurement relies on the date/time of the device to be pretty much in sync with the host
705+
# since we use the timestamps from the host to decide which parts of the device log to get and
706+
# we then use that to calculate the time delta from watchdog events
707+
if len(events) != 4:
708+
raise Exception("Didn't get the right amount of watchdog events, this could mean the app crashed or the device clock is not in sync with the host.")
709+
710+
timeToMainEventStart = events[0]
711+
timeToMainEventStop = events[1]
712+
timeToFirstDrawEventStart = events[2]
713+
timeToFirstDrawEventStop = events[3]
714+
715+
# validate log messages
716+
if f'application<{self.packagename}>:{app_pid}' not in timeToMainEventStart['eventMessage'] or 'Now monitoring resource allowance of 20.00s' not in timeToMainEventStart['eventMessage']:
717+
raise Exception(f"Invalid timeToMainEventStart: {timeToMainEventStart['eventMessage']}")
718+
719+
if f'application<{self.packagename}>:{app_pid}' not in timeToMainEventStop['eventMessage'] or 'Stopped monitoring' not in timeToMainEventStop['eventMessage']:
720+
raise Exception(f"Invalid timeToMainEventStop: {timeToMainEventStop['eventMessage']}")
721+
722+
if f'application<{self.packagename}>:{app_pid}' not in timeToFirstDrawEventStart['eventMessage'] or 'Now monitoring resource allowance of' not in timeToFirstDrawEventStart['eventMessage']:
723+
raise Exception(f"Invalid timeToFirstDrawEventStart: {timeToFirstDrawEventStart['eventMessage']}")
724+
725+
if f'application<{self.packagename}>:{app_pid}' not in timeToFirstDrawEventStop['eventMessage'] or 'Stopped monitoring' not in timeToFirstDrawEventStop['eventMessage']:
726+
raise Exception(f"Invalid timeToFirstDrawEventStop: {timeToFirstDrawEventStop['eventMessage']}")
727+
728+
timeToMainEventStartDateTime = datetime.strptime(timeToMainEventStart['timestamp'], '%Y-%m-%d %H:%M:%S.%f%z')
729+
timeToMainEventEndDateTime = datetime.strptime(timeToMainEventStop['timestamp'], '%Y-%m-%d %H:%M:%S.%f%z')
730+
timeToMainMilliseconds = (timeToMainEventEndDateTime - timeToMainEventStartDateTime).total_seconds() * 1000
731+
732+
timeToFirstDrawEventStartDateTime = datetime.strptime(timeToFirstDrawEventStart['timestamp'], '%Y-%m-%d %H:%M:%S.%f%z')
733+
timeToFirstDrawEventEndDateTime = datetime.strptime(timeToFirstDrawEventStop['timestamp'], '%Y-%m-%d %H:%M:%S.%f%z')
734+
timeToFirstDrawMilliseconds = (timeToFirstDrawEventEndDateTime - timeToFirstDrawEventStartDateTime).total_seconds() * 1000
735+
736+
totalTimeMilliseconds = timeToMainMilliseconds + timeToFirstDrawMilliseconds
737+
738+
if i == 0:
739+
# ignore the warmup iteration
740+
getLogger().info(f'Warmup iteration took {totalTimeMilliseconds}')
741+
else:
742+
# TODO: this isn't really a COLD run, we should have separate measurements for starting the app right after install
743+
launchState = 'COLD'
744+
allResults.append(f'LaunchState: {launchState}\nTotalTime: {int(totalTimeMilliseconds)}\nTimeToMain: {int(timeToMainMilliseconds)}\n\n')
745+
746+
# Done with testing, uninstall the app
747+
getLogger().info("Uninstalling app")
748+
uninstallAppCmd = xharnesscommand() + [
749+
'apple',
750+
'uninstall',
751+
'--app', self.packagename,
752+
'--target', 'ios-device',
753+
'-o',
754+
const.TRACEDIR,
755+
'-v'
756+
]
757+
RunCommand(uninstallAppCmd, verbose=True).run()
758+
759+
# Create traces to store the data so we can keep the current general parse trace flow
760+
getLogger().info(f"Logs: \n{allResults}")
761+
os.makedirs(f"{const.TRACEDIR}/PerfTest", exist_ok=True)
762+
traceFile = open(f"{const.TRACEDIR}/PerfTest/runoutput.trace", "w")
763+
for result in allResults:
764+
traceFile.write(result)
765+
traceFile.close()
766+
767+
startup = StartupWrapper()
768+
self.traits.add_traits(overwrite=True, apptorun="app", startupmetric=const.STARTUP_DEVICETIMETOMAIN, tracefolder='PerfTest/', tracename='runoutput.trace', scenarioname=self.scenarioname)
769+
startup.parsetraces(self.traits)
770+
591771
elif self.testtype == const.SOD:
592772
sod = SODWrapper()
593773
builtdir = const.PUBDIR if os.path.exists(const.PUBDIR) else None

src/scenarios/shared/util.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ def getruntimeidentifier():
4242
rid = 'win-'
4343
elif sys.platform == 'linux' or sys.platform == 'linux2':
4444
rid = 'linux-'
45+
elif sys.platform == 'darwin':
46+
rid = 'osx-'
4547
else:
4648
raise Exception('Platform %s not supported.' % sys.platform)
4749

0 commit comments

Comments
 (0)