diff --git a/doc/README.md b/doc/README.md index 5e0af1e3f90895ee5cdbc2927ccedf8970053c32..cd9b7b68846b9c164978a10f8e8ea9bc9d846ab1 100644 --- a/doc/README.md +++ b/doc/README.md @@ -6,7 +6,7 @@ We use Sphinx for generating the API and reference documentation. Install the following Python packages needed to build the documentation by entering: ```bash -pip install sphinx sphinx-autodoc-typehints sphinx-rtd-theme +pip install -r requirements.txt ``` To build the HTML documentation, enter:: diff --git a/doc/conf.py b/doc/conf.py index ad8cee166b4df9acec13bca5dd73eccd4db31a9e..8fe20cee8424b1d12a07e7426885920c3dad13da 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -1,14 +1,13 @@ from datetime import date -import sphinx_rtd_theme # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # -import os import sys - -sys.path.insert(0, os.path.abspath("..")) +from pathlib import Path +this_folder = Path(__file__).parent +sys.path.insert(0, (this_folder / "..").absolute().as_posix()) # General configuration # --------------------- @@ -18,16 +17,15 @@ sys.path.insert(0, os.path.abspath("..")) extensions = [ "sphinx.ext.autosummary", "sphinx.ext.autodoc", - "sphinx_autodoc_typehints", "sphinx.ext.coverage", "sphinx.ext.doctest", "sphinx.ext.intersphinx", "sphinx.ext.mathjax", "sphinx.ext.todo", "sphinx.ext.viewcode", - "numpydoc", ] -always_document_param_types = True + +autodoc_typehints = "description" # generate autosummary pages autosummary_generate = True @@ -48,48 +46,27 @@ master_doc = "index" project = "PredTuner" copyright = f"2020-{date.today().year}, University of Illinois" -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -# today = '' -# Else, today_fmt is used as the format for a strftime call. -# today_fmt = '%B %d, %Y' - -# List of documents that shouldn't be included in the build. -# unused_docs = [''] - -# If true, '()' will be appended to :func: etc. cross-reference text. -# add_function_parentheses = True - # If true, the current module name will be prepended to all description # unit titles (such as .. function::). add_module_names = False -# show_authors = True - # The name of the Pygments (syntax highlighting) style to use. # pygments_style = 'friendly' pygments_style = "sphinx" # A list of prefixs that are ignored when creating the module index. (new in Sphinx 0.6) -# modindex_common_prefix = ["networkx."] - -# doctest_global_setup = "import networkx as nx" +# modindex_common_prefix = [] # Options for HTML output # ----------------------- - -html_theme = "sphinx_rtd_theme" -html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] - +html_theme = "pydata_sphinx_theme" html_theme_options = { - "canonical_url": "https://networkx.org/documentation/stable/", - "navigation_depth": 3, - "logo_only": True, + # "github_url": "https://gitlab.engr.illinois.edu/llvm/hpvm-beta", + "show_prev_next": False, + "search_bar_position": "sidebar", } -# html_logo = "_static/networkx_logo.svg" - # The style sheet to use for HTML and HTML Help pages. A file of that name # must exist either in Sphinx' static/ path, or in one of the custom paths # given in html_static_path. @@ -98,26 +75,12 @@ html_theme_options = { # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] +html_static_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. html_last_updated_fmt = "%b %d, %Y" -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -# html_use_smartypants = True - -# Content template for the index page. -# html_index = 'index.html' - -# Custom sidebar templates, maps page names to templates. -# html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# templates. -# html_additional_pages = {'': ''} - # If true, the reST sources are included in the HTML build as _sources/<name>. html_copy_source = False @@ -129,9 +92,6 @@ latex_engine = "xelatex" # The paper size ('letter' or 'a4'). latex_paper_size = "letter" -# The font size ('10pt', '11pt' or '12pt'). -# latex_font_size = '10pt' - latex_appendices = ["tutorial"] # Intersphinx mapping @@ -147,10 +107,3 @@ intersphinx_mapping = { # The reST default role (used for this markup: `text`) to use for all # documents. default_role = "obj" - -numpydoc_show_class_members = False - - -def setup(app): - app.add_css_file("custom.css") - app.add_js_file("copybutton.js") diff --git a/doc/reference/approx-app.rst b/doc/reference/approx-app.rst new file mode 100644 index 0000000000000000000000000000000000000000..20d6e321b7526cad7062c90687073b33d569e76e --- /dev/null +++ b/doc/reference/approx-app.rst @@ -0,0 +1,14 @@ +General Application Autotuning API +================================== + +.. autoclass:: predtuner.approxapp.ApproxApp + :members: + +.. autoclass:: predtuner.approxapp.ApproxTuner + :members: + +.. autoclass:: predtuner.approxapp.ApproxKnob + :members: + +.. autoclass:: predtuner.approxapp.Config + :members: diff --git a/doc/reference/index.rst b/doc/reference/index.rst index 2a3a0a68b70eb678e956900f9a7291e11bc8fbe6..e94a2e79da525f24a8fe700a6efb140e963ec8d8 100644 --- a/doc/reference/index.rst +++ b/doc/reference/index.rst @@ -1,11 +1,15 @@ -PyTorch Autotuning API -====================== +PredTuner Autotuning API +======================== -.. autoclass:: predtuner.torchapp.TorchApp - :members: - :undoc-members: +:doc:`pytorch-app` documents a high-level API for autotuning PyTorch Module. -.. autoclass:: predtuner.modeledapp.ApproxModeledTuner - :members: - :inherited-members: - :undoc-members: +`predtuner` also supports predictive tuning of general applications that are not PyTorch Module, +or even empirical tuning of general application that doesn't support predictive models. +These lower-level APIs are documented in :doc:`modeled-app` and :doc:`approx-app` respectively. + +.. toctree:: + :maxdepth: 1 + + pytorch-app + modeled-app + approx-app diff --git a/doc/reference/modeled-app.rst b/doc/reference/modeled-app.rst new file mode 100644 index 0000000000000000000000000000000000000000..458ffc38bb158559b2e75d0deacdec8e61effe19 --- /dev/null +++ b/doc/reference/modeled-app.rst @@ -0,0 +1,41 @@ +Predictive (Modeled) Autotuning API +=================================== + +.. autoclass:: predtuner.modeledapp.ModeledApp + :show-inheritance: + :members: + +.. autoclass:: predtuner.modeledapp.ApproxModeledTuner + :show-inheritance: + :members: + +.. autoclass:: predtuner.modeledapp.ValConfig + :show-inheritance: + :members: + +Predictive Model Interface +---------------------------- + +.. autoclass:: predtuner.modeledapp.IQoSModel + :members: + +.. autoclass:: predtuner.modeledapp.ICostModel + :members: + +Predefined Predictive Models +---------------------------- + +Below is a list of cost and QoS models already defined: + +* `predtuner.modeledapp.LinearCostModel` +* `predtuner.modeledapp.QoSModelP1` +* `predtuner.modeledapp.QoSModelP2` + +.. autoclass:: predtuner.modeledapp.LinearCostModel + :show-inheritance: + +.. autoclass:: predtuner.modeledapp.QoSModelP1 + :show-inheritance: + +.. autoclass:: predtuner.modeledapp.QoSModelP2 + :show-inheritance: diff --git a/doc/reference/pytorch-app.rst b/doc/reference/pytorch-app.rst new file mode 100644 index 0000000000000000000000000000000000000000..4bd7e3d8f24d12c9fe53b54145c586d2d6a6f0e1 --- /dev/null +++ b/doc/reference/pytorch-app.rst @@ -0,0 +1,17 @@ +PyTorch Autotuning API +====================== + +.. autoclass:: predtuner.torchapp.TorchApp + :show-inheritance: + :members: get_tuner + +.. autofunction:: predtuner.approxes.get_knobs_from_file + +.. autofunction:: predtuner.torchutil.accuracy + +Defining New Approximation Knobs +-------------------------------- + +.. autoclass:: predtuner.torchapp.TorchApproxKnob + :show-inheritance: + :members: diff --git a/doc/requirements.txt b/doc/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..d4dc1a6039b99571b2800077062ae9236d39c710 --- /dev/null +++ b/doc/requirements.txt @@ -0,0 +1,3 @@ +sphinx>=3.5 +pydata-sphinx-theme==0.5.2 +numpydoc>=1.1 \ No newline at end of file diff --git a/predtuner/__init__.py b/predtuner/__init__.py index c11d18f205241f54b32405475e6e19d22d85538a..5edebe18433f6ace586253faa7af9b1b342521e4 100644 --- a/predtuner/__init__.py +++ b/predtuner/__init__.py @@ -2,9 +2,9 @@ from ._logging import config_pylogger from .approxapp import ApproxApp, ApproxKnob, ApproxTuner from .approxes import get_knobs_from_file from .modeledapp import ( - IPerfModel, + ICostModel, IQoSModel, - LinearPerfModel, + LinearCostModel, ModeledApp, QoSModelP1, QoSModelP2, diff --git a/predtuner/approxapp.py b/predtuner/approxapp.py index da12d72dd65fc45a6688416830214826314606d9..222d3cb5878f2f4c1624d6b41b43e4cc5c248758 100644 --- a/predtuner/approxapp.py +++ b/predtuner/approxapp.py @@ -27,6 +27,16 @@ TunerConfigT = Dict[int, int] class ApproxKnob: + r"""Basic definition of an approximation knob. + An approximation knob is an instance of a type of approximation; + for example, Perforated Convolution is a type of approximation, + while row-perforated convolution with stride 2 is a knob. + + :param name: The name of this approximation knob. Must be unique throughout. + :param devices: The devices this knob can be applied on. + Default is `None` which means all devices are supported. + """ + def __init__( self, name: str, devices: List[str] = None, baseline_priority: int = None ): @@ -35,6 +45,10 @@ class ApproxKnob: self.baseline_priority = baseline_priority def exists_on_device(self, device: str) -> bool: + """Returns True if this knob can be applied to an `ApproxApp` on device `device`. + + :param device: The device to check for. + """ if self.devices is None: return True return device in self.devices @@ -57,50 +71,94 @@ class ApproxKnob: class ApproxApp(abc.ABC): """Generic approximable application with operator & knob enumeration, - and measures its own QoS and performance given a configuration. - - Parameters - ---------- - op_knobs: - a mapping from each operator (identified by str) to a list of applicable knobs. + and measures its own QoS and cost given a configuration. + (A configuration is a dictionary from operator name to a knob name.) + To use this class, inherit from it and implement `name` and `measure_qos_cost`. + + :param op_knobs: a mapping from each operator (identified by str) to a list of applicable knobs. + :type op_knobs: Dict[str, List[ApproxKnob]] + :param target_device: the target device that this application should be tuned on. + Each knob has a number of devices it is supported on + (see `ApproxKnob.exists_on_device`) + and only knobs supported on `target_device` will be used for this application. + :type target_device: Optional[str] + + :var baseline_knob: The baseline knob of this application. + This is derived by looking at all knobs defined in `op_knobs` + and deciding which is the baseline. """ def __init__( - self, op_knobs: Dict[str, List[ApproxKnob]], tuning_device: str = None + self, op_knobs: Dict[str, List[ApproxKnob]], target_device: Optional[str] = None ) -> None: super().__init__() self.op_knobs = op_knobs - if tuning_device: - self.op_knobs = self._filter_knob_by_device(self.op_knobs, tuning_device) + if target_device: + self.op_knobs = self._filter_knob_by_device(self.op_knobs, target_device) # Also modifies self.op_knobs in place. self.baseline_knob = self._check_get_baseline_knob_(self.op_knobs) - @abc.abstractmethod - def measure_qos_cost( - self, with_approxes: KnobsT, is_test: bool - ) -> Tuple[float, float]: - pass + @property + def ops(self) -> List[str]: + """A list of operators in this application. + + :rtype: List[str] + """ + return list(self.op_knobs) + + @property + def knobs(self) -> List[ApproxKnob]: + """A list of all unique knobs (see `ApproxKnob`) + applicable to operators in this application. + + :rtype: List[ApproxKnob] + """ + knob_sets = [set(knobs) for knobs in self.op_knobs.values()] + return list(set.union(*knob_sets)) def get_tuner(self) -> "ApproxTuner": - """We implement this function. Sets up an ApproxTuner instance - which the user can directly call `tune()` on with opentuner parameters.""" + """Sets up an ApproxTuner instance which the user can directly call + `tune()` on with opentuner parameters.""" return ApproxTuner(self) @property @abc.abstractmethod def name(self) -> str: - """Name of application. Acts as an identifier in many places, so - the user should try to make it unique.""" + """The name of this application. + Acts as an identifier in many places, so the user should try to make it unique. + + :rtype: str + """ return "" - @property - def ops(self) -> List[str]: - return list(self.op_knobs) + @abc.abstractmethod + def measure_qos_cost( + self, with_approxes: KnobsT, is_test: bool + ) -> Tuple[float, float]: + """Measures the QoS and cost (time, energy, ...) of a given configuration. - @property - def knobs(self) -> List[ApproxKnob]: - knob_sets = [set(knobs) for knobs in self.op_knobs.values()] - return list(set.union(*knob_sets)) + :param with_approxes: The approximation configuration to measure QoS and cost for. + :param is_test: If True, uses a "test" dataset/mode that is held away from the tuner + during tuning; otherwise use "tune" dataset. + How the "tune" and "test" mode behave is up to the user to define. + """ + pass + + def add_baseline_to_knobs(self, approxes: KnobsT) -> KnobsT: + """For each operator not appearing in the keys of configuration `approxes` + (a dictionary), map it to the baseline (see `ApproxApp.baseline_knob`). + + `measure_qos_cost` should call this on the incoming config + if you wish to be able to abbreviate the configuration + (for example, you can write `measure_qos_cost({})` to get the baseline QoS). + This ensures all operators are present when the config is sent to tuner. + + :param approxes: the config to add baseline knobs to. + """ + return { + op_name: approxes.get(op_name, self.baseline_knob.name) + for op_name in self.ops + } @staticmethod def _check_get_baseline_knob_( @@ -124,14 +182,19 @@ class ApproxApp(abc.ABC): for op, knobs in op_knobs.items() } - def add_baseline_to_knobs(self, approxes: KnobsT): - return { - op_name: approxes.get(op_name, self.baseline_knob.name) - for op_name in self.ops - } - class Config: + """An approximation configuration with its measurement results, including QoS and cost. + + :param qos: The QoS of this config (measured on tuning mode, see `ApproxApp.measure_qos_cost`). + :param cost: The *relative* cost (time, energy, etc.) of this config + compared to the baseline config. This is essentially :math:`1 / speedup`. + :param knobs: The op-knob mapping in this configuration. + :param test_qos: The QoS of this config on test mode (see `ApproxApp.measure_qos_cost`). + This is optional as it is filled in only after the config-testing phase + (which can be opt out of). See `ApproxTuner.tune`. + """ + def __init__( self, qos: float, cost: float, knobs: KnobsT, test_qos: Optional[float] = None ) -> None: @@ -148,10 +211,18 @@ class Config: T = TypeVar("T", bound=Config) -# IOpenTuner is generic over the type of the config +# ApproxTuner is generic over the type of the config # So that the user can use custom Config inherited from Config # (in which case they need to override `get_all_configs_from_db`). class ApproxTuner(Generic[T]): + """Supports tuning and holds all tuning results. + `ApproxTuner.tune` is the main method for tuning. + + An instance of `ApproxTuner` can be obtained from `ApproxApp.get_tuner`. + + :param app: the application to tune. + """ + def __init__(self, app: ApproxApp) -> None: self.app = app self._tuned = False @@ -165,6 +236,7 @@ class ApproxTuner(Generic[T]): @property def tuned(self) -> bool: + """Returns True if `tune` has been called at least once.""" return self._tuned def tune( @@ -178,6 +250,30 @@ class ApproxTuner(Generic[T]): app_kwargs: dict = None # TODO: more parameters + opentuner param forwarding ) -> List[T]: + """Runs a tuning session. + + :param max_iter: Number of iterations to use in tuning. + :param qos_tuner_threshold: The QoS threshold that the tuner should aim for. + QoS is assumed to be a higher-better quantity. + This should be slightly tighter than `qos_keep_threshold` + to account for extra error when running on test dataset. + :param qos_keep_threshold: The QoS threshold beyond which we will keep the configuration. + By default it is equal to `qos_keep_threshold`. + :param is_threshold_relative: If True, the actual thresholds are considered to be + ``baseline_qos - given_threshold``. + This applies to `qos_tuner_threshold` and `qos_keep_threshold`. + :param take_best_n: Take the best :math:`n` configurations after tuning. + "Best" is defined as the configurations closest to the pareto curve + of the QoS-cost tradeoff space. + If `take_best_n` is None, only the configurations strictly on the + pareto curve are taken. + :param test_configs: If True, runs the configs on the test dataset, + filter the taken configs by `qos_keep_threshold`, + and fill the `test_qos` field of `Config`. + :param app_kwargs: Additional arguments to pass to + `ApproxApp.measure_qos_cost` during tuning. + """ + from opentuner.tuningrunmain import TuningRunMain from ._dbloader import read_opentuner_db @@ -222,7 +318,7 @@ class ApproxTuner(Generic[T]): self.kept_configs = [ cfg for cfg in self.all_configs if cfg.qos > self.tune_keep_threshold ] - self.best_configs_prefilter = self.take_best_configs(self.kept_configs, take_best_n) + self.best_configs_prefilter = self._take_best_configs(self.kept_configs, take_best_n) msg_logger.info( "Tuning finished with %d configs in total, " "%d configs above keeping threshold, " @@ -232,43 +328,21 @@ class ApproxTuner(Generic[T]): len(self.best_configs_prefilter), ) if test_configs: - msg_logger.info("Calibrating configurations on test inputs") - self.best_configs = self.test_configs(self.best_configs_prefilter) + msg_logger.info("Running configurations on test inputs") + self.best_configs = self._test_configs(self.best_configs_prefilter) else: self.best_configs = self.best_configs_prefilter return self.best_configs - def test_configs(self, configs: List[Config]): - from copy import deepcopy - - from tqdm import tqdm - - assert self.test_keep_threshold is not None - if not configs: - return [] - ret_configs = [] - total_error = 0 - for cfg in tqdm(configs, leave=False): - cfg = deepcopy(cfg) - assert cfg.test_qos is None - cfg.test_qos, _ = self.app.measure_qos_cost(cfg.knobs, True) - msg_logger.debug(f"Calibration: {cfg.qos} (mean) -> {cfg.test_qos} (mean)") - total_error += abs(cfg.qos - cfg.test_qos) - if cfg.test_qos > self.test_keep_threshold: - ret_configs.append(cfg) - else: - msg_logger.debug("Config removed") - mean_err = total_error / len(configs) - msg_logger.info("QoS mean abs difference of calibration: %f", mean_err) - return ret_configs + def dump_configs(self, filepath: PathLike, best_only: bool = True): + """Writes configuration to a JSON file. - @staticmethod - def take_best_configs(configs: List[T], n: Optional[int] = None) -> List[T]: - points = np.array([(c.qos, c.speedup) for c in configs]) - taken_idx = is_pareto_efficient(points, take_n=n) - return [configs[i] for i in taken_idx] + :param filepath: The JSON file to write into. + :param best_only: If True, only writes the "best" configuration + (filtered after running on test dataset, if required). + Otherwise, writes all configurations within the given QoS threshold. + """ - def dump_configs(self, filepath: PathLike, best_only: bool = True): import os from jsonpickle import encode @@ -289,6 +363,16 @@ class ApproxTuner(Generic[T]): connect_best_points: bool = False, use_test_qos: bool = False, ) -> plt.Figure: + """Plots the QoS and speedup of configurations into a scatter plot. + + :param show_qos_loss: If True, uses the loss of QoS (compared to the baseline) + instead of the absolute QoS. + :param connect_best_points: If True, draw a line connecting all the "best" + configurations (otherwise just plot a scatter plot). + :param use_test_qos: If True, plots with the test set QoS (`Config.test_qos`); + otherwise plots the tuning QoS (`Config.qos`). + """ + if not self.tuned: raise RuntimeError( f"No tuning session has been run; call self.tune() first." @@ -320,6 +404,36 @@ class ApproxTuner(Generic[T]): ax.legend() return fig + @staticmethod + def _take_best_configs(configs: List[T], n: Optional[int] = None) -> List[T]: + points = np.array([(c.qos, c.speedup) for c in configs]) + taken_idx = is_pareto_efficient(points, take_n=n) + return [configs[i] for i in taken_idx] + + def _test_configs(self, configs: List[Config]): + from copy import deepcopy + + from tqdm import tqdm + + assert self.test_keep_threshold is not None + if not configs: + return [] + ret_configs = [] + total_error = 0 + for cfg in tqdm(configs, leave=False): + cfg = deepcopy(cfg) + assert cfg.test_qos is None + cfg.test_qos, _ = self.app.measure_qos_cost(cfg.knobs, True) + msg_logger.debug(f"Test dataset: {cfg.qos:.3f} -> {cfg.test_qos:.3f}") + total_error += abs(cfg.qos - cfg.test_qos) + if cfg.test_qos > self.test_keep_threshold: + ret_configs.append(cfg) + else: + msg_logger.debug("Config removed") + mean_err = total_error / len(configs) + msg_logger.debug("QoS changed by %f on test dataset (mean abs diff)", mean_err) + return ret_configs + def _get_tuner_interface( self, opentuner_args, @@ -339,6 +453,8 @@ class ApproxTuner(Generic[T]): # These are also abs thresholds self.tune_keep_threshold = self.baseline_tune_qos - keep_threshold self.test_keep_threshold = self.baseline_test_qos - keep_threshold + else: + self.tune_keep_threshold = self.test_keep_threshold = keep_threshold opentuner_args.test_limit = max_iter msg_logger.info( "Tuner QoS threshold: %f; keeping configurations with QoS >= %f (tune dataset)", @@ -411,7 +527,7 @@ class TunerInterface(MeasurementInterface): return manipulator def run(self, desired_result, input_, limit): - """Run a given configuration then return performance and accuracy.""" + """Run a given configuration then return cost and QoS.""" from opentuner.resultsdb.models import Result cfg = desired_result.configuration.data diff --git a/predtuner/approxes/approxes.py b/predtuner/approxes/approxes.py index 0125de74f71a8a57e0148682e528ac687450da4a..f81265231aae55a76594c0cd7adc7395a3723c4e 100644 --- a/predtuner/approxes/approxes.py +++ b/predtuner/approxes/approxes.py @@ -393,6 +393,21 @@ def get_knobs_from_file( filepath: PathLike = default_knob_file, extra_name_to_class: Dict[str, Type[TorchApproxKnob]] = None, ) -> Set[TorchApproxKnob]: + """get_knobs_from_file(filepath=default_knob_file, extra_name_to_class=None) + + Constructs and returns a set of `TorchApproxKnob` from a knob declaration file. + `default_knob_file` points to a file that is contained in the predtuner package, + so just calling ``get_knobs_from_file()`` should provide a set of predefined knobs already. + + :param filepath: the knob declaration file (JSON) to read from. + :param extra_name_to_class: a mapping from the name of the approximation to the + class (implementation) of the approximation. + If not given, only the builtin approximations will be considered + when parsing the declaration file. + :type extra_name_to_class: Dict[str, Type[TorchApproxKnob]] + :rtype: Set[TorchApproxKnob] + """ + import json extra_name_to_class = extra_name_to_class or {} diff --git a/predtuner/modeledapp.py b/predtuner/modeledapp.py index a52dd11beb33e3c1cbd2a2385c01b2906cc854f5..dba07d61c8decd116f56d5b6661f1e321f7d83a0 100644 --- a/predtuner/modeledapp.py +++ b/predtuner/modeledapp.py @@ -17,72 +17,102 @@ msg_logger = logging.getLogger(__name__) class ModeledApp(ApproxApp, abc.ABC): - """Approximable application that inherits at least 1 interface for performance/QoS modeling. + """Like `.approxapp.ApproxApp`, but uses a model for QoS/cost measurement. - It's invalid to inherit from this class without also implementing at least 1 interface - provided in this set of API; - for non-modeling application, inherit from `ApproxApp` instead. + To use this class, inherit from it and implement `get_models`, + `empirical_measure_qos_cost`, and `.approxapp.ApproxApp.name`. + (This class provides an implementation of `.approxapp.ApproxApp.measure_qos_cost`.) + + :param op_knobs: a mapping from each operator (identified by str) to a list of applicable knobs. + :type op_knobs: Dict[str, List[ApproxKnob]] + :param target_device: the target device that this application should be tuned on. + See `.approxapp.ApproxApp` constructor. + :type target_device: Optional[str] """ def __init__( - self, op_knobs: Dict[str, List[ApproxKnob]], tuning_device: str = None + self, op_knobs: Dict[str, List[ApproxKnob]], target_device: str = None ) -> None: - super().__init__(op_knobs, tuning_device) + super().__init__(op_knobs, target_device) models = self.get_models() self._name_to_model = {m.name: m for m in models} if len(self._name_to_model) != len(models): raise ValueError("Name conflict in models") self._cost_models = { - model.name: model for model in models if isinstance(model, IPerfModel) + model.name: model for model in models if isinstance(model, ICostModel) } self._qos_models = { model.name: model for model in models if isinstance(model, IQoSModel) } @abc.abstractmethod - def get_models(self) -> List[Union["IPerfModel", "IQoSModel"]]: - """Get QoS/Performance prediction models for this application.""" + def get_models(self) -> List[Union["ICostModel", "IQoSModel"]]: + """A list of QoS/Cost prediction models for this application. + + Cost models should inherit from `ICostModel` + while QoS models should inherit from `IQoSModel`. + + :rtype: List[Union[ICostModel, IQoSModel]] + """ pass + @abc.abstractmethod def empirical_measure_qos_cost( self, with_approxes: KnobsT, is_test: bool ) -> Tuple[float, float]: - """Measures QoS and performance by running the program with approximation. + """Empirically measures QoS and cost by actually + running the program with approximation (as opposed to using model). - An implementation is not necessary if empirical measurement is never intended. + :param with_approxes: The approximation configuration to measure QoS and cost for. + :param is_test: If True, uses a "test" dataset/mode that is held away from the tuner + during tuning. """ - raise NotImplementedError() def measure_qos_cost( self, with_approxes: KnobsT, is_test: bool, - qos_model: str = "none", - cost_model: str = "none", + qos_model: Optional[str] = None, + cost_model: Optional[str] = None, ) -> Tuple[float, float]: - """We provide this with the right qos and cost function. - - Empirical measurement will be called once if either `cost_model` or `qos_model` - is "none", otherwise only use model indicated by model name. + """Returns the QoS and cost (time, energy, ...) of a given configuration, + *potentially using models*. + + If either of `cost_model` or `qos_model` is None, + this will perform empirical measurement once to get the one that is not using a model. + Otherwise, no empirical measurement will be used. + + Note that when running on test set (``is_test == True``), no modeling is allowed + (this raises a `ValueError`). + + :param with_approxes: The approximation configuration to measure QoS and cost for. + :param is_test: If True, uses a "test" dataset/mode that is held away from the tuner + during tuning; otherwise use "tune" dataset. + :param qos_model: The QoS model to use in this measurement, keyed by model's name + (See `IQoSModel.name`). + :param cost_model: The Cost model to use in this measurement, keyed by model's name + (See `ICostModel.name`). """ # Testset measurement is always empirical if is_test: + if qos_model is not None or cost_model is not None: + raise ValueError("Test dataset measurement is always empirical") return self.empirical_measure_qos_cost(with_approxes, is_test) # Run empirical measurement once if either cost or qos needs it qos, cost = None, None - if qos_model == "none" or cost_model == "none": + if qos_model is None or cost_model is None: qos, cost = self.empirical_measure_qos_cost(with_approxes, is_test) # If we're asked to use some qos_model, overwrite `qos` value - # even if we already get it from empirical measure (i.e., even if cost_model == "none") - if qos_model != "none": + # even if we already get it from empirical measure (i.e., even if cost_model is None) + if qos_model is not None: if qos_model not in self._qos_models: raise ValueError( f'"{qos_model}" is an invalid value for qos_model ' f"(choose from {list(self._qos_models.keys())})" ) qos = self._qos_models[qos_model].measure_qos(with_approxes) - # Same goes for perf - if cost_model != "none": + # Same goes for cost + if cost_model is not None: if cost_model not in self._cost_models: raise ValueError( f'"{cost_model}" is an invalid value for cost_model ' @@ -93,14 +123,23 @@ class ModeledApp(ApproxApp, abc.ABC): return qos, cost def get_tuner(self) -> "ApproxModeledTuner": + """Sets up an ApproxTuner instance which the user can directly call + `tune()` on with opentuner parameters. + + This returns an `ApproxModeledTuner`, different from `.approxapp.ApproxApp.get_tuner` + which returns an `ApproxTuner`. + + :rtype: ApproxModeledTuner + """ + return ApproxModeledTuner(self) def init_model(self, model_name: str): self._name_to_model[model_name]._init() -class IPerfModel(abc.ABC): - """Abstract base class for models that provide performance prediction.""" +class ICostModel(abc.ABC): + """Abstract base class for models that provide cost prediction.""" def __init__(self) -> None: self._inited = False @@ -113,7 +152,10 @@ class IPerfModel(abc.ABC): @abc.abstractmethod def measure_cost(self, with_approxes: KnobsT) -> float: - """Predict the performance of application.""" + """Predict the cost of application. + + :param with_approxes: The configuration to predict cost for. + """ pass def _init(self): @@ -135,7 +177,10 @@ class IQoSModel(abc.ABC): @abc.abstractmethod def measure_qos(self, with_approxes: KnobsT) -> float: - """Predict the qos of application.""" + """Predict the QoS of application. + + :param with_approxes: The configuration to predict QoS for. + """ pass def _init(self): @@ -143,8 +188,16 @@ class IQoSModel(abc.ABC): self._inited = True -class LinearPerfModel(IPerfModel): - """Weighted linear performance predictor based on cost of each operator.""" +class LinearCostModel(ICostModel): + """Weighted linear cost predictor based on cost of each operator. + + This predictor compute a weighted sum over + the cost of each operator and the speedup of each knob on that operator. + + :param app: The `ModeledApp` to predict cost for. + :param op_costs: A mapping from operator name to its (baseline) cost. + :param knob_speedups: A mapping from knob name to its (expected) speedup. + """ def __init__( self, @@ -169,7 +222,6 @@ class LinearPerfModel(IPerfModel): return "cost_linear" def measure_cost(self, with_approxes: KnobsT) -> float: - """We implement this using a weighted linear performance model.""" with_approxes = self.app.add_baseline_to_knobs(with_approxes) return float( sum(self.cost_df.loc[layer, knob] for layer, knob in with_approxes.items()) @@ -179,13 +231,17 @@ class LinearPerfModel(IPerfModel): class QoSModelP1(IQoSModel): """QoS model `P1` in ApproxTuner. - tensor_output_getter: Run the tensor-based application with config `with_approxes` applied, - and return a single tensor result. + :param app: The `ModeledApp` to predict QoS for. + :param tensor_output_getter: A function that can run the + tensor-based application with a config and return a single tensor result. - Note that while we require the return value to be a PyTorch tensor, - user is free to implement this on non-PyTorch applications. + Note that here we require the return value to be a PyTorch tensor. - qos_metric: Compute a Quality of Service level from the tensor output of application + :param qos_metric: A function that compute a QoS level from the return value + of `tensor_output_getter`. + :param storage: A `pickle` file to store this model into, if the file doesn't exist, + or load the model from if the file exists. + If not given, the model will not be stored. """ def __init__( @@ -212,7 +268,6 @@ class QoSModelP1(IQoSModel): return "qos_p1" def measure_qos(self, with_approxes: KnobsT) -> float: - """Implementation of model.""" assert self.baseline_tensor is not None with_approxes = self.app.add_baseline_to_knobs(with_approxes) delta_sum = sum( @@ -260,7 +315,13 @@ class QoSModelP1(IQoSModel): class QoSModelP2(IQoSModel): - """QoS model `P2` in ApproxTuner.""" + """QoS model `P1` in ApproxTuner. + + :param app: The `ModeledApp` to predict QoS for. + :param storage: A JSON file to store this model into, if the file doesn't exist, + or load the model from if the file exists. + If not given, the model will not be stored. + """ def __init__(self, app: ModeledApp, storage: PathLike = None) -> None: super().__init__() @@ -344,6 +405,22 @@ class QoSModelP2(IQoSModel): class ValConfig(Config): + """An `.approxapp.Config` that also optionally stores the "validation QoS". + + Validation QoS is the empirically measured QoS in the "validation phase" + at the end of tuning (see `ApproxModeledTuner.tune`). + + :param qos: The maybe-predicted QoS of this config. + (If tuning is empirical then this is empirical, not predicted, QoS.) + This is in contrast to `Config.qos`, which is always empirically measured on tuning dataset. + :param cost: The *relative* cost (time, energy, etc.) of this config + compared to the baseline config. This is essentially :math:`1 / speedup`. + :param knobs: The op-knob mapping in this configuration. + :param test_qos: The empirically measured QoS of this config on test mode. + :param validated_qos: The empirically measured QoS of this config on tuning mode, + in the validation phase. See `ApproxModeledTuner.tune`. + """ + def __init__( self, qos: float, @@ -368,23 +445,49 @@ class ApproxModeledTuner(ApproxTuner): take_best_n: Optional[int] = None, test_configs: bool = True, validate_configs: Optional[bool] = None, - cost_model: str = "none", - qos_model: str = "none", + cost_model: Optional[str] = None, + qos_model: Optional[str] = None, ) -> List[ValConfig]: + """Runs a tuning session. + + :param max_iter: Number of iterations to use in tuning. + :param qos_tuner_threshold: The QoS threshold that the tuner should aim for. + QoS is assumed to be a higher-better quantity. + This should be slightly tighter than `qos_keep_threshold` + to account for extra error when running on test dataset. + :param qos_keep_threshold: The QoS threshold beyond which we will keep the configuration. + By default it is equal to `qos_keep_threshold`. + :param is_threshold_relative: If True, the actual thresholds are considered to be + ``baseline_qos - given_threshold``. + This applies to `qos_tuner_threshold` and `qos_keep_threshold`. + :param take_best_n: Take the best :math:`n` configurations after tuning. + "Best" is defined as the configurations closest to the pareto curve + of the QoS-cost tradeoff space. + If `take_best_n` is None, only the configurations strictly on the + pareto curve are taken. + :param test_configs: If True, runs the configs on the test dataset, + filter the taken configs by `qos_keep_threshold`, + and fill the `test_qos` field of `ValConfig`. + :param validate_configs: If True, runs a validation step that empirically measures + the QoS of configs, filter the taken configs by `qos_keep_threshold`, + and fill the `validated_qos` field of `ValConfig`. + :param cost_model: The cost model to use for this tuning session. + :param qos_model: The QoS model to use for this tuning session. + This and `cost_model` are relayed down the line to `ModeledApp.measure_qos_cost`. + """ + qos_desc = ( - "no model for qos" if qos_model == "none" else f'qos model "{qos_model}"' + "no model for qos" if qos_model is None else f'qos model "{qos_model}"' ) cost_desc = ( - "no model for performance" - if cost_model == "none" - else f'performance model "{cost_model}"' + "no model for cost" if cost_model is None else f'cost model "{cost_model}"' ) msg_logger.info("Starting tuning with %s and %s", qos_desc, cost_desc) - if qos_model != "none": + if qos_model is not None: msg_logger.info("Initializing qos model %s", qos_model) self.app.init_model(qos_model) - if cost_model != "none": - msg_logger.info("Initializing performance model %s", cost_model) + if cost_model is not None: + msg_logger.info("Initializing cost model %s", cost_model) self.app.init_model(cost_model) super().tune( max_iter=max_iter, @@ -395,7 +498,7 @@ class ApproxModeledTuner(ApproxTuner): test_configs=False, # Test configs below by ourselves app_kwargs={"cost_model": cost_model, "qos_model": qos_model}, ) - if validate_configs is None and qos_model != "none": + if validate_configs is None and qos_model is not None: msg_logger.info( 'Validating configurations due to using qos model "%s"', qos_model ) @@ -425,12 +528,11 @@ class ApproxModeledTuner(ApproxTuner): ret_configs = [] total_error = 0 for cfg in tqdm(configs, leave=False): - cfg = deepcopy(cfg) qos, _ = self.app.measure_qos_cost(cfg.knobs, test_mode) if test_mode: assert cfg.test_qos is None cfg.test_qos = qos - msg_logger.debug(f"Calibration: {cfg.qos} (mean) -> {qos} (mean)") + msg_logger.debug(f"Test: {cfg.qos} (mean) -> {qos} (mean)") else: assert cfg.validated_qos is None cfg.validated_qos = qos @@ -441,15 +543,16 @@ class ApproxModeledTuner(ApproxTuner): else: msg_logger.debug("Config removed") mean_err = total_error / len(configs) - if test_mode: - msg_logger.info("QoS mean abs difference of calibration: %f", mean_err) - else: - msg_logger.info("QoS mean abs difference of validation: %f", mean_err) + dataset_name = "test" if test_mode else "tune" + msg_logger.info("QoS changed by %f on %s dataset (mean abs diff)", mean_err, dataset_name) msg_logger.info("%d of %d configs remain", len(ret_configs), len(configs)) return ret_configs def plot_configs( - self, show_qos_loss: bool = False, connect_best_points: bool = False + self, + show_qos_loss: bool = False, + connect_best_points: bool = False, + use_test_qos: bool = False, ) -> plt.Figure: if not self.tuned: raise RuntimeError( @@ -461,7 +564,14 @@ class ApproxModeledTuner(ApproxTuner): val_qos_nones = [conf.validated_qos is None for conf in self.best_configs] if any(val_qos_nones): assert all(val_qos_nones) - return super().plot_configs(show_qos_loss, connect_best_points, False) + return super().plot_configs( + show_qos_loss, connect_best_points, use_test_qos + ) + + if use_test_qos: + raise ValueError( + "use_test_qos is not yet supported for plotting predictive tuning session." + ) def get_points(confs, validated): def qos_speedup(conf): diff --git a/predtuner/torchapp.py b/predtuner/torchapp.py index 00a0376da0f7b24db45a615c13f8d0c2c740040c..3e0678d0cc5d66969bce31926fda4fd1a67bf4fa 100644 --- a/predtuner/torchapp.py +++ b/predtuner/torchapp.py @@ -10,9 +10,9 @@ from torch.utils.data.dataloader import DataLoader from ._logging import PathLike from .approxapp import ApproxKnob, KnobsT from .modeledapp import ( - IPerfModel, + ICostModel, IQoSModel, - LinearPerfModel, + LinearCostModel, ModeledApp, QoSModelP1, QoSModelP2, @@ -34,15 +34,27 @@ class TorchApproxKnob(ApproxKnob): @property @abc.abstractmethod def expected_speedup(self) -> float: + """The speedup this knob is expected to provide. Used for cost prediction.""" pass @abc.abstractmethod def is_applicable(self, op: Module) -> bool: + """Returns True if this knob can be applied to this Module. + + :param op: the module to check availability for. + :type op: torch.nn.Module + :rtype: torch.nn.Module + """ pass @abc.abstractmethod def apply(self, op: Module) -> Module: - """Applies knob to `module` and returns an approximated `module`.""" + """Applies knob to a Module and returns an approximated Module. + + :param op: the module to apply approximation on. + :type op: torch.nn.Module + :rtype: torch.nn.Module + """ pass @@ -53,40 +65,35 @@ class TorchApp(ModeledApp, abc.ABC): r"""Adaptor for approximable PyTorch Modules with tensor output. A TorchApp stores the PyTorch Module, datasets for tuning and calibration, - set of available TorchApproxKnob each of which may be applied to some layer in the Module, + set of available `TorchApproxKnob` each of which may be applied to some layer in the Module, and the quality of service (QoS) metric of application (e.g., accuracy). - It provides empirical tuning and predictive tuning capability (see `TorchApp.tune()`), - - Parameters - ---------- - app_name: - Name of the application, which is used as an identifier in tuning sessions, etc. - module: - The PyTorch module to tune. - tune_dataloader: - A dataset to use as inputs to module during tuning. (PyTorch DataLoader is conceptually - an enumerable, batched dataset.) - test_dataloader: - A input dataset used for QoS testing (see `test_config` parameter of `ApproxModeledTuner.tune`). - knobs: - A set of knobs to be considered. Each knob has an `is_applicable()` method - which is used to determine which layer it can apply to. - tensor_to_qos: - QoS metric function which computes QoS from the module's output. - combine_qos: - A function to combine each batch's QoS into one value. - When QoS is accuracy this will most likely be `mean()` (which is the default). - device: - The device to store module and perform inference on. By default is "cuda" - if CUDA is available, otherwise "cpu". - model_storage_folder: - A folder to store the serialized QoS models into. - `QoSModelP1` will be serialized into `model_storage_folder / "p1.pkl"`, - and `QoSModelP2` into `model_storage_folder / "p2.json"`. - See `QoSModelP1` and `QoSModelP2` for details. - - Attributes - ---------- + It provides empirical tuning and predictive tuning capability, + automatically supporting `.modeledapp.LinearCostModel`, + `.modeledapp.QoSModelP1`, and `.modeledapp.QoSModelP2`. + + In contrast to `.approxapp.ApproxApp` and `.modeledapp.ModeledApp`, + there should be no need to inherit from `TorchApp` in most use cases. + + :param app_name: Name of the application, which is used as an identifier in tuning sessions, etc. + :param module: The PyTorch module to tune. + :param tune_dataloader: A `torch.utils.data.Dataset` dataset to use as inputs to module during tuning. + :param test_dataloader: A `torch.utils.data.Dataset` dataset used for QoS testing + (see `test_configs` parameter of `ApproxModeledTuner.tune`). + :param knobs: A set of `TorchApproxKnob` to be considered. Each knob has an `is_applicable()` method + which is used to determine which layer it can apply to. + `.approxes.get_knobs_from_file` returns a set of builtin knobs that will exactly fit here. + :param tensor_to_qos: QoS metric function which computes QoS from the module's output. + `.torchutil.accuracy` computes the classification accuracy which can be applied here. + :param combine_qos: A function to combine each batch's QoS into one value. + When QoS is Classification Accuracy, this will most likely be `numpy.mean` + (which is the default value). + :param target_device: The target device that this application should be tuned on. + :param torch_device: The PyTorch device where the model inference is run on. + This device should be able to run the implementations of the knobs + available for this app on `target_device`. + :param model_storage_folder: A folder to store the serialized QoS models into. + `QoSModelP1` will be serialized into ``model_storage_folder / "p1.pkl"``, + and `QoSModelP2` into ``model_storage_folder / "p2.json"``. """ def __init__( @@ -98,7 +105,7 @@ class TorchApp(ModeledApp, abc.ABC): knobs: Set[TorchApproxKnob], tensor_to_qos: Callable[[torch.Tensor, Any], float], combine_qos: Callable[[np.ndarray], float] = np.mean, - tuning_device: str = None, + target_device: str = None, torch_device: Union[torch.device, str] = _default_device, model_storage_folder: Optional[PathLike] = None, ) -> None: @@ -107,7 +114,7 @@ class TorchApp(ModeledApp, abc.ABC): self.tune_loader = tune_dataloader self.test_loader = test_dataloader self.name_to_knob = { - k.name: k for k in self._check_and_filter_knob(knobs, tuning_device) + k.name: k for k in self._check_and_filter_knob(knobs, target_device) } self.tensor_to_qos = tensor_to_qos self.combine_qos = combine_qos @@ -132,17 +139,17 @@ class TorchApp(ModeledApp, abc.ABC): self._op_costs[op_name] = summary.loc[op_name, "flops"] # Init parent class last - super().__init__(op_knobs, tuning_device) + super().__init__(op_knobs, target_device) @property def name(self) -> str: """Returns the name of application.""" return self.app_name - def get_models(self) -> List[Union[IPerfModel, IQoSModel]]: + def get_models(self) -> List[Union[ICostModel, IQoSModel]]: """Returns a list of predictive tuning models. - TorchApp in particular derives 1 performance model (LinearPerfModel) + TorchApp in particular derives 1 performance model (LinearCostModel) and 2 QoS models (QoSModelP1, QoSModelP2) automatically. """ @@ -162,7 +169,7 @@ class TorchApp(ModeledApp, abc.ABC): p1_storage = self.model_storage / "p1.pkl" if self.model_storage else None p2_storage = self.model_storage / "p2.json" if self.model_storage else None return [ - LinearPerfModel(self, self._op_costs, self._knob_speedups), + LinearCostModel(self, self._op_costs, self._knob_speedups), QoSModelP1( self, self._get_raw_output_valset, batched_valset_qos, p1_storage ), diff --git a/predtuner/torchutil/common_qos.py b/predtuner/torchutil/common_qos.py index a6a19f6625453bcd381cdd392f1c8a0206c206f8..b9d8dbf5a0945f274a1fa4b50724fcde5887d45a 100644 --- a/predtuner/torchutil/common_qos.py +++ b/predtuner/torchutil/common_qos.py @@ -2,6 +2,12 @@ from torch import Tensor def accuracy(output: Tensor, target: Tensor) -> float: + """The "classification accuracy" metric (return value is between 0 and 100). + + :param output: Probability distribution output from the model. + :param target: A 1d-tensor of labels, one for each input image, from the dataset. + """ + _, pred_labels = output.max(1) n_correct = (pred_labels == target).sum().item() return n_correct / len(output) * 100 diff --git a/test/test_torchapp.py b/test/test_torchapp.py index f43fdb45e5b574c00cdd748fe506ee50e2c1a5b9..e4b7359fbd213635088559c966cacd634785d8e9 100644 --- a/test/test_torchapp.py +++ b/test/test_torchapp.py @@ -41,7 +41,7 @@ class TestTorchAppTuning(TorchAppSetUp): self.assertEqual(self.app.baseline_knob.name, "11") def test_cpu_knobs(self): - app = TorchApp(**self.app_args, tuning_device="cpu") + app = TorchApp(**self.app_args, target_device="cpu") n_knobs = {op: len(ks) for op, ks in app.op_knobs.items()} for op_name, op in app.midx.name_to_module.items(): nknob = 28 if isinstance(op, Conv2d) else 1 @@ -49,7 +49,7 @@ class TestTorchAppTuning(TorchAppSetUp): self.assertEqual(app.baseline_knob.name, "11") def test_gpu_knobs(self): - app = TorchApp(**self.app_args, tuning_device="gpu") + app = TorchApp(**self.app_args, target_device="gpu") n_knobs = {op: len(ks) for op, ks in app.op_knobs.items()} for op_name, op in app.midx.name_to_module.items(): nknob = 28 if isinstance(op, Conv2d) else 1