mirror of
https://github.com/torvalds/linux.git
synced 2026-11-10 17:11:02 +01:00
Port gecko.py to a standalone script in tools/perf/python/ that uses the perf module directly to convert perf.data profiles into Firefox Gecko profile format. Improvements compared to the legacy script: - Encapsulate profiler state in GeckoCLI and CategoryData classes with full type annotations, removing global variables. - Harden the local HTTP server in _write_and_launch(): write the temporary profile to an isolated tempfile.TemporaryDirectory() with a randomized UUID filename instead of the current working directory, bind HTTPServer exclusively to 127.0.0.1 on an OS-assigned ephemeral port (0), restrict CORS Access-Control-Allow-Origin to 'https://profiler.firefox.com' instead of '*', and restrict HTTP GET requests exclusively to the randomized profile filename (returning HTTP 403 for directory listings and any other path). - Add -i/--input and -e/--event CLI options via argparse. Add a shell test (test_gecko_python.sh) to verify the standalone script. Assisted-by: Antigravity:gemini-3.1-pro Signed-off-by: Ian Rogers <irogers@google.com> Signed-off-by: Arnaldo Carvalho de Melo <acme@redhat.com>
412 lines
15 KiB
Python
Executable File
412 lines
15 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: GPL-2.0
|
|
"""
|
|
gecko.py - Convert perf record output to Firefox's gecko profile format
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import functools
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import urllib.parse
|
|
import uuid
|
|
import webbrowser
|
|
from dataclasses import dataclass, field
|
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
|
from typing import Dict, List, NamedTuple, Optional, Tuple
|
|
|
|
import perf
|
|
|
|
|
|
# https://github.com/firefox-devtools/profiler/blob/53970305b51b9b472e26d7457fee1d66cd4e2737/src/types/gecko-profile.js#L156
|
|
class Frame(NamedTuple):
|
|
"""A single stack frame in the gecko profile format."""
|
|
string_id: int
|
|
relevantForJS: bool
|
|
innerWindowID: int
|
|
implementation: None
|
|
optimizations: None
|
|
line: None
|
|
column: None
|
|
category: int
|
|
subcategory: Optional[int]
|
|
|
|
|
|
# https://github.com/firefox-devtools/profiler/blob/53970305b51b9b472e26d7457fee1d66cd4e2737/src/types/gecko-profile.js#L216
|
|
class Stack(NamedTuple):
|
|
"""A single stack in the gecko profile format."""
|
|
prefix_id: Optional[int]
|
|
frame_id: int
|
|
|
|
|
|
# https://github.com/firefox-devtools/profiler/blob/53970305b51b9b472e26d7457fee1d66cd4e2737/src/types/gecko-profile.js#L90
|
|
class Sample(NamedTuple):
|
|
"""A single sample in the gecko profile format."""
|
|
stack_id: Optional[int]
|
|
time_ms: float
|
|
responsiveness: int
|
|
|
|
|
|
@dataclass
|
|
class Tables:
|
|
"""Interned tables for the gecko profile format."""
|
|
frame_table: List[Frame] = field(default_factory=list)
|
|
string_table: List[str] = field(default_factory=list)
|
|
string_map: Dict[str, int] = field(default_factory=dict)
|
|
stack_table: List[Stack] = field(default_factory=list)
|
|
stack_map: Dict[Tuple[Optional[int], int], int] = field(default_factory=dict)
|
|
frame_map: Dict[str, int] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class Thread:
|
|
"""A builder for a profile of the thread."""
|
|
comm: str
|
|
pid: int
|
|
tid: int
|
|
user_category: int
|
|
kernel_category: int
|
|
samples: List[Sample] = field(default_factory=list)
|
|
tables: Tables = field(default_factory=Tables)
|
|
|
|
def _intern_stack(self, frame_id: int, prefix_id: Optional[int]) -> int:
|
|
"""Gets a matching stack, or saves the new stack. Returns a Stack ID."""
|
|
key = (prefix_id, frame_id)
|
|
stack_id = self.tables.stack_map.get(key)
|
|
if stack_id is None:
|
|
stack_id = len(self.tables.stack_table)
|
|
self.tables.stack_table.append(Stack(prefix_id=prefix_id, frame_id=frame_id))
|
|
self.tables.stack_map[key] = stack_id
|
|
return stack_id
|
|
|
|
def _intern_string(self, string: str) -> int:
|
|
"""Gets a matching string, or saves the new string. Returns a String ID."""
|
|
string_id = self.tables.string_map.get(string)
|
|
if string_id is not None:
|
|
return string_id
|
|
string_id = len(self.tables.string_table)
|
|
self.tables.string_table.append(string)
|
|
self.tables.string_map[string] = string_id
|
|
return string_id
|
|
|
|
def _intern_frame(self, frame_str: str) -> int:
|
|
"""Gets a matching stack frame, or saves the new frame. Returns a Frame ID."""
|
|
frame_id = self.tables.frame_map.get(frame_str)
|
|
if frame_id is not None:
|
|
return frame_id
|
|
frame_id = len(self.tables.frame_table)
|
|
self.tables.frame_map[frame_str] = frame_id
|
|
string_id = self._intern_string(frame_str)
|
|
|
|
category = self.user_category
|
|
if (frame_str.find('kallsyms') != -1 or
|
|
frame_str.find('/vmlinux') != -1 or
|
|
frame_str.endswith('.ko)')):
|
|
category = self.kernel_category
|
|
|
|
self.tables.frame_table.append(Frame(
|
|
string_id=string_id,
|
|
relevantForJS=False,
|
|
innerWindowID=0,
|
|
implementation=None,
|
|
optimizations=None,
|
|
line=None,
|
|
column=None,
|
|
category=category,
|
|
subcategory=None,
|
|
))
|
|
return frame_id
|
|
|
|
def add_sample(self, comm: str, stack: List[str], time_ms: float) -> None:
|
|
"""Add a timestamped stack trace sample to the thread builder."""
|
|
if self.comm != comm:
|
|
self.comm = comm
|
|
|
|
prefix_stack_id: Optional[int] = None
|
|
for frame in stack:
|
|
frame_id = self._intern_frame(frame)
|
|
prefix_stack_id = self._intern_stack(frame_id, prefix_stack_id)
|
|
|
|
if prefix_stack_id is not None:
|
|
self.samples.append(Sample(stack_id=prefix_stack_id,
|
|
time_ms=time_ms,
|
|
responsiveness=0))
|
|
|
|
def to_json_dict(self) -> Dict:
|
|
"""Converts current Thread to GeckoThread JSON format."""
|
|
return {
|
|
"tid": self.tid,
|
|
"pid": self.pid,
|
|
"name": self.comm,
|
|
"markers": {
|
|
"schema": {
|
|
"name": 0,
|
|
"startTime": 1,
|
|
"endTime": 2,
|
|
"phase": 3,
|
|
"category": 4,
|
|
"data": 5,
|
|
},
|
|
"data": [],
|
|
},
|
|
"samples": {
|
|
"schema": {
|
|
"stack": 0,
|
|
"time": 1,
|
|
"responsiveness": 2,
|
|
},
|
|
"data": self.samples
|
|
},
|
|
"frameTable": {
|
|
"schema": {
|
|
"location": 0,
|
|
"relevantForJS": 1,
|
|
"innerWindowID": 2,
|
|
"implementation": 3,
|
|
"optimizations": 4,
|
|
"line": 5,
|
|
"column": 6,
|
|
"category": 7,
|
|
"subcategory": 8,
|
|
},
|
|
"data": self.tables.frame_table,
|
|
},
|
|
"stackTable": {
|
|
"schema": {
|
|
"prefix": 0,
|
|
"frame": 1,
|
|
},
|
|
"data": self.tables.stack_table,
|
|
},
|
|
"stringTable": self.tables.string_table,
|
|
"registerTime": 0,
|
|
"unregisterTime": None,
|
|
"processType": "default",
|
|
}
|
|
|
|
|
|
class CORSRequestHandler(SimpleHTTPRequestHandler):
|
|
"""Enable CORS for requests from profiler.firefox.com."""
|
|
def __init__(self, *args, allowed_file: Optional[str] = None, **kwargs):
|
|
self.allowed_file = allowed_file
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def end_headers(self):
|
|
self.send_header('Access-Control-Allow-Origin', 'https://profiler.firefox.com')
|
|
super().end_headers()
|
|
|
|
def do_GET(self):
|
|
if self.allowed_file and self.path.split('?')[0] != f'/{self.allowed_file}':
|
|
self.send_error(403, "Access denied")
|
|
return
|
|
super().do_GET()
|
|
|
|
def list_directory(self, path):
|
|
self.send_error(403, "Directory listing forbidden")
|
|
return None
|
|
|
|
|
|
@dataclass
|
|
class CategoryData:
|
|
"""Category configuration for the gecko profile."""
|
|
user_index: int = 0
|
|
kernel_index: int = 1
|
|
categories: List[Dict] = field(default_factory=list)
|
|
|
|
|
|
class GeckoCLI:
|
|
"""Command-line interface for converting perf data to Gecko format."""
|
|
def __init__(self, args: argparse.Namespace) -> None:
|
|
self.args = args
|
|
self.tid_to_thread: Dict[int, Thread] = {}
|
|
self.start_time_ms: Optional[float] = None
|
|
self.session: Optional[perf.session] = None
|
|
self.product = args.product
|
|
self.cat_data = CategoryData(
|
|
categories=[
|
|
{
|
|
"name": 'User',
|
|
"color": args.user_color,
|
|
"subcategories": ['Other']
|
|
},
|
|
{
|
|
"name": 'Kernel',
|
|
"color": args.kernel_color,
|
|
"subcategories": ['Other']
|
|
},
|
|
]
|
|
)
|
|
|
|
def process_event(self, sample) -> None:
|
|
"""Process a single perf sample event."""
|
|
if self.args.event_name and self.args.event_name not in str(sample.evsel):
|
|
return
|
|
|
|
# sample_time is in nanoseconds. Gecko wants milliseconds.
|
|
time_ms = sample.sample_time / 1000000.0
|
|
pid = sample.sample_pid
|
|
tid = sample.sample_tid
|
|
|
|
if self.start_time_ms is None:
|
|
self.start_time_ms = time_ms
|
|
|
|
try:
|
|
thread_info = self.session.find_thread(sample.sample_pid, sample.sample_tid) if self.session else None
|
|
comm = (thread_info.comm() if thread_info is not None else None) or "[unknown]"
|
|
except AttributeError:
|
|
comm = "[unknown]"
|
|
|
|
stack = []
|
|
callchain = sample.callchain
|
|
if callchain:
|
|
for entry in callchain:
|
|
symbol = entry.symbol or "[unknown]"
|
|
dso = entry.dso or "[unknown]"
|
|
stack.append(f"{symbol} (in {dso})")
|
|
# Reverse because Gecko wants root first.
|
|
stack.reverse()
|
|
else:
|
|
symbol = sample.symbol or "[unknown]"
|
|
dso = sample.dso or "[unknown]"
|
|
stack.append(f"{symbol} (in {dso})")
|
|
|
|
thread = self.tid_to_thread.get(tid)
|
|
if thread is None:
|
|
thread = Thread(comm=comm, pid=pid, tid=tid,
|
|
user_category=self.cat_data.user_index,
|
|
kernel_category=self.cat_data.kernel_index)
|
|
self.tid_to_thread[tid] = thread
|
|
thread.add_sample(comm=comm, stack=stack, time_ms=time_ms)
|
|
|
|
def run(self) -> None:
|
|
"""Run the conversion process."""
|
|
input_file = self.args.input or "perf.data"
|
|
if input_file != "-" and not os.path.exists(input_file):
|
|
print(f"Error: {input_file} not found.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
self.session = perf.session(perf.data(input_file), sample=self.process_event)
|
|
except (OSError, RuntimeError, ValueError) as e:
|
|
print(f"Error opening session: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if self.session:
|
|
try:
|
|
self.session.process_events()
|
|
finally:
|
|
# Break the reference cycle between perf.session and self.process_event
|
|
# because perf.session lacks cyclic GC support (tp_traverse).
|
|
self.session = None
|
|
|
|
threads = [t.to_json_dict() for t in self.tid_to_thread.values()]
|
|
|
|
gecko_profile = {
|
|
"meta": {
|
|
"interval": 1,
|
|
"processType": 0,
|
|
"product": self.product,
|
|
"stackwalk": 1,
|
|
"debug": 0,
|
|
"gcpoison": 0,
|
|
"asyncstack": 1,
|
|
"startTime": self.start_time_ms or 0,
|
|
"shutdownTime": None,
|
|
"version": 24,
|
|
"presymbolicated": True,
|
|
"categories": self.cat_data.categories,
|
|
"markerSchema": [],
|
|
},
|
|
"libs": [],
|
|
"threads": threads,
|
|
"processes": [],
|
|
"pausedRanges": [],
|
|
}
|
|
|
|
output_file = self.args.save_only
|
|
if output_file is None:
|
|
self._write_and_launch(gecko_profile)
|
|
else:
|
|
print(f'[ perf gecko: Captured and wrote into {output_file} ]')
|
|
with open(output_file, 'w', encoding='utf-8') as f:
|
|
json.dump(gecko_profile, f, indent=2)
|
|
|
|
def _write_and_launch(self, profile: Dict) -> None:
|
|
"""Write the profile to a file and launch the Firefox profiler."""
|
|
print("Starting Firefox Profiler on your default browser...")
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir_name:
|
|
profile_name = f'gecko_profile_{uuid.uuid4().hex}.json'
|
|
filename = os.path.join(tmp_dir_name, profile_name)
|
|
|
|
with open(filename, 'w', encoding='utf-8') as f:
|
|
json.dump(profile, f, indent=2)
|
|
|
|
handler = functools.partial(
|
|
CORSRequestHandler, directory=tmp_dir_name, allowed_file=profile_name
|
|
)
|
|
try:
|
|
httpd = HTTPServer(('127.0.0.1', 0), handler)
|
|
except OSError as e:
|
|
print(f"Error starting HTTP server: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
port = httpd.server_port
|
|
|
|
def start_server():
|
|
httpd.serve_forever()
|
|
|
|
thread = threading.Thread(target=start_server, daemon=True)
|
|
thread.start()
|
|
|
|
safe_string = urllib.parse.quote_plus(f'http://127.0.0.1:{port}/{profile_name}')
|
|
url = f'https://profiler.firefox.com/from-url/{safe_string}'
|
|
print(f"Please open the following URL in your browser to view the profile:\n\n{url}\n")
|
|
launch_html = os.path.join(tmp_dir_name, 'launch.html')
|
|
with open(launch_html, 'w', encoding='utf-8') as f:
|
|
f.write(f'<!DOCTYPE html><html><head>'
|
|
f'<meta http-equiv="refresh" content="0;url={url}">'
|
|
f'<script>window.location.replace({json.dumps(url)});</script>'
|
|
f'</head><body>Redirecting to Firefox Profiler...</body></html>')
|
|
if not webbrowser.open(f'file://{launch_html}'):
|
|
print("Failed to open browser, please open the URL manually.")
|
|
|
|
print(f'[ perf gecko: Captured and wrote into {filename} ]')
|
|
print("Press Ctrl+C to stop the local server.")
|
|
try:
|
|
# Keep the main thread alive so the daemon thread can serve requests
|
|
stop_event = threading.Event()
|
|
while True:
|
|
stop_event.wait(1)
|
|
except KeyboardInterrupt:
|
|
print("\nStopping server...")
|
|
httpd.shutdown()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(
|
|
description="Convert perf.data to Firefox's Gecko Profile format"
|
|
)
|
|
parser.add_argument('--product', default='perf', help='Product name (e.g. perf)')
|
|
parser.add_argument('--user-color', default='yellow',
|
|
help='Color for the User category',
|
|
choices=['yellow', 'blue', 'purple', 'green', 'orange', 'red',
|
|
'grey', 'magenta'])
|
|
parser.add_argument('--kernel-color', default='orange',
|
|
help='Color for the Kernel category',
|
|
choices=['yellow', 'blue', 'purple', 'green', 'orange', 'red',
|
|
'grey', 'magenta'])
|
|
parser.add_argument('--save-only',
|
|
help='Save the output to a file instead of opening Firefox\'s profiler')
|
|
parser.add_argument("-i", "--input", help="input perf.data file")
|
|
parser.add_argument("-e", "--event", default="", dest="event_name", type=str,
|
|
help="specify the event to generate gecko profile for")
|
|
|
|
cli_args = parser.parse_args()
|
|
cli = GeckoCLI(cli_args)
|
|
cli.run()
|