diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..2908971ae --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,21 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.10" + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + +# Specify dependencies to enable reproducible builds: +# https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +python: + install: + - requirements: docs/requirements.txt diff --git a/docs/conf.py b/docs/conf.py index 8d5c94012..fca81a2c4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,6 +35,7 @@ "sphinx.ext.todo", "sphinx.ext.coverage", "sphinx.ext.viewcode", + # "sphinx_search.extension", # Disable until search is improved. ] # Add any paths that contain templates here, relative to this directory. @@ -51,7 +52,7 @@ # General information about the project. project = "Lab" -copyright = "2011-2020 Jendrik Seipp et al." +copyright = "Jendrik Seipp et al." # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git a/docs/downward.tutorial.rst b/docs/downward.tutorial.rst index 23783faa8..39258aadc 100644 --- a/docs/downward.tutorial.rst +++ b/docs/downward.tutorial.rst @@ -123,15 +123,15 @@ Run tutorial experiment The files below are two experiment scripts, a ``project.py`` module that bundles common functionality for all experiments related to the project, a parser -script, and a script for collecting results and making reports. You can use the +module, and a script for collecting results and making reports. You can use the files as a basis for your own experiments. They are available in the `Lab repo `_. Copy the files into ``experiments/my-exp-dir``. .. highlight:: bash -Make sure the experiment script and the parser are executable. Then you -can see the available steps with :: +Make sure the experiment script is executable. Then you can see the available +steps with :: ./2020-09-11-A-cg-vs-ff.py @@ -171,7 +171,7 @@ reference on all Downward Lab classes. .. literalinclude:: ../examples/downward/project.py :caption: -.. literalinclude:: ../examples/downward/parser.py +.. literalinclude:: ../examples/downward/custom_parser.py :caption: .. literalinclude:: ../examples/downward/01-evaluation.py diff --git a/docs/faq.rst b/docs/faq.rst index 2051b7814..80e255acf 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -38,12 +38,43 @@ again as above. I forgot to parse something. How can I run only the parsers again? ------------------------------------------------------------------ -See the `parsing documentation `_ for how to write -parsers. Once you have fixed your existing parsers or added new parsers, -add ``exp.add_parse_again_step()`` to your experiment script -``my-exp.py`` and then call :: - - ./my-exp.py parse-again +Now that parsing is done in its own experiment step, simply consult the `parsing +documentation `_ for how to amend your parsers and then run the +"parse" experiment step again with :: + + ./my-exp.py parse + + +.. _portparsers: + +How do I port my parsers to version 8.x? +---------------------------------------- + +Since version 8.0, Lab has a dedicated "parse" experiment step. First of all, +what are the benefits of this? + +* No need to write parsers in separate files. +* Log output from solvers and parsers remains separate. +* No need for ``exp.add_parse_again_step()``. Parsing and re-parsing is now + exactly the same. +* Parsers are checked for syntax errors before the experiment is run. +* Parsing runs much faster (for an experiment with 3 algorithms and 5 parsers + the parsing time went down from 51 minutes to 5 minutes, both measured on + cold file system caches). +* As before, you can let the Slurm environment do the parsing for you and get + notified when the report is finished: ``./myexp.py build start parse fetch + report`` + +To adapt your parsers to this new API, you need to make the following changes: + +* Your parser module (e.g., "custom_parser.py") does not have to be executable + anymore, but it must be importable and expose a :class:`Parser + ` instance (see the changes to the `translator parser + `_ + for an example). Then, instead of ``exp.add_parser("custom_parser.py")`` use + ``from custom_parser import MyParser`` and ``exp.add_parser(MyParser())``. +* Remove ``exp.add_parse_again_step()`` and insert ``exp.add_step("parse", + exp.parse)`` after ``exp.add_step("start", exp.start_runs)``. How can I compute a new attribute from multiple runs? diff --git a/docs/ff.rst b/docs/ff.rst index 184cee3bf..ac6d0ea08 100644 --- a/docs/ff.rst +++ b/docs/ff.rst @@ -27,5 +27,5 @@ Downward experiments, we recommend taking a look at the Here is a simple parser for FF: -.. literalinclude:: ../examples/ff/ff-parser.py +.. literalinclude:: ../examples/ff/ff_parser.py :caption: diff --git a/docs/lab.concepts.rst b/docs/lab.concepts.rst index 570c75d26..b6ee67719 100644 --- a/docs/lab.concepts.rst +++ b/docs/lab.concepts.rst @@ -4,12 +4,13 @@ Concepts ======== An **experiment** consists of multiple **steps**. Most experiments will -have steps for building and executing the experiment: +have steps for building and executing the experiment, and parsing logs: >>> from lab.experiment import Experiment >>> exp = Experiment() >>> exp.add_step("build", exp.build) >>> exp.add_step("start", exp.start_runs) + >>> exp.add_step("parse", exp.parse) Moreover, there are usually steps for **fetching** the results and making **reports**: @@ -18,11 +19,11 @@ Moreover, there are usually steps for **fetching** the results and making >>> exp.add_fetcher(name="fetch") >>> exp.add_report(Report(attributes=["error"])) -The "build" step creates all necessary files for running the experiment in -the **experiment directory**. After the "start" step has finished running -the experiment, we can fetch the result from the experiment directory to -the **evaluation directory**. All reports only operate on evaluation -directories. +The "build" step creates all necessary files for running the experiment in the +**experiment directory**. After the "start" step has finished running the +experiment, we can parse data from logs and generated files into "properties" +files, and then fetch all properties files from the experiment directory to the +**evaluation directory**. All reports only operate on evaluation directories. An experiment usually also has multiple **runs**, one for each pair of algorithm and benchmark. diff --git a/docs/lab.tutorial.rst b/docs/lab.tutorial.rst index f855e99f0..6730ff7c4 100644 --- a/docs/lab.tutorial.rst +++ b/docs/lab.tutorial.rst @@ -35,12 +35,7 @@ Select steps by name or index:: ./exp.py build ./exp.py 2 - ./exp.py 3 4 - -Here is the parser that the experiment uses: - -.. literalinclude:: ../examples/vertex-cover/parser.py - :caption: + ./exp.py 3 4 5 Find out how to create your own experiments by browsing the `Lab API `_. diff --git a/docs/news.rst b/docs/news.rst index 56ed6a5be..ba9091ff8 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -1,17 +1,30 @@ Changelog ========= -next (unreleased) +v8.0 (2023-10-21) ----------------- Lab ^^^ -* Provide support for HTCondor clusters in external repo and add link to docs (Martín Pozo). +* Make parsing a separate experiment step, see :ref:`FAQs ` for motivation and upgrade instructions (Jendrik Seipp). +Downward Lab +^^^^^^^^^^^^ +* None. + + +v7.5 (2023-10-21) +----------------- + +Lab +^^^ +* Provide support for `HTCondor `_ clusters in a `third-party repository `_ and add link to docs (Martín Pozo). +* Add documentation for AI Basel's infai_3 partition (Silvan Sievers). +* Don't rely on the existence of the 'runs-00001-00100' dir when fetching results (Jendrik Seipp). Downward Lab ^^^^^^^^^^^^ -* no changes so far +* None. v7.4 (2023-08-18) diff --git a/docs/requirements.in b/docs/requirements.in new file mode 100644 index 000000000..1967cde37 --- /dev/null +++ b/docs/requirements.in @@ -0,0 +1,3 @@ +readthedocs-sphinx-search +sphinx +sphinx_rtd_theme diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 000000000..34d07e5f4 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,64 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile +# +alabaster==0.7.13 + # via sphinx +babel==2.13.0 + # via sphinx +certifi==2023.7.22 + # via requests +charset-normalizer==3.3.0 + # via requests +docutils==0.18.1 + # via + # sphinx + # sphinx-rtd-theme +idna==3.4 + # via requests +imagesize==1.4.1 + # via sphinx +jinja2==3.1.2 + # via sphinx +markupsafe==2.1.3 + # via jinja2 +packaging==23.2 + # via sphinx +pygments==2.16.1 + # via sphinx +readthedocs-sphinx-search==0.3.1 + # via -r requirements.in +requests==2.31.0 + # via sphinx +snowballstemmer==2.2.0 + # via sphinx +sphinx==7.2.6 + # via + # -r requirements.in + # sphinx-rtd-theme + # sphinxcontrib-applehelp + # sphinxcontrib-devhelp + # sphinxcontrib-htmlhelp + # sphinxcontrib-jquery + # sphinxcontrib-qthelp + # sphinxcontrib-serializinghtml +sphinx-rtd-theme==1.3.0 + # via -r requirements.in +sphinxcontrib-applehelp==1.0.7 + # via sphinx +sphinxcontrib-devhelp==1.0.5 + # via sphinx +sphinxcontrib-htmlhelp==2.0.4 + # via sphinx +sphinxcontrib-jquery==4.1 + # via sphinx-rtd-theme +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==1.0.6 + # via sphinx +sphinxcontrib-serializinghtml==1.1.9 + # via sphinx +urllib3==2.0.7 + # via requests diff --git a/docs/singularity.rst b/docs/singularity.rst index ec8fd9f6f..418038c47 100644 --- a/docs/singularity.rst +++ b/docs/singularity.rst @@ -11,7 +11,7 @@ Downward Lab. The experiment script needs a parser and a helper script: -.. literalinclude:: ../examples/singularity/singularity-parser.py +.. literalinclude:: ../examples/singularity/singularity_parser.py :caption: .. literalinclude:: ../examples/singularity/run-singularity.sh diff --git a/downward/experiment.py b/downward/experiment.py index 006fab458..dd652530f 100644 --- a/downward/experiment.py +++ b/downward/experiment.py @@ -9,14 +9,15 @@ from downward import suites from downward.cached_revision import CachedFastDownwardRevision +from downward.parsers.anytime_search_parser import AnytimeSearchParser +from downward.parsers.exitcode_parser import ExitcodeParser +from downward.parsers.planner_parser import PlannerParser +from downward.parsers.single_search_parser import SingleSearchParser +from downward.parsers.translator_parser import TranslatorParser from lab import tools from lab.experiment import Experiment, get_default_data_dir, Run -DIR = os.path.dirname(os.path.abspath(__file__)) -DOWNWARD_SCRIPTS_DIR = os.path.join(DIR, "scripts") - - class FastDownwardAlgorithm: """ A Fast Downward algorithm is the combination of revision, driver options and @@ -122,6 +123,7 @@ class FastDownwardExperiment(Experiment): >>> exp = FastDownwardExperiment() >>> exp.add_step("build", exp.build) >>> exp.add_step("start", exp.start_runs) + >>> exp.add_step("parse", exp.parse) >>> exp.add_fetcher(name="fetch") """ @@ -129,25 +131,23 @@ class FastDownwardExperiment(Experiment): # Built-in parsers that can be passed to exp.add_parser(). #: Parsed attributes: "error", "planner_exit_code", "unsolvable". - EXITCODE_PARSER = os.path.join(DOWNWARD_SCRIPTS_DIR, "exitcode-parser.py") + EXITCODE_PARSER = ExitcodeParser() #: Parsed attributes: "translator_peak_memory", "translator_time_done", etc. - TRANSLATOR_PARSER = os.path.join(DOWNWARD_SCRIPTS_DIR, "translator-parser.py") + TRANSLATOR_PARSER = TranslatorParser() #: Parsed attributes: "coverage", "memory", "total_time", etc. - SINGLE_SEARCH_PARSER = os.path.join(DOWNWARD_SCRIPTS_DIR, "single-search-parser.py") + SINGLE_SEARCH_PARSER = SingleSearchParser() #: Parsed attributes: "cost", "cost:all", "coverage". - ANYTIME_SEARCH_PARSER = os.path.join( - DOWNWARD_SCRIPTS_DIR, "anytime-search-parser.py" - ) + ANYTIME_SEARCH_PARSER = AnytimeSearchParser() #: Used attributes: "memory", "total_time", #: "translator_peak_memory", "translator_time_done". #: #: Parsed attributes: "node", "planner_memory", "planner_time", #: "planner_wall_clock_time", "score_planner_memory", "score_planner_time". - PLANNER_PARSER = os.path.join(DOWNWARD_SCRIPTS_DIR, "planner-parser.py") + PLANNER_PARSER = PlannerParser() def __init__(self, path=None, environment=None, revision_cache=None): """ diff --git a/downward/parsers/__init__.py b/downward/parsers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/downward/scripts/anytime-search-parser.py b/downward/parsers/anytime_search_parser.py old mode 100755 new mode 100644 similarity index 71% rename from downward/scripts/anytime-search-parser.py rename to downward/parsers/anytime_search_parser.py index ea11262ec..0e1b7b349 --- a/downward/scripts/anytime-search-parser.py +++ b/downward/parsers/anytime_search_parser.py @@ -1,5 +1,3 @@ -#! /usr/bin/env python - """ Parse anytime-search runs of Fast Downward. This includes iterated searches and portfolios. @@ -54,18 +52,18 @@ def add_memory(content, props): props["memory"] = raw_memory -def main(): - parser = Parser() - parser.add_pattern("raw_memory", r"Peak memory: (.+) KB", type=int), - parser.add_function(find_all_matches("cost:all", r"Plan cost: (.+)\n", type=float)) - parser.add_function( - find_all_matches("steps:all", r"Plan length: (.+) step\(s\).\n", type=float) - ) - parser.add_function(reduce_to_min("cost:all", "cost")) - parser.add_function(reduce_to_min("steps:all", "steps")) - parser.add_function(coverage) - parser.add_function(add_memory) - parser.parse() - - -main() +class AnytimeSearchParser(Parser): + def __init__(self): + super().__init__() + + self.add_pattern("raw_memory", r"Peak memory: (.+) KB", type=int) + self.add_function( + find_all_matches("cost:all", r"Plan cost: (.+)\n", type=float) + ) + self.add_function( + find_all_matches("steps:all", r"Plan length: (.+) step\(s\).\n", type=float) + ) + self.add_function(reduce_to_min("cost:all", "cost")) + self.add_function(reduce_to_min("steps:all", "steps")) + self.add_function(coverage) + self.add_function(add_memory) diff --git a/downward/scripts/exitcode-parser.py b/downward/parsers/exitcode_parser.py old mode 100755 new mode 100644 similarity index 92% rename from downward/scripts/exitcode-parser.py rename to downward/parsers/exitcode_parser.py index 6ed78e56c..2d6fafbf3 --- a/downward/scripts/exitcode-parser.py +++ b/downward/parsers/exitcode_parser.py @@ -1,5 +1,3 @@ -#! /usr/bin/env python - """ Parse Fast Downward exit code and store a message describing the outcome in the "error" attribute. @@ -49,9 +47,9 @@ def parse_exit_code(content, props): props.add_unexplained_error(outcome.msg) -class ExitCodeParser(Parser): +class ExitcodeParser(Parser): def __init__(self): - Parser.__init__(self) + super().__init__() self.add_pattern( "planner_exit_code", r"planner exit code: (.+)\n", @@ -60,11 +58,3 @@ def __init__(self): required=True, ) self.add_function(parse_exit_code) - - -def main(): - parser = ExitCodeParser() - parser.parse() - - -main() diff --git a/downward/scripts/planner-parser.py b/downward/parsers/planner_parser.py old mode 100755 new mode 100644 similarity index 96% rename from downward/scripts/planner-parser.py rename to downward/parsers/planner_parser.py index 89ff157b6..69f72d833 --- a/downward/scripts/planner-parser.py +++ b/downward/parsers/planner_parser.py @@ -1,5 +1,3 @@ -#! /usr/bin/env python - from lab import tools from lab.parser import Parser @@ -96,11 +94,3 @@ def __init__(self): self.add_function(add_planner_memory) self.add_function(add_planner_time) self.add_function(add_planner_scores) - - -def main(): - parser = PlannerParser() - parser.parse() - - -main() diff --git a/downward/scripts/single-search-parser.py b/downward/parsers/single_search_parser.py old mode 100755 new mode 100644 similarity index 98% rename from downward/scripts/single-search-parser.py rename to downward/parsers/single_search_parser.py index 9421e632c..16fa0bec0 --- a/downward/scripts/single-search-parser.py +++ b/downward/parsers/single_search_parser.py @@ -1,5 +1,3 @@ -#! /usr/bin/env python - """ Regular expressions and functions for parsing single-search runs of Fast Downward. """ @@ -160,11 +158,3 @@ def __init__(self): self.add_function(add_initial_h_values) self.add_function(ensure_minimum_times) self.add_function(add_scores) - - -def main(): - parser = SingleSearchParser() - parser.parse() - - -main() diff --git a/downward/scripts/translator-parser.py b/downward/parsers/translator_parser.py old mode 100755 new mode 100644 similarity index 95% rename from downward/scripts/translator-parser.py rename to downward/parsers/translator_parser.py index da99201fa..f12ac65bf --- a/downward/scripts/translator-parser.py +++ b/downward/parsers/translator_parser.py @@ -1,5 +1,3 @@ -#! /usr/bin/env python - """ Regular expressions and functions for parsing translator logs. """ @@ -72,8 +70,3 @@ def __init__(self): self.add_function(parse_translator_timestamps) self.add_function(parse_old_statistics) self.add_function(parse_statistics) - - -if __name__ == "__main__": - parser = TranslatorParser() - parser.parse() diff --git a/examples/downward/2020-09-11-A-cg-vs-ff.py b/examples/downward/2020-09-11-A-cg-vs-ff.py index 929f990fd..a22eb6fb5 100755 --- a/examples/downward/2020-09-11-A-cg-vs-ff.py +++ b/examples/downward/2020-09-11-A-cg-vs-ff.py @@ -3,6 +3,8 @@ import os import shutil +import custom_parser + import project @@ -64,11 +66,12 @@ exp.add_parser(exp.EXITCODE_PARSER) exp.add_parser(exp.TRANSLATOR_PARSER) exp.add_parser(exp.SINGLE_SEARCH_PARSER) -exp.add_parser(project.DIR / "parser.py") +exp.add_parser(custom_parser.get_parser()) exp.add_parser(exp.PLANNER_PARSER) exp.add_step("build", exp.build) exp.add_step("start", exp.start_runs) +exp.add_step("parse", exp.parse) exp.add_fetcher(name="fetch") if not project.REMOTE: diff --git a/examples/downward/2020-09-11-B-bounded-cost.py b/examples/downward/2020-09-11-B-bounded-cost.py index cdc26adf2..2e8becb84 100755 --- a/examples/downward/2020-09-11-B-bounded-cost.py +++ b/examples/downward/2020-09-11-B-bounded-cost.py @@ -4,6 +4,8 @@ import os import shutil +import custom_parser + from downward import suites from downward.cached_revision import CachedFastDownwardRevision from downward.experiment import FastDownwardAlgorithm, FastDownwardRun @@ -89,11 +91,12 @@ exp.add_parser(project.FastDownwardExperiment.EXITCODE_PARSER) exp.add_parser(project.FastDownwardExperiment.TRANSLATOR_PARSER) exp.add_parser(project.FastDownwardExperiment.SINGLE_SEARCH_PARSER) -exp.add_parser(project.DIR / "parser.py") +exp.add_parser(custom_parser.get_parser()) exp.add_parser(project.FastDownwardExperiment.PLANNER_PARSER) exp.add_step("build", exp.build) exp.add_step("start", exp.start_runs) +exp.add_step("parse", exp.parse) exp.add_fetcher(name="fetch") if not project.REMOTE: diff --git a/examples/downward/parser.py b/examples/downward/custom_parser.py old mode 100755 new mode 100644 similarity index 94% rename from examples/downward/parser.py rename to examples/downward/custom_parser.py index 6a9f4d075..09d7a36bb --- a/examples/downward/parser.py +++ b/examples/downward/custom_parser.py @@ -1,5 +1,3 @@ -#! /usr/bin/env python - import logging import re @@ -32,7 +30,7 @@ def search_from_bottom(content, props): self.add_function(search_from_bottom, file=file) -def main(): +def get_parser(): parser = CommonParser() parser.add_bottom_up_pattern( "search_start_time", @@ -54,8 +52,4 @@ def main(): r"New best heuristic value for .+: (\d+)\n", type=int, ) - parser.parse() - - -if __name__ == "__main__": - main() + return parser diff --git a/examples/ff/ff.py b/examples/ff/ff.py index e88e044a6..61a0eca72 100755 --- a/examples/ff/ff.py +++ b/examples/ff/ff.py @@ -8,6 +8,8 @@ import os import platform +from ff_parser import FFParser + from downward import suites from downward.reports.absolute import AbsoluteReport from lab.environments import BaselSlurmEnvironment, LocalEnvironment @@ -51,7 +53,7 @@ class BaseReport(AbsoluteReport): # Create a new experiment. exp = Experiment(environment=ENV) # Add custom parser for FF. -exp.add_parser("ff-parser.py") +exp.add_parser(FFParser()) for task in suites.build_suite(BENCHMARKS_DIR, SUITE): run = exp.add_run() @@ -87,6 +89,9 @@ class BaseReport(AbsoluteReport): # Add step that executes all runs. exp.add_step("start", exp.start_runs) +# Add step that parses log output into "properties" files. +exp.add_step("parse", exp.parse) + # Add step that collects properties from run directories and # writes them to *-eval/properties. exp.add_fetcher(name="fetch") diff --git a/examples/ff/ff-parser.py b/examples/ff/ff_parser.py old mode 100755 new mode 100644 similarity index 68% rename from examples/ff/ff-parser.py rename to examples/ff/ff_parser.py index 72fc138cc..6b9625530 --- a/examples/ff/ff-parser.py +++ b/examples/ff/ff_parser.py @@ -1,5 +1,3 @@ -#! /usr/bin/env python - """ FF example output: @@ -53,15 +51,21 @@ def trivially_unsolvable(content, props): ) -parser = Parser() -parser.add_pattern("node", r"node: (.+)\n", type=str, file="driver.log", required=True) -parser.add_pattern( - "planner_exit_code", r"run-planner exit code: (.+)\n", type=int, file="driver.log" -) -parser.add_pattern("evaluations", r"evaluating (\d+) states") -parser.add_function(error) -parser.add_function(coverage) -parser.add_function(get_plan) -parser.add_function(get_times) -parser.add_function(trivially_unsolvable) -parser.parse() +class FFParser(Parser): + def __init__(self): + super().__init__() + self.add_pattern( + "node", r"node: (.+)\n", type=str, file="driver.log", required=True + ) + self.add_pattern( + "planner_exit_code", + r"run-planner exit code: (.+)\n", + type=int, + file="driver.log", + ) + self.add_pattern("evaluations", r"evaluating (\d+) states") + self.add_function(error) + self.add_function(coverage) + self.add_function(get_plan) + self.add_function(get_times) + self.add_function(trivially_unsolvable) diff --git a/examples/lmcut.py b/examples/lmcut.py index 32b240ec8..d404f9a3b 100755 --- a/examples/lmcut.py +++ b/examples/lmcut.py @@ -51,6 +51,8 @@ # Add step that executes all runs. exp.add_step("start", exp.start_runs) +exp.add_step("parse", exp.parse) + # Add step that collects properties from run directories and # writes them to *-eval/properties. exp.add_fetcher(name="fetch") diff --git a/examples/showcase-options.py b/examples/showcase-options.py index 6c6dbf5cc..66174feff 100755 --- a/examples/showcase-options.py +++ b/examples/showcase-options.py @@ -130,12 +130,13 @@ def add_quality(self, run): # Add step that executes all runs. exp.add_step("start", exp.start_runs) +# Add step that parses data from the logs into "properties" files. +exp.add_step("parse", exp.parse) + # Add step that collects properties from run directories and # writes them to *-eval/properties. exp.add_fetcher(name="fetch") -exp.add_parse_again_step() - # Define a filter. def only_two_algorithms(run): diff --git a/examples/singularity/singularity-exp.py b/examples/singularity/singularity-exp.py index 27ce5e7e6..47f65b1be 100755 --- a/examples/singularity/singularity-exp.py +++ b/examples/singularity/singularity-exp.py @@ -30,6 +30,8 @@ import platform import sys +from singularity_parser import get_parser + from downward import suites from downward.reports.absolute import AbsoluteReport from lab.environments import BaselSlurmEnvironment, LocalEnvironment @@ -88,8 +90,9 @@ class BaseReport(AbsoluteReport): exp = Experiment(environment=ENVIRONMENT) exp.add_step("build", exp.build) exp.add_step("start", exp.start_runs) +exp.add_step("parse", exp.parse) exp.add_fetcher(name="fetch") -exp.add_parser(DIR / "singularity-parser.py") +exp.add_parser(get_parser()) def get_image(name): diff --git a/examples/singularity/singularity-parser.py b/examples/singularity/singularity_parser.py old mode 100755 new mode 100644 similarity index 96% rename from examples/singularity/singularity-parser.py rename to examples/singularity/singularity_parser.py index a9789ff91..41ec7c3da --- a/examples/singularity/singularity-parser.py +++ b/examples/singularity/singularity_parser.py @@ -1,5 +1,3 @@ -#! /usr/bin/env python - import re import sys @@ -71,8 +69,7 @@ def set_outcome(content, props): props["error"] = "unexpected-error" -def main(): - print("Running singularity parser") +def get_parser(): parser = Parser() parser.add_pattern( "planner_exit_code", @@ -113,8 +110,4 @@ def main(): parser.add_function(unsolvable) parser.add_function(parse_g_value_over_time) parser.add_function(set_outcome, file="values.log") - parser.parse() - - -if __name__ == "__main__": - main() + return parser diff --git a/examples/vertex-cover/exp.py b/examples/vertex-cover/exp.py index b4a445427..c5b53c14f 100755 --- a/examples/vertex-cover/exp.py +++ b/examples/vertex-cover/exp.py @@ -11,6 +11,7 @@ from downward.reports.absolute import AbsoluteReport from lab.environments import BaselSlurmEnvironment, LocalEnvironment from lab.experiment import Experiment +from lab.parser import Parser from lab.reports import Attribute @@ -54,12 +55,47 @@ class BaseReport(AbsoluteReport): Attribute("solved", absolute=True), ] +""" +Create parser for the following example solver output: + +Algorithm: 2approx +Cover: set([1, 3, 5, 6, 7, 8, 9]) +Cover size: 7 +Solve time: 0.000771s +""" + + +def make_parser(): + def solved(content, props): + props["solved"] = int("cover" in props) + + def error(content, props): + if props["solved"]: + props["error"] = "cover-found" + else: + props["error"] = "unsolved" + + vc_parser = Parser() + vc_parser.add_pattern( + "node", r"node: (.+)\n", type=str, file="driver.log", required=True + ) + vc_parser.add_pattern( + "solver_exit_code", r"solve exit code: (.+)\n", type=int, file="driver.log" + ) + vc_parser.add_pattern("cover", r"Cover: (\{.*\})", type=str) + vc_parser.add_pattern("cover_size", r"Cover size: (\d+)\n", type=int) + vc_parser.add_pattern("solve_time", r"Solve time: (.+)s", type=float) + vc_parser.add_function(solved) + vc_parser.add_function(error) + return vc_parser + + # Create a new experiment. exp = Experiment(environment=ENV) # Add solver to experiment and make it available to all runs. exp.add_resource("solver", os.path.join(SCRIPT_DIR, "solver.py")) # Add custom parser. -exp.add_parser("parser.py") +exp.add_parser(make_parser()) for algo in ALGORITHMS: for task in SUITE: @@ -94,6 +130,9 @@ class BaseReport(AbsoluteReport): # Add step that executes all runs. exp.add_step("start", exp.start_runs) +# Add step that parses the logs. +exp.add_step("parse", exp.parse) + # Add step that collects properties from run directories and # writes them to *-eval/properties. exp.add_fetcher(name="fetch") diff --git a/examples/vertex-cover/parser.py b/examples/vertex-cover/parser.py deleted file mode 100755 index e28bae02d..000000000 --- a/examples/vertex-cover/parser.py +++ /dev/null @@ -1,39 +0,0 @@ -#! /usr/bin/env python - -""" -Solver example output: - -Algorithm: 2approx -Cover: set([1, 3, 5, 6, 7, 8, 9]) -Cover size: 7 -Solve time: 0.000771s -""" - -from lab.parser import Parser - - -def solved(content, props): - props["solved"] = int("cover" in props) - - -def error(content, props): - if props["solved"]: - props["error"] = "cover-found" - else: - props["error"] = "unsolved" - - -if __name__ == "__main__": - parser = Parser() - parser.add_pattern( - "node", r"node: (.+)\n", type=str, file="driver.log", required=True - ) - parser.add_pattern( - "solver_exit_code", r"solve exit code: (.+)\n", type=int, file="driver.log" - ) - parser.add_pattern("cover", r"Cover: (\{.*\})", type=str) - parser.add_pattern("cover_size", r"Cover size: (\d+)\n", type=int) - parser.add_pattern("solve_time", r"Solve time: (.+)s", type=float) - parser.add_function(solved) - parser.add_function(error) - parser.parse() diff --git a/lab/__init__.py b/lab/__init__.py index b2598df12..6cd8a5fad 100644 --- a/lab/__init__.py +++ b/lab/__init__.py @@ -1,2 +1,2 @@ #: Lab version number. A "+" is appended to all non-tagged revisions. -__version__ = "7.4+" +__version__ = "8.0+" diff --git a/lab/environments.py b/lab/environments.py index dde5f1195..d85086ac0 100644 --- a/lab/environments.py +++ b/lab/environments.py @@ -140,6 +140,7 @@ class SlurmEnvironment(Environment): * "infai_1": 24 nodes with 16 cores, 64GB memory, 500GB Sata (default) * "infai_2": 24 nodes with 20 cores, 128GB memory, 240GB SSD + * "infai_3": 12 nodes with 128 cores, 512GB memory, 240GB SSD *qos* must be a valid Slurm QOS name. In Basel this must be "normal". @@ -155,11 +156,11 @@ class SlurmEnvironment(Environment): allocated for each core. The string must end with one of the letters K, M or G. The default is "3872M". The value for *memory_per_cpu* should not surpass the amount of memory that is - available per core, which is "3872M" for infai_1 and "6354M" for - infai_2. Processes that surpass the *memory_per_cpu* limit are - terminated with SIGKILL. To impose a soft limit that can be - caught from within your programs, you can use the - ``memory_limit`` kwarg of + available per core, which is "3872M" for infai_1, "6354M" for + infai_2, and "4028M" for infai_3. Processes that surpass the + *memory_per_cpu* limit are terminated with SIGKILL. To impose a + soft limit that can be caught from within your programs, you can + use the ``memory_limit`` kwarg of :py:func:`~lab.experiment.Run.add_command`. Fast Downward users should set memory limits via the ``driver_options``. @@ -178,6 +179,7 @@ class SlurmEnvironment(Environment): >>> env1 = BaselSlurmEnvironment(partition="infai_1", memory_per_cpu="3872M") >>> env2 = BaselSlurmEnvironment(partition="infai_2", memory_per_cpu="6354M") + >>> env3 = BaselSlurmEnvironment(partition="infai_3", memory_per_cpu="4028M") Example that reserves 12 GiB of memory on infai_1: @@ -199,6 +201,16 @@ class SlurmEnvironment(Environment): ... cpus_per_task=2, ... ) + Example that reserves 12 GiB of memory on infai_3: + + >>> # 12 * 1024 / 4028 = 3.05 -> round to next int -> 4 cores per task + >>> # 12G / 4 = 3G per core + >>> env = BaselSlurmEnvironment( + ... partition="infai_3", + ... memory_per_cpu="3G", + ... cpus_per_task=4, + ... ) + Use *export* to specify a list of environment variables that should be exported from the login node to the compute nodes (default: ["PATH"]). diff --git a/lab/experiment.py b/lab/experiment.py index 3b20264ad..e80bb550a 100644 --- a/lab/experiment.py +++ b/lab/experiment.py @@ -1,14 +1,14 @@ """Main module for creating experiments.""" from collections import OrderedDict -from glob import glob import logging import os -import subprocess +from pathlib import Path import sys from lab import environments, tools from lab.fetcher import Fetcher +from lab.parser import Parser from lab.steps import get_step, get_steps_text, Step @@ -77,12 +77,11 @@ def _check_name(name, typ, extra_chars=""): class _Resource: - def __init__(self, name, source, dest, symlink, is_parser): + def __init__(self, name, source, dest, symlink): self.name = name self.source = source self.dest = dest self.symlink = symlink - self.is_parser = is_parser class _Buildable: @@ -154,7 +153,7 @@ def add_resource(self, name, source, dest="", symlink=False): if name: self._check_alias(name) self.env_vars_relative[name] = dest - self.resources.append(_Resource(name, source, dest, symlink, is_parser=False)) + self.resources.append(_Resource(name, source, dest, symlink)) def add_new_file(self, name, dest, content, permissions=0o644): """ @@ -287,10 +286,8 @@ def _build_new_files(self): tools.write_file(filename, content) os.chmod(filename, permissions) - def _build_resources(self, only_parsers=False): + def _build_resources(self): for resource in self.resources: - if only_parsers and not resource.is_parser: - continue if not os.path.exists(resource.source): logging.critical(f"Resource not found: {resource.source}") dest = self._get_abs_path(resource.dest) @@ -349,6 +346,7 @@ def __init__(self, path=None, environment=None): self.steps = [] self.runs = [] + self.parsers = [] self.set_property("experiment_file", self._script) @@ -408,82 +406,47 @@ def add_step(self, name, function, *args, **kwargs): raise ValueError(f"Step names must be unique: {name}") self.steps.append(Step(name, function, *args, **kwargs)) - def add_parser(self, path_to_parser): + def add_parser(self, parser): """ - Add a parser to each run of the experiment. - - Add the parser as a resource to the experiment and add a command - that executes the parser to each run. Since commands are - executed in the order they are added, parsers should be added - after all other commands. If you need to change your parsers and - execute them again you can use the :meth:`.add_parse_again_step` - method. - - *path_to_parser* must be the path to a Python script. The script - is executed in the run directory and manipulates the run's - "properties" file. The last part of the filename (without the - extension) is used as a resource name. Therefore, it must be - unique among all parsers and other resources. Also, it must - start with a letter and contain only letters, numbers, - underscores and dashes (which are converted to underscores - automatically). - - For information about how to write parsers see :ref:`parsing`. + Add a :class:`lab.parser.Parser` to each run of the experiment. + + Each parser is executed in each run directory and manipulates the run's + "properties" file. For information about how to write parsers see + :ref:`parsing`. """ - name, _ = os.path.splitext(os.path.basename(path_to_parser)) - name = name.replace("-", "_") - self._check_alias(name) - if not os.path.isfile(path_to_parser): - logging.critical(f"Parser {path_to_parser} could not be found.") - - dest = os.path.basename(path_to_parser) - self.env_vars_relative[name] = dest - self.resources.append( - _Resource(name, path_to_parser, dest, symlink=False, is_parser=True) - ) - self.add_command(name, [tools.get_python_executable(), f"{{{name}}}"]) + if not isinstance(parser, Parser): + raise TypeError(f'"{parser}" must be a Parser instance') + self.parsers.append(parser) - def add_parse_again_step(self): + def parse(self): """ - Add a step that copies the parsers from their originally specified - locations to the experiment directory and runs all of them again. This - step overwrites the existing properties file in each run dir. + Run all parsers that have been added to the experiment with + :meth:`.add_parser`. - Do not forget to run the default fetch step again to overwrite - existing data in the -eval dir of the experiment. + After parsing, you'll want to run a "fetch" step to collect the parsed + data from the experiment into the evaluation directory. """ - def run_parsers(): - if not os.path.isdir(self.path): - logging.critical(f"{self.path} is missing or not a directory") - - # Copy all parsers from their source to their destination again. - self._build_resources(only_parsers=True) - - run_dirs = sorted(glob(os.path.join(self.path, "runs-*-*", "*"))) - - total_dirs = len(run_dirs) - logging.info(f"Parsing properties in {total_dirs:d} run directories") - for index, run_dir in enumerate(run_dirs, start=1): - if os.path.exists(os.path.join(run_dir, "properties")): - tools.remove_path(os.path.join(run_dir, "properties")) - loglevel = logging.INFO if index % 100 == 0 else logging.DEBUG - logging.log(loglevel, f"Parsing run: {index:6d}/{total_dirs:d}") - for resource in self.resources: - if resource.is_parser: - parser_filename = self.env_vars_relative[resource.name] - rel_parser = os.path.join("../../", parser_filename) - # Since parsers often produce output which we would - # rather not want to see for each individual run, we - # suppress it here. - subprocess.check_call( - [tools.get_python_executable(), rel_parser], - cwd=run_dir, - stdout=subprocess.DEVNULL, - ) - - self.add_step("parse-again", run_parsers) + if not os.path.isdir(self.path): + logging.critical(f"{self.path} is missing or not a directory") + + run_dirs = sorted(Path(self.path).glob("runs-*-*/*")) + num_runs = len(run_dirs) + logging.info( + f"Running {len(self.parsers)} parsers in {num_runs:d} run directories." + ) + for index, run_dir in enumerate(run_dirs, start=1): + props_path = run_dir / "properties" + if props_path.is_file(): + props_path.unlink() + + loglevel = logging.INFO if index % 100 == 0 else logging.DEBUG + logging.log(loglevel, f"Parsing run: {index:6d}/{num_runs:d}") + props = tools.Properties(filename=props_path) + for parser in self.parsers: + parser.parse(run_dir, props) + props.write() def add_fetcher( self, src=None, dest=None, merge=None, name=None, filter=None, **kwargs diff --git a/lab/fetcher.py b/lab/fetcher.py index 0c9e016c4..4b8e0229d 100644 --- a/lab/fetcher.py +++ b/lab/fetcher.py @@ -49,7 +49,18 @@ def fetch_dir(self, run_dir): static_props = tools.Properties( filename=run_dir / lab.experiment.STATIC_RUN_PROPERTIES_FILENAME ) - dynamic_props = tools.Properties(filename=run_dir / "properties") + dynamic_props_path = run_dir / "properties" + dynamic_props = tools.Properties(filename=dynamic_props_path) + if not dynamic_props_path.exists(): + logging.critical( + f'Properties file "{tools.get_relative_path(dynamic_props_path)}" is' + f' missing. Did you forget to add or run the "parse" step?' + ) + elif not dynamic_props: + logging.critical( + f'Properties file "{tools.get_relative_path(dynamic_props_path)}" is' + f" empty. Have you added at least one parser?" + ) props = tools.Properties() props.update(static_props) diff --git a/lab/parser.py b/lab/parser.py index 5bd9b1dc4..4b4f8de58 100644 --- a/lab/parser.py +++ b/lab/parser.py @@ -1,41 +1,29 @@ """ -A parser can be any program that analyzes files in the run's directory -(e.g. ``run.log``) and manipulates the ``properties`` file in the same -directory. +To parse logs or generated files, you can use the ``Parser`` class. Here is an +example parser for the FF planner: -To make parsing easier, however, you can use the ``Parser`` class. Here is -an example parser for the FF planner: - -.. literalinclude:: ../examples/ff/ff-parser.py +.. literalinclude:: ../examples/ff/ff_parser.py :caption: -You can add this parser to all runs by using :meth:`add_parser() +You can add a parser to all runs with :meth:`add_parser() `: >>> from pathlib import Path >>> from lab.experiment import Experiment +>>> parser = Parser() +>>> parser.add_pattern("exitcode", "retcode: (.+)\\n", type=int, file="run.log") >>> exp = Experiment() ->>> # The path can be absolute or relative to the working directory at build time. ->>> parser = Path(__file__).resolve().parents[1] / "examples/ff/ff-parser.py" >>> exp.add_parser(parser) -All added parsers will be run in the order in which they were added after -executing the run's commands. - -If you need to change your parsers and execute them again, use the -:meth:`~lab.experiment.Experiment.add_parse_again_step` method to re-parse -your results. +Parsers are run in the order in which they were added. """ from collections import defaultdict -import errno import logging -import os.path +from pathlib import Path import re -from lab import tools - def _get_pattern_flags(s): flags = 0 @@ -87,38 +75,27 @@ def __str__(self): class _FileParser: """ - Private class that parses a given file according to the added patterns. + Private class that searches a given file for the added patterns. """ def __init__(self): - self.filename = None - self.content = None self.patterns = [] - def load_file(self, filename): - self.filename = filename - with open(filename) as f: - self.content = f.read() - def add_pattern(self, pattern): self.patterns.append(pattern) - def search_patterns(self): - assert self.content is not None - found_props = {} + def search_patterns(self, filename, content, props): for pattern in self.patterns: - found_props.update(pattern.search(self.content, self.filename)) - return found_props + props.update(pattern.search(content, filename)) class Parser: """ - Parse files in the current directory and write results into the - run's ``properties`` file. + Parse logs or files in a given directory and write results into the + ``properties`` file. """ def __init__(self): - tools.configure_logging() self.file_parsers = defaultdict(_FileParser) self.functions = [] @@ -184,34 +161,34 @@ def add_function(self, function, file="run.log"): """ self.functions.append(_Function(function, file)) - def parse(self): + def parse(self, run_dir, props): """Search all patterns and apply all functions. - The found values are written to the run's ``properties`` file. + Add the found values to *props*. """ - run_dir = os.path.abspath(".") - prop_file = os.path.join(run_dir, "properties") - self.props = tools.Properties(filename=prop_file) + run_dir = Path(run_dir).resolve() - for filename, file_parser in list(self.file_parsers.items()): - # If filename is absolute it will not be changed here. - path = os.path.join(run_dir, filename) - try: - file_parser.load_file(path) - except OSError as err: - if err.errno == errno.ENOENT: + content_cache = {} + + def get_content(path): + if path not in content_cache: + try: + content_cache[path] = path.read_text() + except FileNotFoundError: logging.info(f'File "{path}" is missing and thus not parsed.') - del self.file_parsers[filename] - else: - logging.error(f'Failed to read "{path}": {err}') + content_cache[path] = None + return content_cache[path] - for file_parser in self.file_parsers.values(): - self.props.update(file_parser.search_patterns()) + for filename, file_parser in self.file_parsers.items(): + # If filename is absolute, path is set to filename. + path = run_dir / filename + content = get_content(path) + if content: + file_parser.search_patterns(str(path), content, props) for function in self.functions: - with open(function.filename) as f: - content = f.read() - function.function(content, self.props) - - self.props.write() + path = run_dir / function.filename + content = get_content(path) + if content: + function.function(content, props) diff --git a/lab/tools.py b/lab/tools.py index 6370d7eb4..c40b95557 100644 --- a/lab/tools.py +++ b/lab/tools.py @@ -280,9 +280,8 @@ def default(self, o): """Transparently handle properties files compressed with xz.""" def __init__(self, filename=None): - self.path = filename + self.path = Path(filename).resolve() if filename else None if self.path: - self.path = Path(self.path).resolve() xz_path = self.path.with_suffix(".xz") if self.path.is_file() and xz_path.is_file(): logging.critical(f"Only one of {self.path} and {xz_path} may exist") diff --git a/setup.py b/setup.py index d78a97531..c37f54124 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ url="https://github.com/aibasel/lab", license="GPL3+", packages=find_packages("."), - package_data={"downward": ["scripts/*.py"], "lab": ["data/*", "scripts/*.py"]}, + package_data={"lab": ["data/*", "scripts/*.py"]}, classifiers=[ "Development Status :: 5 - Production/Stable", "Environment :: Console", diff --git a/tests/run-downward-experiment b/tests/run-downward-experiment index 301cb25d1..ad568da9c 100755 --- a/tests/run-downward-experiment +++ b/tests/run-downward-experiment @@ -11,6 +11,6 @@ rm -rf ${EXPDIR}/data/ mkdir -p ${EXPDIR} cp ${REPO}/examples/downward/*.py ${EXPDIR} cp ${REPO}/examples/downward/bounds.json ${EXPDIR} -${DIR}/run-example-experiment ${EXPDIR}/2020-09-11-A-cg-vs-ff.py 1 2 3 6 9 -${DIR}/run-example-experiment ${EXPDIR}/2020-09-11-B-bounded-cost.py 1 2 3 6 +${DIR}/run-example-experiment ${EXPDIR}/2020-09-11-A-cg-vs-ff.py 1 2 3 4 7 10 +${DIR}/run-example-experiment ${EXPDIR}/2020-09-11-B-bounded-cost.py 1 2 3 4 7 ${DIR}/run-example-experiment ${EXPDIR}/01-evaluation.py 1 2 3 4 diff --git a/tox.ini b/tox.ini index f7b11634a..9616d3c95 100644 --- a/tox.ini +++ b/tox.ini @@ -51,9 +51,7 @@ allowlist_externals = [testenv:docs] skipsdist = true -deps = - sphinx - sphinx_rtd_theme +deps = -rdocs/requirements.txt commands = bash {toxinidir}/tests/build-docs