summaryrefslogtreecommitdiffstats
path: root/testing/talos/talos/xtalos/etlparser.py
diff options
context:
space:
mode:
Diffstat (limited to 'testing/talos/talos/xtalos/etlparser.py')
-rw-r--r--testing/talos/talos/xtalos/etlparser.py640
1 files changed, 640 insertions, 0 deletions
diff --git a/testing/talos/talos/xtalos/etlparser.py b/testing/talos/talos/xtalos/etlparser.py
new file mode 100644
index 0000000000..10fa5c64d3
--- /dev/null
+++ b/testing/talos/talos/xtalos/etlparser.py
@@ -0,0 +1,640 @@
+#!/usr/bin/env python
+
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+import csv
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+
+from talos.xtalos import xtalos
+
+EVENTNAME_INDEX = 0
+PROCESS_INDEX = 2
+THREAD_ID_INDEX = 3
+PARENT_PID_INDEX = 3
+DISKBYTES_COL = "Size"
+FNAME_COL = "FileName"
+IMAGEFUNC_COL = "Image!Function"
+EVENTGUID_COL = "EventGuid"
+ACTIVITY_ID_COL = "etw:ActivityId"
+NUMBYTES_COL = "NumBytes"
+BYTESSENT_COL = "BytesSent"
+
+CEVT_WINDOWS_RESTORED = "{917b96b1-ecad-4dab-a760-8d49027748ae}"
+CEVT_XPCOM_SHUTDOWN = "{26d1e091-0ae7-4f49-a554-4214445c505c}"
+NAME_SUBSTITUTIONS = [
+ # Careful with your regex!
+ # Substitution happens after combinations like \t \s \n ... are replaced
+ # with their real representations. So, prepend them with extra backslash.
+ # Read more: https://docs.python.org/2.7/library/re.html#re.sub
+ (re.compile(r"{\w{8}-\w{4}-\w{4}-\w{4}-\w{12}}"), "{uuid}"),
+ (re.compile(r"talos\\tests\\tp5n\\.*"), r"talos\\tests\{tp5n_files}"),
+ (re.compile(r"nvidia corporation\\3d vision\\.*"), "{nvidia_3d_vision}"),
+ (re.compile(r"cltbld\.t-w732-ix-\d+\.\d+"), "{cltbld}"),
+ (
+ re.compile(r"venv\\lib\\site-packages\\pip\\_vendor\\.*"),
+ r"venv\\lib\\site-packages\\{pip_vendor}",
+ ),
+]
+stages = ["startup", "normal", "shutdown"]
+net_events = {
+ "TcpDataTransferReceive": "recv",
+ "UdpEndpointReceiveMessages": "recv",
+ "TcpDataTransferSend": "send",
+ "UdpEndpointSendMessages": "send",
+}
+gThreads = {}
+gConnectionIDs = {}
+gHeaders = {}
+gBrowserPID = None
+
+
+def uploadFile(filename):
+ mud = os.environ.get("MOZ_UPLOAD_DIR", None)
+ if mud:
+ print("uploading raw file %s via blobber" % filename)
+ mud_filename = os.path.join(mud, filename)
+ shutil.copyfile(filename, "%s.log" % mud_filename)
+
+
+def filterOutHeader(data):
+ # -1 means we have not yet found the header
+ # 0 means we are in the header
+ # 1+ means that we are past the header
+ state = -1
+ done = False
+ while not done:
+ try:
+ row = next(data)
+ except StopIteration:
+ done = True
+ break
+ except UnicodeDecodeError:
+ continue
+ except csv.Error:
+ continue
+
+ if not len(row):
+ continue
+
+ if state < 0:
+ # Keep looking for the header (denoted by "BeginHeader").
+ if row[0] == "BeginHeader":
+ state = 0
+ continue
+
+ if state == 0:
+ # Eventually, we'll find the end (denoted by "EndHeader").
+ if row[0] == "EndHeader":
+ state = 1
+ continue
+
+ gHeaders[row[EVENTNAME_INDEX]] = row
+ continue
+
+ if state >= 1:
+ state = state + 1
+
+ # The line after "EndHeader" is also not useful, so we want to strip
+ # that in addition to the header.
+ if state > 2:
+ yield row
+
+
+def getIndex(eventName, *colNames):
+ eventHeader = gHeaders[eventName]
+
+ for colName in colNames:
+ try:
+ return eventHeader.index(colName)
+ except ValueError:
+ pass
+
+ return None
+
+
+def readFile(filename):
+ data = csv.reader(
+ open(filename, "r", encoding="cp1252"),
+ delimiter=",",
+ quotechar='"',
+ skipinitialspace=True,
+ )
+ data = filterOutHeader(data)
+ return data
+
+
+def fileSummary(row, stage, retVal):
+ event = row[EVENTNAME_INDEX]
+
+ # TODO: do we care about the other events?
+ if event not in ("FileIoRead", "FileIoWrite"):
+ return
+ fname_index = getIndex(event, FNAME_COL)
+
+ # We only care about events that have a file name.
+ if fname_index is None:
+ return
+
+ # Some data rows are missing the filename?
+ if len(row) <= fname_index:
+ return
+
+ thread_extra = ""
+ if gThreads[row[THREAD_ID_INDEX]] == "main":
+ thread_extra = " (main)"
+ key_tuple = (
+ row[fname_index],
+ "%s%s" % (row[THREAD_ID_INDEX], thread_extra),
+ stages[stage],
+ )
+ total_tuple = (
+ row[fname_index],
+ "%s%s" % (row[THREAD_ID_INDEX], thread_extra),
+ "all",
+ )
+
+ if key_tuple not in retVal:
+ retVal[key_tuple] = {
+ "DiskReadBytes": 0,
+ "DiskReadCount": 0,
+ "DiskWriteBytes": 0,
+ "DiskWriteCount": 0,
+ }
+ retVal[total_tuple] = retVal[key_tuple]
+
+ if event == "FileIoRead":
+ retVal[key_tuple]["DiskReadCount"] += 1
+ retVal[total_tuple]["DiskReadCount"] += 1
+ idx = getIndex(event, DISKBYTES_COL)
+ retVal[key_tuple]["DiskReadBytes"] += int(row[idx], 16)
+ retVal[total_tuple]["DiskReadBytes"] += int(row[idx], 16)
+ elif event == "FileIoWrite":
+ retVal[key_tuple]["DiskWriteCount"] += 1
+ retVal[total_tuple]["DiskWriteCount"] += 1
+ idx = getIndex(event, DISKBYTES_COL)
+ retVal[key_tuple]["DiskWriteBytes"] += int(row[idx], 16)
+ retVal[total_tuple]["DiskWriteBytes"] += int(row[idx], 16)
+
+
+def etl2csv(xperf_path, etl_filename, debug=False):
+ """
+ Convert etl_filename to etl_filename.csv (temp file) which is the .csv
+ representation of the .etl file Etlparser will read this .csv and parse
+ the information we care about into the final output. This is done to keep
+ things simple and to preserve resources on talos machines
+ (large files == high memory + cpu)
+ """
+
+ xperf_cmd = [
+ xperf_path,
+ "-merge",
+ "%s.user" % etl_filename,
+ "%s.kernel" % etl_filename,
+ etl_filename,
+ ]
+ if debug:
+ print("executing '%s'" % subprocess.list2cmdline(xperf_cmd))
+ subprocess.call(xperf_cmd)
+
+ csv_filename = "%s.csv" % etl_filename
+ xperf_cmd = [xperf_path, "-i", etl_filename, "-o", csv_filename]
+ if debug:
+ print("executing '%s'" % subprocess.list2cmdline(xperf_cmd))
+ subprocess.call(xperf_cmd)
+ return csv_filename
+
+
+def trackProcess(row, firstFirefoxPID):
+ global gBrowserPID
+ if gBrowserPID:
+ return
+
+ # Without the launcher, the initial Firefox process *is* the browser
+ # process. OTOH, with the launcher process enabled, the browser is actually
+ # the first child process of the first Firefox process.
+ parentPID = int(row[PARENT_PID_INDEX])
+ if parentPID == firstFirefoxPID:
+ proc = row[PROCESS_INDEX]
+ gBrowserPID = int(re.search("^.* \(\s*(\d+)\)$", proc).group(1))
+
+
+def getBrowserPID():
+ global gBrowserPID
+ return gBrowserPID
+
+
+def trackThread(row, browserPID):
+ event, proc, tid = row[EVENTNAME_INDEX], row[PROCESS_INDEX], row[THREAD_ID_INDEX]
+ if event in ["T-DCStart", "T-Start"]:
+ procName, procID = re.search("^(.*) \(\s*(\d+)\)$", proc).group(1, 2)
+ if procID == str(browserPID):
+ imgIdx = getIndex(event, IMAGEFUNC_COL)
+ img = re.match("([^!]+)!", row[imgIdx]).group(1)
+ if img == procName:
+ gThreads[tid] = "main"
+ else:
+ gThreads[tid] = "nonmain"
+ elif event in ["T-DCEnd", "T-End"] and tid in gThreads:
+ del gThreads[tid]
+
+
+def trackThreadFileIO(row, io, stage):
+ event, tid = row[EVENTNAME_INDEX], row[THREAD_ID_INDEX]
+ opType = {"FileIoWrite": "write", "FileIoRead": "read"}[event]
+ th, stg = gThreads[tid], stages[stage]
+ sizeIdx = getIndex(event, DISKBYTES_COL)
+ bytes = int(row[sizeIdx], 16)
+ io[(th, stg, "file_%s_ops" % opType)] = (
+ io.get((th, stg, "file_%s_ops" % opType), 0) + 1
+ )
+ io[(th, stg, "file_%s_bytes" % opType)] = (
+ io.get((th, stg, "file_%s_bytes" % opType), 0) + bytes
+ )
+ io[(th, stg, "file_io_bytes")] = io.get((th, stg, "file_io_bytes"), 0) + bytes
+
+
+def trackThreadNetIO(row, io, stage):
+ event, tid = row[EVENTNAME_INDEX], row[THREAD_ID_INDEX]
+ connIdIdx = getIndex(event, ACTIVITY_ID_COL)
+ connID = row[connIdIdx]
+ if connID not in gConnectionIDs:
+ gConnectionIDs[connID] = tid
+ origThread = gConnectionIDs[connID]
+ if origThread in gThreads:
+ match = re.match("[\w-]+\/([\w-]+)?", event)
+ if not match:
+ raise xtalos.XTalosError(
+ "Could not find a regular expression match for event: {}".format(event)
+ )
+ netEvt = match.group(1)
+
+ if netEvt in net_events:
+ opType = net_events[netEvt]
+ th, stg = gThreads[origThread], stages[stage]
+ # On newer versions of Windows, some net I/O events have switched to
+ # using BYTESSENT_COL, so we try both
+ lenIdx = getIndex(event, NUMBYTES_COL, BYTESSENT_COL)
+ bytes = int(row[lenIdx])
+ io[(th, stg, "net_%s_bytes" % opType)] = (
+ io.get((th, stg, "net_%s_bytes" % opType), 0) + bytes
+ )
+ io[(th, stg, "net_io_bytes")] = io.get((th, stg, "net_io_bytes"), 0) + bytes
+
+
+def updateStage(row, stage):
+ guidIdx = getIndex(row[EVENTNAME_INDEX], EVENTGUID_COL)
+ if row[guidIdx] == CEVT_WINDOWS_RESTORED and stage == 0:
+ stage = 1
+ elif row[guidIdx] == CEVT_XPCOM_SHUTDOWN and stage == 1:
+ stage = 2
+ return stage
+
+
+def loadAllowlist(filename):
+ if not filename:
+ return
+ if not os.path.exists(filename):
+ print("Warning: xperf allowlist %s was not found" % filename)
+ return
+ lines = open(filename).readlines()
+ # Expand paths
+ lines = [os.path.expandvars(elem.strip()) for elem in lines]
+ files = set()
+ dirs = set()
+ recur = set()
+ for line in lines:
+ if line.startswith("#"):
+ continue
+ elif line.endswith("\\*\\*"):
+ recur.add(line[:-4])
+ elif line.endswith("\\*"):
+ dirs.add(line[:-2])
+ else:
+ files.add(line)
+ return (files, dirs, recur)
+
+
+def checkAllowlist(filename, allowlist):
+ if not allowlist:
+ return False
+ if filename in allowlist[0]:
+ return True
+ if os.path.dirname(filename) in allowlist[1]:
+ return True
+ head = filename
+ while len(head) > 3: # Length 3 implies root directory, e.g. C:\
+ head, tail = os.path.split(head)
+ if head in allowlist[2]:
+ return True
+ return False
+
+
+def etlparser(
+ xperf_path,
+ etl_filename,
+ processID,
+ approot=None,
+ configFile=None,
+ outputFile=None,
+ allowlist_file=None,
+ error_filename=None,
+ all_stages=False,
+ all_threads=False,
+ debug=False,
+):
+ global NAME_SUBSTITUTIONS
+
+ # setup output file
+ if outputFile:
+ outFile = open(outputFile, "w")
+ else:
+ outFile = sys.stdout
+
+ files = {}
+ io = {}
+ stage = 0
+
+ csvname = etl2csv(xperf_path, etl_filename, debug=debug)
+ for row in readFile(csvname):
+ event = row[EVENTNAME_INDEX]
+ if event == "P-Start":
+ trackProcess(row, processID)
+ elif event in ["T-DCStart", "T-Start", "T-DCEnd", "T-End"]:
+ trackThread(row, getBrowserPID())
+ elif (
+ event in ["FileIoRead", "FileIoWrite"] and row[THREAD_ID_INDEX] in gThreads
+ ):
+ fileSummary(row, stage, files)
+ trackThreadFileIO(row, io, stage)
+ elif event.endswith("Event/Classic") and row[THREAD_ID_INDEX] in gThreads:
+ stage = updateStage(row, stage)
+ elif event.startswith("Microsoft-Windows-TCPIP"):
+ trackThreadNetIO(row, io, stage)
+
+ if debug:
+ uploadFile(csvname)
+
+ output = "thread, stage, counter, value\n"
+ for cntr in sorted(io.keys()):
+ output += "%s, %s\n" % (", ".join(cntr), str(io[cntr]))
+ if outputFile:
+ fname = "%s_thread_stats%s" % os.path.splitext(outputFile)
+ with open(fname, "w") as f:
+ f.write(output)
+
+ if debug:
+ uploadFile(fname)
+ else:
+ print(output)
+
+ allowlist = loadAllowlist(allowlist_file)
+
+ header = "filename, tid, stage, readcount, readbytes, writecount," " writebytes"
+ outFile.write(header + "\n")
+
+ # Filter out stages, threads, and allowlisted files that we're not
+ # interested in
+ filekeys = [
+ x
+ for x in files.keys()
+ if (all_stages or x[2] == stages[0])
+ and (all_threads or x[1].endswith("(main)"))
+ and (all_stages and x[2] != stages[0] or not checkAllowlist(x[0], allowlist))
+ ]
+ if debug:
+ # in debug, we want stages = [startup+normal] and all threads, not just (main)
+ # we will use this data to upload fileIO info to blobber only for debug mode
+ outputData = [
+ x
+ for x in files.keys()
+ if (all_stages or x[2] in [stages[0], stages[1]])
+ and (
+ all_stages
+ and x[2] not in [stages[0], stages[1]]
+ or not checkAllowlist(x[0], allowlist)
+ )
+ ]
+ else:
+ outputData = filekeys
+
+ # output data
+ for row in outputData:
+ output = "%s, %s, %s, %s, %s, %s, %s\n" % (
+ row[0],
+ row[1],
+ row[2],
+ files[row]["DiskReadCount"],
+ files[row]["DiskReadBytes"],
+ files[row]["DiskWriteCount"],
+ files[row]["DiskWriteBytes"],
+ )
+ outFile.write(output)
+
+ if outputFile:
+ # close the file handle
+ outFile.close()
+ if debug:
+ uploadFile(outputFile)
+
+ # We still like to have the outputfile to record the raw data, now
+ # filter out acceptable files/ranges
+ allowlist_path = None
+ wl_temp = {}
+ dirname = os.path.dirname(__file__)
+ if os.path.exists(os.path.join(dirname, "xperf_allowlist.json")):
+ allowlist_path = os.path.join(dirname, "xperf_allowlist.json")
+ elif os.path.exists(os.path.join(dirname, "xtalos")) and os.path.exists(
+ os.path.join(dirname, "xtalos", "xperf_allowlist.json")
+ ):
+ allowlist_path = os.path.join(dirname, "xtalos", "xperf_allowlist.json")
+
+ wl_temp = {}
+ if allowlist_path:
+ with open(allowlist_path, "r") as fHandle:
+ wl_temp = json.load(fHandle)
+
+ # Approot is the full path where the application is located at
+ # We depend on it for dependentlibs.list to ignore files required for
+ # normal startup.
+ if approot:
+ if os.path.exists("%s\\dependentlibs.list" % approot):
+ with open("%s\\dependentlibs.list" % approot, "r") as fhandle:
+ libs = fhandle.readlines()
+
+ for lib in libs:
+ wl_temp["{firefox}\\%s" % lib.strip()] = {"ignore": True}
+
+ # Windows isn't case sensitive, this protects us against mismatched
+ # systems.
+ wl = {}
+ for item in wl_temp:
+ wl[item.lower()] = wl_temp[item]
+
+ errors = []
+ for row in filekeys:
+ filename = original_filename = row[0]
+ filename = filename.lower()
+ # take care of 'program files (x86)' matching 'program files'
+ filename = filename.replace(" (x86)", "")
+
+ paths = ["profile", "firefox", "desktop", "talos"]
+ for path in paths:
+ pathname = "%s\\" % path
+ parts = filename.split(pathname)
+ if len(parts) >= 2:
+ filename = "{%s}\\%s" % (path, pathname.join(parts[1:]))
+
+ parts = filename.split("\\installtime")
+ if len(parts) >= 2:
+ filename = "%s\\{time}" % parts[0]
+
+ # NOTE: this is Prefetch or prefetch, not case sensitive operating
+ # system
+ parts = filename.split("refetch")
+ if len(parts) >= 2:
+ filename = "%srefetch\\{prefetch}.pf" % parts[0]
+
+ for pattern, substitution in NAME_SUBSTITUTIONS:
+ filename = re.sub(pattern, substitution, filename)
+
+ if filename in wl:
+ if "ignore" in wl[filename] and wl[filename]["ignore"]:
+ continue
+
+ # too noisy
+ # if wl[filename]['minbytes'] > (files[row]['DiskReadBytes'] +\
+ # files[row]['DiskWriteBytes']):
+ # print "%s: read %s bytes, less than expected minimum: %s"
+ # % (filename, (files[row]['DiskReadBytes'] +
+ # files[row]['DiskWriteBytes']),
+ # wl[filename]['minbytes'])
+
+ # don't report in first round
+ # elif wl[filename]['maxbytes'] < (files[row]['DiskReadBytes'] +\
+ # files[row]['DiskWriteBytes']):
+ # errors.append("%s: read %s bytes, more than expected maximum:"
+ # " %s"
+ # % (filename, (files[row]['DiskReadBytes'] +
+ # files[row]['DiskWriteBytes']),
+ # wl[filename]['maxbytes']))
+
+ # too noisy
+ # elif wl[filename]['mincount'] > (files[row]['DiskReadCount'] +\
+ # files[row]['DiskWriteCount']):
+ # print "%s: %s accesses, less than expected minimum: %s"
+ # % (filename, (files[row]['DiskReadCount'] +
+ # files[row]['DiskWriteCount']),
+ # wl[filename]['mincount'])
+
+ # don't report in first round
+ # elif wl[filename]['maxcount'] < (files[row]['DiskReadCount'] +\
+ # files[row]['DiskWriteCount']):
+ # errors.append("%s: %s accesses, more than expected maximum:"
+ # " %s"
+ # % (filename, (files[row]['DiskReadCount'] +
+ # files[row]['DiskWriteCount']),
+ # wl[filename]['maxcount']))
+ else:
+ errors.append(
+ "File '%s' (normalized from '%s') was accessed and we were not expecting"
+ " it. DiskReadCount: %s, DiskWriteCount: %s,"
+ " DiskReadBytes: %s, DiskWriteBytes: %s"
+ % (
+ filename,
+ original_filename,
+ files[row]["DiskReadCount"],
+ files[row]["DiskWriteCount"],
+ files[row]["DiskReadBytes"],
+ files[row]["DiskWriteBytes"],
+ )
+ )
+
+ if errors:
+ # output specific errors to be picked up by tbpl parser
+ for error in errors:
+ # NOTE: the ' :' is intentional, without the space before the :,
+ # some parser will translate this
+ print("TEST-UNEXPECTED-FAIL : xperf: %s" % error)
+
+ # We detect if browser_failures.txt exists to exit and turn the job
+ # orange
+ if error_filename:
+ with open(error_filename, "w") as errorFile:
+ errorFile.write("\n".join(errors))
+
+ if debug:
+ uploadFile(etl_filename)
+
+
+def etlparser_from_config(config_file, **kwargs):
+ """start from a YAML config file"""
+
+ # option defaults
+ args = {
+ "xperf_path": "xperf.exe",
+ "etl_filename": "test.etl",
+ "outputFile": "etl_output.csv",
+ "processID": None,
+ "approot": None,
+ "allowlist_file": None,
+ "error_filename": None,
+ "all_stages": False,
+ "all_threads": False,
+ }
+ args.update(kwargs)
+
+ # override from YAML config file
+ args = xtalos.options_from_config(args, config_file)
+
+ # ensure process ID is given
+ if not args.get("processID"):
+ raise xtalos.XTalosError("No process ID option given")
+
+ # ensure path to xperf given
+ if not os.path.exists(args["xperf_path"]):
+ raise xtalos.XTalosError(
+ "ERROR: xperf_path '%s' does not exist" % args["xperf_path"]
+ )
+
+ # update args with config file
+ args["configFile"] = config_file
+
+ # call etlparser
+ etlparser(**args)
+
+
+def main(args=sys.argv[1:]):
+
+ # parse command line arguments
+ parser = xtalos.XtalosOptions()
+ args = parser.parse_args(args)
+ args = parser.verifyOptions(args)
+ if args is None:
+ parser.error("Unable to verify arguments")
+ if not args.processID:
+ parser.error("No process ID argument given")
+
+ # call API
+ etlparser(
+ args.xperf_path,
+ args.etl_filename,
+ args.processID,
+ args.approot,
+ args.configFile,
+ args.outputFile,
+ args.allowlist_file,
+ args.error_filename,
+ args.all_stages,
+ args.all_threads,
+ debug=args.debug_level >= xtalos.DEBUG_INFO,
+ )
+
+
+if __name__ == "__main__":
+ main()