Browse Source

libpython: Support benchmarks of non-parallel runs better (#1733)

* Function for possibly non-parallel repeated runs for writing benchmark scripts.
* Better documentation of non-parallel runs in resolution-changing benchmark.
* CLI for joining JSON result files from multiple benchmarks and plotting from a file.
* CLI which is using argparse with subcommands (subparsers) is extensible and more can be added in the future.
Vaclav Petras 3 years ago
parent
commit
7b09143baf

+ 1 - 1
python/grass/benchmark/Makefile

@@ -5,7 +5,7 @@ include $(MODULE_TOPDIR)/include/Make/Python.make
 
 DSTDIR = $(ETC)/python/grass/benchmark
 
-MODULES = plots results runners
+MODULES = app plots results runners __main__
 
 PYFILES := $(patsubst %,$(DSTDIR)/%.py,$(MODULES) __init__)
 PYCFILES := $(patsubst %,$(DSTDIR)/%.pyc,$(MODULES) __init__)

+ 21 - 1
python/grass/benchmark/__init__.py

@@ -1,16 +1,36 @@
+# MODULE:    grass.benchmark
+#
+# AUTHOR(S): Vaclav Petras <wenzeslaus gmail com>
+#
+# PURPOSE:   Benchmarking for GRASS GIS modules
+#
+# COPYRIGHT: (C) 2021 Vaclav Petras, and by the GRASS Development Team
+#
+#            This program is free software under the GNU General Public
+#            License (>=v2). Read the file COPYING that comes with GRASS
+#            for details.
+
 """Benchmarking for GRASS GIS modules
 
 This subpackage of the grass package is experimental and the API can change anytime.
 The API of the package is defined by what is imported in the top-level ``__init__.py``
 file of the subpackage.
+
+The functions in the Python API raise exceptions, although calls of other functions from
+the grass package may call grass.script.fatal and exit
+(see :func:`grass.script.core.set_raise_on_error` for changing the behavior).
+This applies to the CLI interface of this subpackage too except that raised usage
+exceptions originating in the CLI code result in *sys.exit* with an error message, not
+traceback. Messages and other user-visible texts in this package are not translatable.
 """
 
 from .plots import nprocs_plot, num_cells_plot
 from .results import (
     join_results,
+    join_results_from_files,
     load_results,
     load_results_from_file,
     save_results,
     save_results_to_file,
 )
-from .runners import benchmark_nprocs, benchmark_resolutions
+from .runners import benchmark_nprocs, benchmark_resolutions, benchmark_single

+ 19 - 0
python/grass/benchmark/__main__.py

@@ -0,0 +1,19 @@
+# MODULE:    grass.benchmark
+#
+# AUTHOR(S): Vaclav Petras <wenzeslaus gmail com>
+#
+# PURPOSE:   Benchmarking for GRASS GIS modules
+#
+# COPYRIGHT: (C) 2021 Vaclav Petras, and by the GRASS Development Team
+#
+#            This program is free software under the GNU General Public
+#            License (>=v2). Read the file COPYING that comes with GRASS
+#            for details.
+
+
+"""The main file for executing using python -m"""
+
+from grass.benchmark.app import main
+
+if __name__ == "__main__":
+    main()

+ 181 - 0
python/grass/benchmark/app.py

@@ -0,0 +1,181 @@
+# MODULE:    grass.benchmark
+#
+# AUTHOR(S): Vaclav Petras <wenzeslaus gmail com>
+#
+# PURPOSE:   Benchmarking for GRASS GIS modules
+#
+# COPYRIGHT: (C) 2021 Vaclav Petras, and by the GRASS Development Team
+#
+#            This program is free software under the GNU General Public
+#            License (>=v2). Read the file COPYING that comes with GRASS
+#            for details.
+
+
+"""CLI for the benchmark package"""
+
+import argparse
+import sys
+from pathlib import Path
+
+from grass.benchmark import (
+    join_results_from_files,
+    load_results_from_file,
+    num_cells_plot,
+    save_results_to_file,
+)
+
+
+class CliUsageError(ValueError):
+    """Raised when error is in the command line arguments.
+
+    Used when the error is discovered only after argparse parsed the arguments.
+    """
+
+    # ArgumentError from argparse may work too, but it is not documented and
+    # takes a reference argument which we don't have access to after the parse step.
+    pass
+
+
+def join_results_cli(args):
+    """Translate CLI parser result to API calls."""
+    if args.prefixes and len(args.results) != len(args.prefixes):
+        raise CliUsageError(
+            f"Number of prefixes ({len(args.prefixes)}) needs to be the same"
+            f" as the number of input result files ({len(args.results)})"
+        )
+    results = join_results_from_files(
+        source_filenames=args.results,
+        prefixes=args.prefixes,
+    )
+    save_results_to_file(results, args.output)
+
+
+def plot_cells_cli(args):
+    """Translate CLI parser result to API calls."""
+    results = load_results_from_file(args.input)
+    num_cells_plot(
+        results.results,
+        filename=args.output,
+        title=args.title,
+        show_resolution=args.resolutions,
+    )
+
+
+def get_executable_name():
+    """Get name of the executable and module.
+
+    This is a workaround for Python issue:
+    argparse support for "python -m module" in help
+    https://bugs.python.org/issue22240
+    """
+    executable = Path(sys.executable).stem
+    return f"{executable} -m grass.benchmark"
+
+
+class ExtendAction(argparse.Action):
+    """Support for agrparse action="extend" before Python 3.8
+
+    Each parser instance needs the action to be registered.
+    """
+
+    # pylint: disable=too-few-public-methods
+    def __call__(self, parser, namespace, values, option_string=None):
+        items = getattr(namespace, self.dest) or []
+        items.extend(values)
+        setattr(namespace, self.dest, items)
+
+
+def add_subcommand_parser(subparsers, name, description):
+    """Add parser for a subcommand into subparsers."""
+    # help is in parent's help, description in subcommand's help.
+    return subparsers.add_parser(name, help=description, description=description)
+
+
+def add_subparsers(parser, dest):
+    """Add subparsers in a unified way.
+
+    Uses title 'subcommands' for the list of commands
+    (instead of the 'positional' which is the default).
+
+    The *dest* should be 'command', 'subcommand', etc. with appropriate nesting.
+    """
+    if sys.version_info < (3, 7):
+        # required as parameter is only in >=3.7.
+        return parser.add_subparsers(title="subcommands", dest=dest)
+    return parser.add_subparsers(title="subcommands", required=True, dest=dest)
+
+
+def add_results_subcommand(parent_subparsers):
+    """Add results subcommand."""
+    main_parser = add_subcommand_parser(
+        parent_subparsers, "results", description="Manipulate results"
+    )
+    main_subparsers = add_subparsers(main_parser, dest="subcommand")
+
+    join = main_subparsers.add_parser("join", help="Join results")
+    join.add_argument("results", help="Files with results", nargs="*", metavar="file")
+    join.add_argument("output", help="Output file", metavar="output_file")
+    if sys.version_info < (3, 8):
+        join.register("action", "extend", ExtendAction)
+    join.add_argument(
+        "--prefixes",
+        help="Add prefixes to result labels per file",
+        action="extend",
+        nargs="*",
+        metavar="text",
+    )
+    join.set_defaults(handler=join_results_cli)
+
+
+def add_plot_subcommand(parent_subparsers):
+    """Add plot subcommand."""
+    main_parser = add_subcommand_parser(
+        parent_subparsers, "plot", description="Plot results"
+    )
+    main_subparsers = add_subparsers(main_parser, dest="subcommand")
+
+    join = main_subparsers.add_parser("cells", help="Plot for variable number of cells")
+    join.add_argument("input", help="file with results (JSON)", metavar="input_file")
+    join.add_argument(
+        "output", help="output file (e.g., PNG)", nargs="?", metavar="output_file"
+    )
+    join.add_argument(
+        "--title",
+        help="Title for the plot",
+        metavar="text",
+    )
+    join.add_argument(
+        "--resolutions",
+        help="Use resolutions for x axis instead of cell count",
+        action="store_true",
+    )
+    join.set_defaults(handler=plot_cells_cli)
+
+
+def define_arguments():
+    """Define top level parser and create subparsers."""
+    parser = argparse.ArgumentParser(
+        description="Process results from module benchmarks.",
+        prog=get_executable_name(),
+    )
+    subparsers = add_subparsers(parser, dest="command")
+
+    add_results_subcommand(subparsers)
+    add_plot_subcommand(subparsers)
+
+    return parser
+
+
+def main(args=None):
+    """Define and parse command line parameters then run the appropriate handler."""
+    parser = define_arguments()
+    args = parser.parse_args(args)
+    try:
+        args.handler(args)
+    except CliUsageError as error:
+        # Report a usage error and exit.
+        sys.exit(f"ERROR: {error}")
+
+
+if __name__ == "__main__":
+    main()

+ 9 - 3
python/grass/benchmark/plots.py

@@ -40,7 +40,7 @@ def get_pyplot(to_file):
 def nprocs_plot(results, filename=None):
     """Plot results from a multiple nprocs (thread) benchmarks.
 
-    *results* is a list of individual results from separate benchmars.
+    *results* is a list of individual results from separate benchmarks.
     One result is required to have attributes: *nprocs*, *times*, *label*.
     The *nprocs* attribute is a list of all processing elements
     (cores, threads, processes) used in the benchmark.
@@ -76,10 +76,10 @@ def nprocs_plot(results, filename=None):
         plt.show()
 
 
-def num_cells_plot(results, filename=None, show_resolution=False):
+def num_cells_plot(results, filename=None, title=None, show_resolution=False):
     """Plot results from a multiple raster grid size benchmarks.
 
-    *results* is a list of individual results from separate benchmars
+    *results* is a list of individual results from separate benchmarks
     with one result being similar to the :func:`nprocs_plot` function.
     The result is required to have *times* and *label* attributes
     and may have an *all_times* attribute.
@@ -116,6 +116,12 @@ def num_cells_plot(results, filename=None, show_resolution=False):
     else:
         plt.xlabel("Number of cells")
     plt.ylabel("Time [s]")
+    if title:
+        plt.title(title)
+    elif show_resolution:
+        plt.title("Execution time by resolution")
+    else:
+        plt.title("Execution time by cell count")
     if filename:
         plt.savefig(filename)
     else:

+ 8 - 0
python/grass/benchmark/results.py

@@ -93,3 +93,11 @@ def join_results(results, prefixes=None):
                 result.label = f"{prefix}: {result.label}"
             joined.append(result)
     return joined
+
+
+def join_results_from_files(source_filenames, prefixes):
+    """Join multiple files into one results object."""
+    to_merge = []
+    for result_file in source_filenames:
+        to_merge.append(load_results_from_file(result_file))
+    return join_results(to_merge, prefixes=prefixes)

+ 61 - 6
python/grass/benchmark/runners.py

@@ -20,7 +20,55 @@ from types import SimpleNamespace
 import grass.script as gs
 
 
-def benchmark_nprocs(module, label, max_nprocs, repeat):
+def benchmark_single(module, label, repeat=5):
+    """Benchmark module as is without chaning anything.
+
+    *module* is an instance of PyGRASS Module class or any object which
+    has a *run* method which takes no arguments and executes the benchmarked code,
+    and attribute *time* which is set to execution time after the *run*
+    function returned. Additionally, the object should be convertible to *str*
+    for printing.
+
+    *repeat* sets how many times the each run is repeated.
+    *label* is a text to add to the result (for user-facing display).
+
+    Returns an object with attributes *time* (an average execution time),
+    *all_times* (list of measured execution times),
+    and *label* (the provided parameter as is).
+    """
+    term_size = shutil.get_terminal_size()
+    if hasattr(module, "get_bash"):
+        print(module.get_bash())
+    else:
+        print(module)
+
+    min_avg = float("inf")
+
+    print("\u2500" * term_size.columns)
+    time_sum = 0
+    measured_times = []
+    for _ in range(repeat):
+        module.run()
+        print(f"{module.time}s")
+        time_sum += module.time
+        measured_times.append(module.time)
+
+    avg = time_sum / repeat
+    if avg < min_avg:
+        min_avg = avg
+    print(f"\nResult - {avg}s")
+
+    print("\u2500" * term_size.columns)
+    print(f"Best average time - {min_avg}s\n")
+
+    return SimpleNamespace(
+        all_times=measured_times,
+        time=avg,
+        label=label,
+    )
+
+
+def benchmark_nprocs(module, label, max_nprocs, repeat=5):
     """Benchmark module using values of nprocs up to *max_nprocs*.
 
     *module* is an instance of PyGRASS Module class or any object which
@@ -30,7 +78,8 @@ def benchmark_nprocs(module, label, max_nprocs, repeat):
     function returned. Additionally, the object should be convertible to *str*
     for printing.
 
-    The module is executed  used to generate range of values from 1 up to *max_nprocs*.
+    The module is executed for each generated value of nprocs. *max_nprocs* is used
+    to generate a continuous range of integer values from 1 up to *max_nprocs*.
     *repeat* sets how many times the each run is repeated.
     So, the module will run ``max_nprocs * repeat`` times.
 
@@ -48,7 +97,8 @@ def benchmark_nprocs(module, label, max_nprocs, repeat):
         print(module)
 
     min_avg = float("inf")
-    min_time = 1
+    min_time = None
+    serial_avg = None
     avg_times = []
     all_times = []
     nprocs_list = list(range(1, max_nprocs + 1))
@@ -75,7 +125,8 @@ def benchmark_nprocs(module, label, max_nprocs, repeat):
         print(f"\nResult - {avg}s")
 
     print("\u2500" * term_size.columns)
-    print(f"\nSerial average time - {serial_avg}s")
+    if serial_avg is not None:
+        print(f"\nSerial average time - {serial_avg}s")
     print(f"Best average time - {min_avg}s ({min_time} threads)\n")
 
     return SimpleNamespace(
@@ -99,7 +150,8 @@ def benchmark_resolutions(module, resolutions, label, repeat=5, nprocs=None):
     So, the module will run ``len(resolutions) * repeat`` times.
 
     *label* is a text to add to the result (for user-facing display).
-    Optional *nprocs* is passed to the module if present.
+    Optional *nprocs* is passed to the module if present
+    (the called module does not have to support nprocs parameter).
 
     Returns an object with attributes *times* (list of average execution times),
     *all_times* (list of lists of measured execution times), *resolutions*
@@ -107,7 +159,10 @@ def benchmark_resolutions(module, resolutions, label, repeat=5, nprocs=None):
     and *label* (the provided parameter as is).
     """
     term_size = shutil.get_terminal_size()
-    print(module.get_bash())
+    if hasattr(module, "get_bash"):
+        print(module.get_bash())
+    else:
+        print(module)
 
     avg_times = []
     all_times = []

+ 22 - 0
python/grass/benchmark/testsuite/test_benchmark.py

@@ -18,6 +18,7 @@ from types import SimpleNamespace
 
 from grass.benchmark import (
     benchmark_resolutions,
+    benchmark_single,
     join_results,
     load_results,
     load_results_from_file,
@@ -60,6 +61,27 @@ class TestBenchmarksRun(TestCase):
         num_cells_plot(results, filename=plot_file)
         self.assertTrue(Path(plot_file).is_file())
 
+    def test_single(self):
+        """Test that single benchmark function runs"""
+        label = "Standard output"
+        repeat = 4
+        benchmarks = [
+            dict(
+                module=Module("r.univar", map="elevation", stdout_=DEVNULL, run_=False),
+                label=label,
+            )
+        ]
+        results = []
+        for benchmark in benchmarks:
+            results.append(benchmark_single(**benchmark, repeat=repeat))
+        self.assertEqual(len(results), len(benchmarks))
+        for result in results:
+            self.assertTrue(hasattr(result, "all_times"))
+            self.assertTrue(hasattr(result, "time"))
+            self.assertTrue(hasattr(result, "label"))
+            self.assertEqual(len(result.all_times), repeat)
+        self.assertEqual(results[0].label, label)
+
 
 class TestBenchmarkResults(TestCase):
     """Tests that saving results work"""

+ 52 - 0
python/grass/benchmark/testsuite/test_benchmark_cli.py

@@ -0,0 +1,52 @@
+# MODULE:    Test of grass.benchmark
+#
+# AUTHOR(S): Vaclav Petras <wenzeslaus gmail com>
+#
+# PURPOSE:   Benchmarking for GRASS GIS modules
+#
+# COPYRIGHT: (C) 2021 Vaclav Petras, and by the GRASS Development Team
+#
+#            This program is free software under the GNU General Public
+#            License (>=v2). Read the file COPYING that comes with GRASS
+#            for details.
+
+"""Tests of grass.benchmark CLI"""
+
+from pathlib import Path
+from subprocess import DEVNULL
+
+from grass.benchmark import benchmark_resolutions, save_results_to_file
+from grass.benchmark.app import main as benchmark_main
+from grass.gunittest.case import TestCase
+from grass.gunittest.main import test
+from grass.pygrass.modules import Module
+
+
+class TestBenchmarkCLI(TestCase):
+    """Tests that benchmarkin CLI works"""
+
+    def test_plot_workflow(self):
+        """Test that plot workflow runs"""
+        label = "Standard output"
+        repeat = 4
+        json_filename = "plot_test.json"
+        png_filename = "plot_test.png"
+        png_filename_resolutions = "plot_test_resolutions.png"
+        # The benchmark part has only Python API, not CLI.
+        result = benchmark_resolutions(
+            module=Module("r.univar", map="elevation", stdout_=DEVNULL, run_=False),
+            label=label,
+            repeat=repeat,
+            resolutions=[1000, 500],
+        )
+        save_results_to_file([result], json_filename)
+        benchmark_main(["plot", "cells", json_filename, png_filename])
+        self.assertTrue(Path(png_filename).is_file())
+        benchmark_main(
+            ["plot", "cells", "--resolutions", json_filename, png_filename_resolutions]
+        )
+        self.assertTrue(Path(png_filename_resolutions).is_file())
+
+
+if __name__ == "__main__":
+    test()