gecko.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. # gecko.py - Convert perf record output to Firefox's gecko profile format
  2. # SPDX-License-Identifier: GPL-2.0
  3. #
  4. # The script converts perf.data to Gecko Profile Format,
  5. # which can be read by https://profiler.firefox.com/.
  6. #
  7. # Usage:
  8. #
  9. # perf record -a -g -F 99 sleep 60
  10. # perf script report gecko
  11. #
  12. # Combined:
  13. #
  14. # perf script gecko -F 99 -a sleep 60
  15. import os
  16. import sys
  17. import time
  18. import json
  19. import string
  20. import random
  21. import argparse
  22. import threading
  23. import webbrowser
  24. import urllib.parse
  25. from os import system
  26. from functools import reduce
  27. from dataclasses import dataclass, field
  28. from http.server import HTTPServer, SimpleHTTPRequestHandler, test
  29. from typing import List, Dict, Optional, NamedTuple, Set, Tuple, Any
  30. # Add the Perf-Trace-Util library to the Python path
  31. sys.path.append(os.environ['PERF_EXEC_PATH'] + \
  32. '/scripts/python/Perf-Trace-Util/lib/Perf/Trace')
  33. from perf_trace_context import *
  34. from Core import *
  35. StringID = int
  36. StackID = int
  37. FrameID = int
  38. CategoryID = int
  39. Milliseconds = float
  40. # start_time is intialiazed only once for the all event traces.
  41. start_time = None
  42. # https://github.com/firefox-devtools/profiler/blob/53970305b51b9b472e26d7457fee1d66cd4e2737/src/types/profile.js#L425
  43. # Follow Brendan Gregg's Flamegraph convention: orange for kernel and yellow for user space by default.
  44. CATEGORIES = None
  45. # The product name is used by the profiler UI to show the Operating system and Processor.
  46. PRODUCT = os.popen('uname -op').read().strip()
  47. # store the output file
  48. output_file = None
  49. # Here key = tid, value = Thread
  50. tid_to_thread = dict()
  51. # The HTTP server is used to serve the profile to the profiler UI.
  52. http_server_thread = None
  53. # The category index is used by the profiler UI to show the color of the flame graph.
  54. USER_CATEGORY_INDEX = 0
  55. KERNEL_CATEGORY_INDEX = 1
  56. # https://github.com/firefox-devtools/profiler/blob/53970305b51b9b472e26d7457fee1d66cd4e2737/src/types/gecko-profile.js#L156
  57. class Frame(NamedTuple):
  58. string_id: StringID
  59. relevantForJS: bool
  60. innerWindowID: int
  61. implementation: None
  62. optimizations: None
  63. line: None
  64. column: None
  65. category: CategoryID
  66. subcategory: int
  67. # https://github.com/firefox-devtools/profiler/blob/53970305b51b9b472e26d7457fee1d66cd4e2737/src/types/gecko-profile.js#L216
  68. class Stack(NamedTuple):
  69. prefix_id: Optional[StackID]
  70. frame_id: FrameID
  71. # https://github.com/firefox-devtools/profiler/blob/53970305b51b9b472e26d7457fee1d66cd4e2737/src/types/gecko-profile.js#L90
  72. class Sample(NamedTuple):
  73. stack_id: Optional[StackID]
  74. time_ms: Milliseconds
  75. responsiveness: int
  76. @dataclass
  77. class Thread:
  78. """A builder for a profile of the thread.
  79. Attributes:
  80. comm: Thread command-line (name).
  81. pid: process ID of containing process.
  82. tid: thread ID.
  83. samples: Timeline of profile samples.
  84. frameTable: interned stack frame ID -> stack frame.
  85. stringTable: interned string ID -> string.
  86. stringMap: interned string -> string ID.
  87. stackTable: interned stack ID -> stack.
  88. stackMap: (stack prefix ID, leaf stack frame ID) -> interned Stack ID.
  89. frameMap: Stack Frame string -> interned Frame ID.
  90. comm: str
  91. pid: int
  92. tid: int
  93. samples: List[Sample] = field(default_factory=list)
  94. frameTable: List[Frame] = field(default_factory=list)
  95. stringTable: List[str] = field(default_factory=list)
  96. stringMap: Dict[str, int] = field(default_factory=dict)
  97. stackTable: List[Stack] = field(default_factory=list)
  98. stackMap: Dict[Tuple[Optional[int], int], int] = field(default_factory=dict)
  99. frameMap: Dict[str, int] = field(default_factory=dict)
  100. """
  101. comm: str
  102. pid: int
  103. tid: int
  104. samples: List[Sample] = field(default_factory=list)
  105. frameTable: List[Frame] = field(default_factory=list)
  106. stringTable: List[str] = field(default_factory=list)
  107. stringMap: Dict[str, int] = field(default_factory=dict)
  108. stackTable: List[Stack] = field(default_factory=list)
  109. stackMap: Dict[Tuple[Optional[int], int], int] = field(default_factory=dict)
  110. frameMap: Dict[str, int] = field(default_factory=dict)
  111. def _intern_stack(self, frame_id: int, prefix_id: Optional[int]) -> int:
  112. """Gets a matching stack, or saves the new stack. Returns a Stack ID."""
  113. key = f"{frame_id}" if prefix_id is None else f"{frame_id},{prefix_id}"
  114. # key = (prefix_id, frame_id)
  115. stack_id = self.stackMap.get(key)
  116. if stack_id is None:
  117. # return stack_id
  118. stack_id = len(self.stackTable)
  119. self.stackTable.append(Stack(prefix_id=prefix_id, frame_id=frame_id))
  120. self.stackMap[key] = stack_id
  121. return stack_id
  122. def _intern_string(self, string: str) -> int:
  123. """Gets a matching string, or saves the new string. Returns a String ID."""
  124. string_id = self.stringMap.get(string)
  125. if string_id is not None:
  126. return string_id
  127. string_id = len(self.stringTable)
  128. self.stringTable.append(string)
  129. self.stringMap[string] = string_id
  130. return string_id
  131. def _intern_frame(self, frame_str: str) -> int:
  132. """Gets a matching stack frame, or saves the new frame. Returns a Frame ID."""
  133. frame_id = self.frameMap.get(frame_str)
  134. if frame_id is not None:
  135. return frame_id
  136. frame_id = len(self.frameTable)
  137. self.frameMap[frame_str] = frame_id
  138. string_id = self._intern_string(frame_str)
  139. symbol_name_to_category = KERNEL_CATEGORY_INDEX if frame_str.find('kallsyms') != -1 \
  140. or frame_str.find('/vmlinux') != -1 \
  141. or frame_str.endswith('.ko)') \
  142. else USER_CATEGORY_INDEX
  143. self.frameTable.append(Frame(
  144. string_id=string_id,
  145. relevantForJS=False,
  146. innerWindowID=0,
  147. implementation=None,
  148. optimizations=None,
  149. line=None,
  150. column=None,
  151. category=symbol_name_to_category,
  152. subcategory=None,
  153. ))
  154. return frame_id
  155. def _add_sample(self, comm: str, stack: List[str], time_ms: Milliseconds) -> None:
  156. """Add a timestamped stack trace sample to the thread builder.
  157. Args:
  158. comm: command-line (name) of the thread at this sample
  159. stack: sampled stack frames. Root first, leaf last.
  160. time_ms: timestamp of sample in milliseconds.
  161. """
  162. # Ihreads may not set their names right after they are created.
  163. # Instead, they might do it later. In such situations, to use the latest name they have set.
  164. if self.comm != comm:
  165. self.comm = comm
  166. prefix_stack_id = reduce(lambda prefix_id, frame: self._intern_stack
  167. (self._intern_frame(frame), prefix_id), stack, None)
  168. if prefix_stack_id is not None:
  169. self.samples.append(Sample(stack_id=prefix_stack_id,
  170. time_ms=time_ms,
  171. responsiveness=0))
  172. def _to_json_dict(self) -> Dict:
  173. """Converts current Thread to GeckoThread JSON format."""
  174. # Gecko profile format is row-oriented data as List[List],
  175. # And a schema for interpreting each index.
  176. # Schema:
  177. # https://github.com/firefox-devtools/profiler/blob/main/docs-developer/gecko-profile-format.md
  178. # https://github.com/firefox-devtools/profiler/blob/53970305b51b9b472e26d7457fee1d66cd4e2737/src/types/gecko-profile.js#L230
  179. return {
  180. "tid": self.tid,
  181. "pid": self.pid,
  182. "name": self.comm,
  183. # https://github.com/firefox-devtools/profiler/blob/53970305b51b9b472e26d7457fee1d66cd4e2737/src/types/gecko-profile.js#L51
  184. "markers": {
  185. "schema": {
  186. "name": 0,
  187. "startTime": 1,
  188. "endTime": 2,
  189. "phase": 3,
  190. "category": 4,
  191. "data": 5,
  192. },
  193. "data": [],
  194. },
  195. # https://github.com/firefox-devtools/profiler/blob/53970305b51b9b472e26d7457fee1d66cd4e2737/src/types/gecko-profile.js#L90
  196. "samples": {
  197. "schema": {
  198. "stack": 0,
  199. "time": 1,
  200. "responsiveness": 2,
  201. },
  202. "data": self.samples
  203. },
  204. # https://github.com/firefox-devtools/profiler/blob/53970305b51b9b472e26d7457fee1d66cd4e2737/src/types/gecko-profile.js#L156
  205. "frameTable": {
  206. "schema": {
  207. "location": 0,
  208. "relevantForJS": 1,
  209. "innerWindowID": 2,
  210. "implementation": 3,
  211. "optimizations": 4,
  212. "line": 5,
  213. "column": 6,
  214. "category": 7,
  215. "subcategory": 8,
  216. },
  217. "data": self.frameTable,
  218. },
  219. # https://github.com/firefox-devtools/profiler/blob/53970305b51b9b472e26d7457fee1d66cd4e2737/src/types/gecko-profile.js#L216
  220. "stackTable": {
  221. "schema": {
  222. "prefix": 0,
  223. "frame": 1,
  224. },
  225. "data": self.stackTable,
  226. },
  227. "stringTable": self.stringTable,
  228. "registerTime": 0,
  229. "unregisterTime": None,
  230. "processType": "default",
  231. }
  232. # Uses perf script python interface to parse each
  233. # event and store the data in the thread builder.
  234. def process_event(param_dict: Dict) -> None:
  235. global start_time
  236. global tid_to_thread
  237. time_stamp = (param_dict['sample']['time'] // 1000) / 1000
  238. pid = param_dict['sample']['pid']
  239. tid = param_dict['sample']['tid']
  240. comm = param_dict['comm']
  241. # Start time is the time of the first sample
  242. if not start_time:
  243. start_time = time_stamp
  244. # Parse and append the callchain of the current sample into a stack.
  245. stack = []
  246. if param_dict['callchain']:
  247. for call in param_dict['callchain']:
  248. if 'sym' not in call:
  249. continue
  250. stack.append(f'{call["sym"]["name"]} (in {call["dso"]})')
  251. if len(stack) != 0:
  252. # Reverse the stack, as root come first and the leaf at the end.
  253. stack = stack[::-1]
  254. # During perf record if -g is not used, the callchain is not available.
  255. # In that case, the symbol and dso are available in the event parameters.
  256. else:
  257. func = param_dict['symbol'] if 'symbol' in param_dict else '[unknown]'
  258. dso = param_dict['dso'] if 'dso' in param_dict else '[unknown]'
  259. stack.append(f'{func} (in {dso})')
  260. # Add sample to the specific thread.
  261. thread = tid_to_thread.get(tid)
  262. if thread is None:
  263. thread = Thread(comm=comm, pid=pid, tid=tid)
  264. tid_to_thread[tid] = thread
  265. thread._add_sample(comm=comm, stack=stack, time_ms=time_stamp)
  266. def trace_begin() -> None:
  267. global output_file
  268. if (output_file is None):
  269. print("Staring Firefox Profiler on your default browser...")
  270. global http_server_thread
  271. http_server_thread = threading.Thread(target=test, args=(CORSRequestHandler, HTTPServer,))
  272. http_server_thread.daemon = True
  273. http_server_thread.start()
  274. # Trace_end runs at the end and will be used to aggregate
  275. # the data into the final json object and print it out to stdout.
  276. def trace_end() -> None:
  277. global output_file
  278. threads = [thread._to_json_dict() for thread in tid_to_thread.values()]
  279. # Schema: https://github.com/firefox-devtools/profiler/blob/53970305b51b9b472e26d7457fee1d66cd4e2737/src/types/gecko-profile.js#L305
  280. gecko_profile_with_meta = {
  281. "meta": {
  282. "interval": 1,
  283. "processType": 0,
  284. "product": PRODUCT,
  285. "stackwalk": 1,
  286. "debug": 0,
  287. "gcpoison": 0,
  288. "asyncstack": 1,
  289. "startTime": start_time,
  290. "shutdownTime": None,
  291. "version": 24,
  292. "presymbolicated": True,
  293. "categories": CATEGORIES,
  294. "markerSchema": [],
  295. },
  296. "libs": [],
  297. "threads": threads,
  298. "processes": [],
  299. "pausedRanges": [],
  300. }
  301. # launch the profiler on local host if not specified --save-only args, otherwise print to file
  302. if (output_file is None):
  303. output_file = 'gecko_profile.json'
  304. with open(output_file, 'w') as f:
  305. json.dump(gecko_profile_with_meta, f, indent=2)
  306. launchFirefox(output_file)
  307. time.sleep(1)
  308. print(f'[ perf gecko: Captured and wrote into {output_file} ]')
  309. else:
  310. print(f'[ perf gecko: Captured and wrote into {output_file} ]')
  311. with open(output_file, 'w') as f:
  312. json.dump(gecko_profile_with_meta, f, indent=2)
  313. # Used to enable Cross-Origin Resource Sharing (CORS) for requests coming from 'https://profiler.firefox.com', allowing it to access resources from this server.
  314. class CORSRequestHandler(SimpleHTTPRequestHandler):
  315. def end_headers (self):
  316. self.send_header('Access-Control-Allow-Origin', 'https://profiler.firefox.com')
  317. SimpleHTTPRequestHandler.end_headers(self)
  318. # start a local server to serve the gecko_profile.json file to the profiler.firefox.com
  319. def launchFirefox(file):
  320. safe_string = urllib.parse.quote_plus(f'http://localhost:8000/{file}')
  321. url = 'https://profiler.firefox.com/from-url/' + safe_string
  322. webbrowser.open(f'{url}')
  323. def main() -> None:
  324. global output_file
  325. global CATEGORIES
  326. parser = argparse.ArgumentParser(description="Convert perf.data to Firefox\'s Gecko Profile format which can be uploaded to profiler.firefox.com for visualization")
  327. # Add the command-line options
  328. # Colors must be defined according to this:
  329. # https://github.com/firefox-devtools/profiler/blob/50124adbfa488adba6e2674a8f2618cf34b59cd2/res/css/categories.css
  330. parser.add_argument('--user-color', default='yellow', help='Color for the User category', choices=['yellow', 'blue', 'purple', 'green', 'orange', 'red', 'grey', 'magenta'])
  331. parser.add_argument('--kernel-color', default='orange', help='Color for the Kernel category', choices=['yellow', 'blue', 'purple', 'green', 'orange', 'red', 'grey', 'magenta'])
  332. # If --save-only is specified, the output will be saved to a file instead of opening Firefox's profiler directly.
  333. parser.add_argument('--save-only', help='Save the output to a file instead of opening Firefox\'s profiler')
  334. # Parse the command-line arguments
  335. args = parser.parse_args()
  336. # Access the values provided by the user
  337. user_color = args.user_color
  338. kernel_color = args.kernel_color
  339. output_file = args.save_only
  340. CATEGORIES = [
  341. {
  342. "name": 'User',
  343. "color": user_color,
  344. "subcategories": ['Other']
  345. },
  346. {
  347. "name": 'Kernel',
  348. "color": kernel_color,
  349. "subcategories": ['Other']
  350. },
  351. ]
  352. if __name__ == '__main__':
  353. main()