#!/usr/bin/env python3
# Copyright 2021 The IREE Authors
#
# Licensed under the Apache License v2.0 with LLVM Exceptions.
# See https://llvm.org/LICENSE.txt for license information.
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
"""Runs all matched benchmark suites on an Android device.

This script probes the Android phone via `adb` and uses the device information
to filter and run suitable benchmarks and optionally captures Tracy traces on
the Android phone.

It expects that `adb` is installed, and there is iree tools cross-compiled
towards Android. If to capture traces, another set of tracing-enabled iree
tools and the Tracy `capture` tool should be cross-compiled towards Android.

Example usages:

  # Without trace generation
  python3 run_benchmarks.py \
    --normal_benchmark_tool_dir=/path/to/normal/android/target/tools/dir \
    /path/to/host/build/dir

  # With trace generation
  python3 run_benchmarks.py \
    --normal_benchmark_tool_dir=/path/to/normal/android/target/tools/dir \
    --traced_benchmark_tool_dir=/path/to/tracy/android/target/tools/dir \
    --trace_capture_tool=/path/to/host/build/tracy/capture \
    /path/to/host/build/dir
"""

import sys
import pathlib

# Add build_tools python dir to the search path.
sys.path.insert(0, str(pathlib.Path(__file__).parent.with_name("python")))

import atexit
import json
import shutil
import subprocess
import tarfile
from typing import Any, List, Optional, Sequence, Tuple

from common.benchmark_config import BenchmarkConfig
from common.benchmark_driver import BenchmarkDriver
from common.benchmark_definition import (
    DriverInfo,
    execute_cmd,
    execute_cmd_and_get_stdout,
    execute_cmd_and_get_output,
    get_git_commit_hash,
    get_iree_benchmark_module_arguments,
    wait_for_iree_benchmark_module_start,
    parse_iree_benchmark_metrics,
)
from common.benchmark_suite import MODEL_FLAGFILE_NAME, BenchmarkCase, BenchmarkSuite
from common.android_device_utils import (
    get_android_device_model,
    get_android_device_info,
    get_android_gpu_name,
)
import common.common_arguments
from e2e_test_artifacts import iree_artifacts
from e2e_test_framework import serialization
from e2e_test_framework.definitions import common_definitions, iree_definitions
from e2e_test_framework.device_specs import device_parameters

# Root directory to perform benchmarks in on the Android device.
ANDROID_TMPDIR = pathlib.PurePosixPath("/data/local/tmp/iree-benchmarks")

NORMAL_TOOL_REL_DIR = pathlib.PurePosixPath("normal-tools")
TRACED_TOOL_REL_DIR = pathlib.PurePosixPath("traced-tools")


def adb_push_to_tmp_dir(
    content: pathlib.Path,
    relative_dir: pathlib.PurePosixPath = pathlib.PurePosixPath(),
    verbose: bool = False,
) -> pathlib.PurePosixPath:
    """Pushes content onto the Android device.

    Args:
      content: the full path to the source file.
      relative_dir: the directory to push to; relative to ANDROID_TMPDIR.

    Returns:
      The full path to the content on the Android device.
    """
    filename = content.name
    android_path = ANDROID_TMPDIR / relative_dir / filename
    # When the output is a TTY, keep the default progress info output.
    # In other cases, redirect progress info to null to avoid bloating log files.
    stdout_redirect = None if sys.stdout.isatty() else subprocess.DEVNULL
    execute_cmd(
        ["adb", "push", content.resolve(), android_path],
        verbose=verbose,
        stdout=stdout_redirect,
    )
    return android_path


def adb_execute_and_get_output(
    cmd_args: Sequence[str],
    relative_dir: pathlib.PurePosixPath = pathlib.PurePosixPath(),
    verbose: bool = False,
) -> Tuple[str, str]:
    """Executes command with adb shell.

    Switches to `relative_dir` relative to the android tmp directory before
    executing. Waits for completion and returns the command stdout.

    Args:
      cmd_args: a list containing the command to execute and its parameters
      relative_dir: the directory to execute the command in; relative to
        ANDROID_TMPDIR.

    Returns:
      Strings for stdout and stderr.
    """
    cmd = ["adb", "shell", "cd", ANDROID_TMPDIR / relative_dir, "&&"]
    cmd.extend(cmd_args)
    return execute_cmd_and_get_output(cmd, verbose=verbose)


def adb_execute(
    cmd_args: Sequence[str],
    relative_dir: pathlib.PurePosixPath = pathlib.PurePosixPath(),
    verbose: bool = False,
) -> subprocess.CompletedProcess:
    """Executes command with adb shell.

    Switches to `relative_dir` relative to the android tmp directory before
    executing. Waits for completion. Output is streamed to the terminal.

    Args:
      cmd_args: a list containing the command to execute and its parameters
      relative_dir: the directory to execute the command in; relative to
        ANDROID_TMPDIR.

    Returns:
      The completed process.
    """
    cmd = ["adb", "shell", "cd", ANDROID_TMPDIR / relative_dir, "&&"]
    cmd.extend(cmd_args)
    return execute_cmd(cmd, verbose=verbose)


def is_magisk_su():
    """Returns true if the Android device has a Magisk SU binary."""
    stdout, _ = adb_execute_and_get_output(["su", "--help"])
    return "MagiskSU" in stdout


def adb_execute_as_root(cmd_args: Sequence[Any]) -> subprocess.CompletedProcess:
    """Executes the given command as root."""
    cmd = ["su", "-c" if is_magisk_su() else "root"]
    cmd.extend(cmd_args)
    return adb_execute(cmd)


def adb_start_cmd(
    cmd_args: Sequence[str],
    relative_dir: pathlib.PurePosixPath = pathlib.PurePosixPath(),
    verbose: bool = False,
) -> subprocess.Popen:
    """Executes command with adb shell in a directory and returns the handle
    without waiting for completion.

    Args:
      cmd_args: a list containing the command to execute and its parameters
      relative_dir: the directory to execute the command in; relative to
        ANDROID_TMPDIR.

    Returns:
      A Popen object for the started command.
    """
    cmd = ["adb", "shell", "cd", ANDROID_TMPDIR / relative_dir, "&&"]
    cmd.extend(cmd_args)

    if verbose:
        print(f"cmd: {cmd}")
    return subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True)


def get_vmfb_full_path_for_benchmark_case(
    benchmark_case_dir: pathlib.Path,
) -> pathlib.Path:
    flagfile = benchmark_case_dir / MODEL_FLAGFILE_NAME
    for line in flagfile.read_text().splitlines():
        flag_name, flag_value = line.strip().split("=")
        if flag_name == "--module":
            # Realpath canonicalization matters. The caller may rely on that to track
            # which files it already pushed.
            return (benchmark_case_dir / flag_value).resolve()
    raise ValueError(f"{flagfile} does not contain a --module flag")


class AndroidBenchmarkDriver(BenchmarkDriver):
    """Android benchmark driver."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.already_pushed_files = {}

    def run_benchmark_case(
        self,
        benchmark_case: BenchmarkCase,
        benchmark_results_filename: Optional[pathlib.Path],
        capture_filename: Optional[pathlib.Path],
    ) -> None:
        benchmark_case_dir = benchmark_case.benchmark_case_dir
        android_case_dir = pathlib.PurePosixPath(
            benchmark_case_dir.relative_to(self.config.root_benchmark_dir)
        )

        run_config = benchmark_case.run_config
        self.__check_and_push_file(
            benchmark_case_dir / iree_artifacts.MODULE_FILENAME, android_case_dir
        )
        taskset = self.__deduce_taskset_from_run_config(run_config)
        run_args = run_config.materialize_run_flags()
        run_args.append(f"--module={iree_artifacts.MODULE_FILENAME}")

        if benchmark_results_filename is not None:
            self.__run_benchmark(
                android_case_dir=android_case_dir,
                tool_name=benchmark_case.benchmark_tool_name,
                driver_info=benchmark_case.driver_info,
                run_args=run_args,
                results_filename=benchmark_results_filename,
                taskset=taskset,
            )

        if capture_filename is not None:
            self.__run_capture(
                android_case_dir=android_case_dir,
                tool_name=benchmark_case.benchmark_tool_name,
                run_args=run_args,
                capture_filename=capture_filename,
                taskset=taskset,
            )

    def __run_benchmark(
        self,
        android_case_dir: pathlib.PurePosixPath,
        tool_name: str,
        driver_info: DriverInfo,
        run_args: Sequence[str],
        results_filename: pathlib.Path,
        taskset: str,
    ):
        if self.config.normal_benchmark_tool_dir is None:
            raise ValueError("normal_benchmark_tool_dir can't be None.")

        host_tool_path = self.config.normal_benchmark_tool_dir / tool_name
        android_tool = self.__check_and_push_file(host_tool_path, NORMAL_TOOL_REL_DIR)
        cmd = ["taskset", taskset, android_tool]
        cmd += run_args
        if tool_name == "iree-benchmark-module":
            cmd += get_iree_benchmark_module_arguments(
                results_filename=f"'{results_filename.name}'",
                driver_info=driver_info,
                benchmark_min_time=self.config.benchmark_min_time,
            )

        benchmark_stdout, benchmark_stderr = adb_execute_and_get_output(
            cmd, android_case_dir, verbose=self.verbose
        )
        benchmark_metrics = parse_iree_benchmark_metrics(
            benchmark_stdout, benchmark_stderr
        )
        if self.verbose:
            print(benchmark_metrics)
        results_filename.write_text(json.dumps(benchmark_metrics.to_json_object()))

    def __run_capture(
        self,
        android_case_dir: pathlib.PurePosixPath,
        tool_name: str,
        capture_filename: pathlib.Path,
        run_args: Sequence[str],
        taskset: str,
    ):
        capture_config = self.config.trace_capture_config
        if capture_config is None:
            raise ValueError("capture_config can't be None.")

        host_tool_path = capture_config.traced_benchmark_tool_dir / tool_name
        android_tool = self.__check_and_push_file(host_tool_path, TRACED_TOOL_REL_DIR)
        run_cmd = [
            "TRACY_NO_EXIT=1",
            f"IREE_PRESERVE_DYLIB_TEMP_FILES={ANDROID_TMPDIR}",
            "taskset",
            taskset,
            android_tool,
        ]
        run_cmd += run_args

        # Just launch the traced benchmark tool with TRACY_NO_EXIT=1 without
        # waiting for the adb command to complete as that won't happen.
        process = adb_start_cmd(run_cmd, android_case_dir, verbose=self.verbose)

        wait_for_iree_benchmark_module_start(process, self.verbose)

        # Now it's okay to collect the trace via the capture tool. This will
        # send the signal to let the previously waiting benchmark tool to
        # complete.
        capture_cmd = [capture_config.trace_capture_tool, "-f", "-o", capture_filename]
        # If verbose, just let the subprocess print its output. The subprocess
        # may need to detect if the output is a TTY to decide whether to log
        # verbose progress info and use ANSI colors, so it's better to use
        # stdout redirection than to capture the output in a string.
        stdout_redirect = None if self.verbose else subprocess.DEVNULL
        execute_cmd(capture_cmd, verbose=self.verbose, stdout=stdout_redirect)

    # TODO(#13187): These logics are inherited from the legacy benchmark suites,
    # which only work for a few specific phones. We should define the topology
    # in their device specs.
    def __deduce_taskset_from_run_config(
        self, run_config: iree_definitions.E2EModelRunConfig
    ) -> str:
        """Deduces the CPU mask according to device and execution config."""

        device_spec = run_config.target_device_spec
        # For GPU benchmarks, use the most performant core.
        if device_spec.architecture.type == common_definitions.ArchitectureType.GPU:
            return "80"

        device_params = device_spec.device_parameters
        single_thread = "1-thread" in run_config.module_execution_config.tags
        if device_parameters.ARM_BIG_CORES in device_params:
            return "80" if single_thread else "f0"
        elif device_parameters.ARM_LITTLE_CORES in device_params:
            return "08" if single_thread else "0f"

        raise ValueError(f"Unsupported config to deduce taskset: '{run_config}'.")

    def __check_and_push_file(
        self, host_path: pathlib.Path, relative_dir: pathlib.PurePosixPath
    ):
        """Checks if the file has been pushed and pushes it if not."""
        android_path = self.already_pushed_files.get(host_path)
        if android_path is not None:
            return android_path

        android_path = adb_push_to_tmp_dir(
            host_path, relative_dir=relative_dir, verbose=self.verbose
        )
        self.already_pushed_files[host_path] = android_path
        return android_path


def set_cpu_frequency_scaling_governor(governor: str):
    git_root = execute_cmd_and_get_stdout(["git", "rev-parse", "--show-toplevel"])
    cpu_script = (
        pathlib.Path(git_root)
        / "build_tools"
        / "benchmarks"
        / "set_android_scaling_governor.sh"
    )
    android_path = adb_push_to_tmp_dir(cpu_script)
    adb_execute_as_root([android_path, governor])


def set_gpu_frequency_scaling_policy(policy: str):
    git_root = execute_cmd_and_get_stdout(["git", "rev-parse", "--show-toplevel"])
    device_model = get_android_device_model()
    gpu_name = get_android_gpu_name()
    benchmarks_tool_dir = pathlib.Path(git_root) / "build_tools" / "benchmarks"
    if device_model == "Pixel-6" or device_model == "Pixel-6-Pro":
        gpu_script = benchmarks_tool_dir / "set_pixel6_gpu_scaling_policy.sh"
    elif gpu_name.lower().startswith("adreno"):
        gpu_script = benchmarks_tool_dir / "set_adreno_gpu_scaling_policy.sh"
    else:
        raise RuntimeError(
            f"Unsupported device '{device_model}' for setting GPU scaling policy"
        )
    android_path = adb_push_to_tmp_dir(gpu_script)
    adb_execute_as_root([android_path, policy])


def main(args):
    device_info = get_android_device_info(args.verbose)
    if args.verbose:
        print(device_info)

    commit = get_git_commit_hash("HEAD")
    benchmark_config = BenchmarkConfig.build_from_args(args, commit)
    benchmark_groups = json.loads(args.execution_benchmark_config.read_text())
    benchmark_group = benchmark_groups.get(args.target_device_name)
    if benchmark_group is None:
        raise ValueError("Target device not found in the benchmark config.")
    run_configs = serialization.unpack_and_deserialize(
        data=benchmark_group["run_configs"],
        root_type=List[iree_definitions.E2EModelRunConfig],
    )
    benchmark_suite = BenchmarkSuite.load_from_run_configs(
        run_configs=run_configs, root_benchmark_dir=benchmark_config.root_benchmark_dir
    )

    benchmark_driver = AndroidBenchmarkDriver(
        device_info=device_info,
        benchmark_config=benchmark_config,
        benchmark_suite=benchmark_suite,
        benchmark_grace_time=1.0,
        verbose=args.verbose,
    )

    if args.pin_cpu_freq:
        set_cpu_frequency_scaling_governor("performance")
        atexit.register(set_cpu_frequency_scaling_governor, "schedutil")
    if args.pin_gpu_freq:
        set_gpu_frequency_scaling_policy("performance")
        atexit.register(set_gpu_frequency_scaling_policy, "default")

    # Clear the benchmark directory on the Android device first just in case
    # there are leftovers from manual or failed runs.
    execute_cmd_and_get_stdout(
        ["adb", "shell", "rm", "-rf", ANDROID_TMPDIR], verbose=args.verbose
    )

    if not args.no_clean:
        # Clear the benchmark directory on the Android device.
        atexit.register(
            execute_cmd_and_get_stdout,
            ["adb", "shell", "rm", "-rf", ANDROID_TMPDIR],
            verbose=args.verbose,
        )
        # Also clear temporary directory on the host device.
        atexit.register(shutil.rmtree, args.tmp_dir)

    # Tracy client and server communicate over port 8086 by default. If we want
    # to capture traces along the way, forward port via adb.
    trace_capture_config = benchmark_config.trace_capture_config
    if trace_capture_config:
        execute_cmd_and_get_stdout(
            ["adb", "forward", "tcp:8086", "tcp:8086"], verbose=args.verbose
        )
        atexit.register(
            execute_cmd_and_get_stdout,
            ["adb", "forward", "--remove", "tcp:8086"],
            verbose=args.verbose,
        )

    benchmark_driver.run()

    benchmark_results = benchmark_driver.get_benchmark_results()
    if args.output is not None:
        with open(args.output, "w") as f:
            f.write(benchmark_results.to_json_str())

    if args.verbose:
        print(benchmark_results.commit)
        print(benchmark_results.benchmarks)

    if trace_capture_config:
        # Put all captures in a tarball and remove the original files.
        with tarfile.open(trace_capture_config.capture_tarball, "w:gz") as tar:
            for capture_filename in benchmark_driver.get_capture_filenames():
                tar.add(capture_filename)

    benchmark_errors = benchmark_driver.get_benchmark_errors()
    if benchmark_errors:
        print("Benchmarking completed with errors", file=sys.stderr)
        raise RuntimeError(benchmark_errors)


if __name__ == "__main__":
    main(common.common_arguments.Parser().parse_args())
