memory_analyzer/memory_analyzer.py (175 lines of code) (raw):
#!/usr/bin/env python3
# Copyright (c) Facebook, Inc. and its affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
import errno
import os
import pickle
import sys
import tempfile
from datetime import datetime
from functools import partial
from multiprocessing.pool import ThreadPool
import click
import pkg_resources
from . import analysis_utils
from .frontend import frontend_utils
def analyze_memory_launcher(
pid, num_refs, specific_refs, debug, output_file, executable, template_out_path
):
templates_path = (
pkg_resources.resource_filename("memory_analyzer", "templates") + "/"
)
cur_path = os.path.dirname(__file__) + "/" # not zip safe, for now
gdb_obj = analysis_utils.GDBObject(pid, cur_path, executable, template_out_path)
output_path = os.path.abspath(output_file)
analysis_utils.render_template(
f"analysis.py.template",
templates_path,
num_refs,
pid,
specific_refs,
output_path,
template_out_path,
)
return gdb_obj.run_analysis(debug)
def write_to_output_file(filename, items):
with open(filename, "wb+") as outputf:
for item in items:
bytes_item = pickle.dumps(item)
outputf.write(bytes_item)
def is_root():
if os.geteuid() == 0:
return True
return False
def validate_pids(ctx, param, pids):
for pid in pids:
pid = int(pid)
try:
os.kill(pid, 0)
except OSError as e:
if e.errno == errno.EPERM and not is_root():
msg = "Permission error, try running as root"
raise click.UsageError(msg)
msg = f"The given PID {pid} is not valid."
raise click.BadParameter(msg)
return pids
def check_positive_int(ctx, param, i):
i = int(i)
if i >= 0:
return i
msg = "The number of references cannot be negative."
raise click.BadParameter(msg)
@click.group()
def cli():
pass
@cli.command()
@click.argument("filename", type=click.Path(exists=True))
def view(filename):
"""
Tool for viewing the output of the memory analyzer. Launches a UI.
Argument:
FILENAME: The filename of the snapshot to view.
"""
try:
pages = frontend_utils.get_pages(filename)
except pickle.UnpicklingError as e:
frontend_utils.echo_error(f"Error unpickling the data from {filename}: {e}")
sys.exit(1)
frontend_utils.initiate_curses(pages)
@cli.command()
@click.argument("pids", callback=validate_pids, nargs=-1)
@click.option(
"-s",
"--show-references",
"num_refs",
default=0,
callback=check_positive_int,
help="Shows the references of the X most common objects.\n\
This is a costly operation, do not use a large number.",
)
@click.option(
"-ss",
"--show-specific-references",
"specific_refs",
multiple=True,
default=[],
help="Shows the references of all objects given.\n\
This is a costly operation, be careful.",
)
@click.option(
"--snapshot",
type=click.Path(exists=True),
help="The file containing snapshot information of previous run.",
)
@click.option(
"-q",
"--quiet",
"quiet",
is_flag=True,
default=False,
help="Don't enter UI after evaluation.",
)
@click.option(
"-d",
"--debug",
"debug",
is_flag=True,
default=False,
help="Show GDB output, for debugging the analyzer.",
)
@click.option("-f", "--output-file", type=str, help="File to output results to.")
@click.option(
"--no-upload",
is_flag=True,
default=False,
help="Do not upload reference graphs to phabricator.",
)
@click.option(
"-e",
"--exec",
"executable",
help="Python executable to use",
default=f"{sys.executable}-dbg",
)
def run(
pids,
num_refs,
specific_refs,
snapshot,
quiet,
debug,
output_file,
no_upload,
executable,
):
"""
Tool for providing memory analysis on a running Python3 process.
Argument:
PIDS: The pid or list of pids of the running Python 3 process(es) to evaluate.
Output:
A binary file of the results. By default, after run completes the user
will enter a UI for navigating the data. If references are set, png
files will also be created and uploaded to phabricator.
Unless otherwise set, the output files will reside in memory_analyzer_out/,
which is created where the service is ran.
"""
runtime = "{:%Y%m%d%H%M%S}".format(datetime.now())
default_filename = f"memory_analyzer_out/memory_analyzer_snapshot-{runtime}"
if not output_file:
output_file = default_filename
references = num_refs > 0 or specific_refs
retrieved_objs = []
# Create a folder for output
if references or output_file == default_filename:
os.makedirs(os.path.dirname(default_filename), exist_ok=True)
template_out_path = tempfile.mkdtemp()
# Make a new thread per PID
worker_pool = ThreadPool(len(pids))
target = partial(
analyze_memory_launcher,
num_refs=num_refs,
specific_refs=specific_refs,
debug=debug,
output_file=output_file,
executable=executable,
template_out_path=template_out_path,
)
for result in worker_pool.imap_unordered(target, pids):
if result.data is None:
frontend_utils.echo_error(
f"{result.title} returned no data! Try rerunning with --debug"
)
else:
retrieved_objs.append(result)
if not retrieved_objs:
frontend_utils.echo_error("No results to report")
sys.exit(1)
if snapshot:
diffs = analysis_utils.snapshot_diff(retrieved_objs, snapshot)
retrieved_objs.extend(diffs)
frontend_utils.echo_info(f"Writing output to file {output_file}")
write_to_output_file(output_file, retrieved_objs)
if not quiet:
frontend_utils.echo_info("Initializing frontend...")
frontend_utils.initiate_curses(retrieved_objs)
if __name__ == "__main__":
cli()