diff --git a/.github/workflows/draft.yml b/.github/workflows/draft.yml index 63cda3a..cda9d39 100644 --- a/.github/workflows/draft.yml +++ b/.github/workflows/draft.yml @@ -46,7 +46,7 @@ jobs: - name: Clippy run: cargo clippy --release --no-deps --all-targets -- -D warnings - - name: Report + - name: Cargo Test run: cargo test --release -- -Z unstable-options --format json --report-time | cargo2junit > results.xml - name: Upload reports @@ -55,6 +55,5 @@ jobs: name: coverage path: | results.xml - cobertura.xml retention-days: 4 continue-on-error: true diff --git a/.github/workflows/ready.yml b/.github/workflows/ready.yml index 1f521f5..6800fec 100644 --- a/.github/workflows/ready.yml +++ b/.github/workflows/ready.yml @@ -44,12 +44,6 @@ jobs: with: components: clippy, rustfmt - - name: Prepare tarpaulin - uses: baptiste0928/cargo-install@v3 - with: - crate: cargo-tarpaulin - locked: false - - name: Prepare cargo2junit uses: baptiste0928/cargo-install@v3 with: @@ -58,14 +52,11 @@ jobs: - name: Formatting run: cargo fmt -- --check - - name: Le clippy + - name: Clippy run: cargo clippy --release --no-deps --all-targets -- -D warnings - - name: Count coverage - run: cargo tarpaulin --engine llvm --out Xml --root . --manifest-path Cargo.toml - - - name: Report - run: cargo test --release -- -Z unstable-options --format json --report-time | cargo2junit > results.xml + - name: Cargo Test + run: cargo test --release -- --nocapture -Z unstable-options --format json --report-time | cargo2junit > results.xml - name: Upload reports uses: actions/upload-artifact@v4 @@ -73,7 +64,6 @@ jobs: name: coverage path: | results.xml - cobertura.xml retention-days: 7 continue-on-error: true diff --git a/Cargo.toml b/Cargo.toml index 72ea6df..9ade134 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "gourd" -version = "1.1.2" +version = "1.2.0" edition = "2021" default-run = "gourd" authors = [ @@ -63,12 +63,13 @@ elf = "0.7.4" [dependencies] # To execute threads locally using a thread-pool executor. -tokio = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } # To encode/decode data in gourd.toml and other Gourd files. toml = "0.8.12" serde = { version = "1.0", features = ["derive"] } chrono = { version = "0.4", features = ["serde"] } +shellexpand = { version = "3.1.0", default-features = false, features = ["base-0", "tilde"] } glob = "0.3.1" regex-lite = "0.1.5" @@ -183,3 +184,4 @@ redundant_static_lifetimes = "allow" missing_docs_in_private_items = "warn" missing_safety_doc = "warn" undocumented_unsafe_blocks = "warn" +literal_string_with_formatting_args = "allow" diff --git a/build.rs b/build.rs index c2e9127..834bfa6 100644 --- a/build.rs +++ b/build.rs @@ -232,7 +232,7 @@ fn generate_html(man_path: PathBuf, out_folder: &Path) -> Result { let out_path = out_folder.join(man_path.with_extension("html").file_name().unwrap()); let style_path = out_folder.join("manpage.css"); - fs::write(&out_path, format!("{}{}{}", PREAMBLE, html, POSTAMBLE))?; + fs::write(&out_path, format!("{PREAMBLE}{html}{POSTAMBLE}"))?; fs::write(style_path, STYLE)?; Ok(out_path) diff --git a/docs/maintainer/architecture/section.tex b/docs/maintainer/architecture/section.tex index 0b31c70..35d878e 100644 --- a/docs/maintainer/architecture/section.tex +++ b/docs/maintainer/architecture/section.tex @@ -85,7 +85,7 @@ \subsection{Interactions} \item \texttt{gourd version} \end{itemize} -\subsection{An overview of an experiments lifetime} +\subsection{An overview of an experiment's lifetime} The first thing that a user will do is run either \texttt{gourd run local} or \texttt{gourd run slurm}. diff --git a/docs/maintainer/version-history/section.tex b/docs/maintainer/version-history/section.tex index f38b9c9..35aba80 100644 --- a/docs/maintainer/version-history/section.tex +++ b/docs/maintainer/version-history/section.tex @@ -5,6 +5,14 @@ \section{Version History} \input{version-history/definitions} % \version{x} for start of version x section +\version{1.2.0}{Sponge Gourd}{} + +Major internal reworkings, redesigned \texttt{gourd analyse}. +Past this major release version the project is open source. +For more details on this release, see + +https://github.com/ConSol-Lab/gourd/pull/19 + \version{1.0.2}{Snake Gourd}{} This patch addresses the following: diff --git a/docs/user/gourd-tutorial.7.tex b/docs/user/gourd-tutorial.7.tex index 502d022..4c00c09 100644 --- a/docs/user/gourd-tutorial.7.tex +++ b/docs/user/gourd-tutorial.7.tex @@ -17,10 +17,10 @@ \newcommand{\thecommand}{GOURD-TUTORIAL} \newcommand{\mansection}{7} \newcommand{\mansectionname}{DelftBlue Tools Manual} -\newcommand{\mandate}{19 AUGUST 2024} -\setDate{19 AUGUST 2024} +\newcommand{\mandate}{25 MARCH 2025} +\setDate{25 MARCH 2025} \setVersionWord{Version:} -\setVersion{1.1.2} +\setVersion{1.2.0} \input{docs/user/latex2man_styling.tex} @@ -153,7 +153,8 @@ \end{Description} Each program links to a \Arg{binary} -- the executable file that runs our - algorithm. In this case, our Fibonacci algorithms are compiled in Rust. + algorithm. + In this case, our Fibonacci algorithms are compiled in Rust. If you are following this tutorial with \Prog{gourd}~\Arg{init} \Arg{--example}~\Arg{fibonacci-comparison}, @@ -199,24 +200,28 @@ a valid number - let's see how this will crash the programs. Inputs are combined with programs in a \textbf{cross product} to create - \emph{runs}. Each program-input combination is exactly one \emph{run}. + \emph{runs}. + Each program-input combination is exactly one \emph{run}. In this example, 3 programs * 4 inputs results in 12 \emph{runs}. \subsection{Running the evaluation} Our \File{gourd.toml} is complete - now it is time to run the evaluation - using \Prog{gourd}~\Arg{run}. Typing \Prog{gourd}~\Arg{run} in a terminal + using \Prog{gourd}~\Arg{run}. + Typing \Prog{gourd}~\Arg{run} in a terminal will tell you that it has two subcommands: \begin{Description}[subcommands]\setlength{\itemsep}{0cm} - \item[\Arg{local}] Run locally on your computer. If connected via SSH to a + \item[\Arg{local}] Run locally on your computer. + If connected via SSH to a cluster computer, \Arg{local} uses the very limited computing power of the login node. \item[\Arg{slurm}] Send to the SLURM cluster scheduler on a supercomputer. \end{Description} The \Arg{slurm} subcommand needs some extra configuration, so let's go with - \Arg{local} for now. Type \Prog{gourd}~\Arg{run}~\Arg{local}. + \Arg{local} for now. + Type \Prog{gourd}~\Arg{run}~\Arg{local}. \begin{verbatim} @@ -250,19 +255,17 @@ \end{verbatim} - If you are seeing similar output, you have successfully reproduced a Gourd - experiment! + If you are seeing similar output, you have successfully reproduced a Gourd experiment! \subsection{Displaying status} - The \Arg{run} command has created an experiment from the experimental - setup and executed it on your computer. Each of the twelve runs are shown - here, grouped by program, alongside with their completion status. In fact, - you can show this view at any time by typing \Prog{gourd}~\Arg{status}. + The \Arg{run} command has created an experiment from the experimental setup and executed it on your computer. + Each of the twelve runs are shown here, grouped by program, alongside with their completion status. + In fact, you can show this view at any time by typing \Prog{gourd}~\Arg{status}. - We can see that runs 0,~4,~and~8 have failed. Let's take a closer look at - why that is! Type \Prog{gourd}~\Arg{status}~\Opt{-i}~\Arg{4} to check on - run number 4. + We can see that runs 0,~4,~and~8 have failed. + Let's take a closer look at why that is! + Type \Prog{gourd}~\Arg{status}~\Opt{-i}~\Arg{4} to check on run number 4. \begin{verbatim} @@ -296,8 +299,7 @@ These files reveal that \File{bad\_test} fails because the Fibonacci programs are expecting a number, but the input is "some text" instead! - Let's fix the problem and replace it with 10, a decidedly more valid - number. + Let's fix the problem and replace it with 10, a decidedly more valid number. \begin{verbatim} @@ -315,9 +317,8 @@ Now we have fixed the problem, and the input called \File{bad\_test} is not so bad after all. - You can imagine that running the whole experiment again when only 1/4 of - the results are invalid would be a waste. We are going to use - \Prog{gourd}~\Arg{rerun} to repeat only the runs that failed. + You can imagine that running the whole experiment again when only 1/4 of the results are invalid would be a waste. + We are going to use \Prog{gourd}~\Arg{rerun} to repeat only the runs that failed. \begin{verbatim} @@ -333,12 +334,11 @@ \end{verbatim} - The \Prog{gourd}~\Arg{rerun} command suggests rerunning the failed runs - only! Another option supported by \Arg{rerun} is to specify a list of IDs - for it to reschedule. + The \Prog{gourd}~\Arg{rerun} command suggests rerunning the failed runs only! + Another option supported by \Arg{rerun} is to specify a list of IDs for it to reschedule. - After \Arg{rerun}, it is necessary to use \Prog{gourd}~\Arg{continue} to - actually execute the newly created runs. Try this in your terminal. + After \Arg{rerun}, it is necessary to use \Prog{gourd}~\Arg{continue} to actually execute the newly created runs. + Try this in your terminal. \subsection{Collecting data} @@ -347,14 +347,14 @@ \Prog{gourd} also provides a simple way to process the numerous metrics files that our runs have generated. - By running \Prog{gourd}~\Arg{analyse}, you can create a CSV file that - collects all metrics from the application's run. On UNIX-like operating - systems, RUsage provides a large array of useful data such as context - switches and page faults in addition to basic timing. + By running \Prog{gourd}~\Arg{analyse}~\Arg{table}, you can create a CSV + file that collects all metrics from the application's run. + On UNIX-like operating systems, RUsage provides a large array of useful + data such as context switches and page faults in addition to basic timing. - Furthermore, \Prog{gourd}~\Arg{analyse}~\OptArg{-o }{ output-type} supports - other ways of collecting and visualising the experiment's output. - Try the \File{plot-png} output type, which produces a cactus-plot summary + Furthermore, \Prog{gourd}~\Arg{analyse} supports + ways of collecting and visualising the experiment's output. + Try the \Prog{gourd}~\Arg{analyse}~\Arg{plot}, which produces a cactus-plot summary of the programs' runtimes. \section{SEE ALSO} diff --git a/docs/user/gourd.1.tex b/docs/user/gourd.1.tex index 1ff37e7..058161c 100644 --- a/docs/user/gourd.1.tex +++ b/docs/user/gourd.1.tex @@ -17,10 +17,10 @@ \newcommand{\thecommand}{GOURD} \newcommand{\mansection}{1} \newcommand{\mansectionname}{DelftBlue Tools Manual} -\newcommand{\mandate}{19 AUGUST 2024} -\setDate{19 AUGUST 2024} +\newcommand{\mandate}{25 MARCH 2025} +\setDate{25 MARCH 2025} \setVersionWord{Version:} -\setVersion{1.1.2} +\setVersion{1.2.0} \input{docs/user/latex2man_styling.tex} @@ -73,7 +73,7 @@ \section{GLOBAL OPTIONS} The following options apply to all \Prog{gourd} commands. - These will be reffered to as \emph{GLOBAL OPTIONS} throught the manual. + These will be referred to as \emph{GLOBAL OPTIONS} throughout the manual. \begin{Description}[Options] \item[\OptArg{-c}{ filename}, \OptArg{\ddash config}{ filename}] @@ -85,7 +85,7 @@ without executing them. \item[\Opt{-h}, \Opt{\ddash help}] Display usage instructions for the \Prog{gourd} utility or any of its commands. - This option extends to all of the subcommands of \Prog{gourd}: for example, running + This option extends to all the subcommands of \Prog{gourd}: for example, running \Prog{gourd} \Arg{status} \Arg{-h} will display help about the \Arg{status} subcommand. \item[\Opt{-s}, \Opt{\ddash script}] Tell \Prog{gourd} to use a script-friendly interface, that is, one that does not use @@ -140,7 +140,7 @@ Because most options are specified in this file, it is usually sufficient to type \Prog{gourd} \Arg{run} \Arg{slurm}|\Arg{local} to run an experiment. - See the manual page for \Prog{gourd-tutorial(7)} for a step-by-step guide on + See the manual page \Prog{gourd-tutorial(7)} for a step-by-step guide on designing experiments to run. @@ -220,8 +220,8 @@ of this manual. Running on Slurm has many configurable options. - Please refer to the manual for \Prog{gourd-tutorial}(1) for example setups - and the manual for \Prog{gourd.toml}(5) for complete reference. + Please refer to the manual \Prog{gourd-tutorial}(1) for example setups + and the manual \Prog{gourd.toml}(5) for complete reference. The implementation of the Slurm API used by \Prog{gourd} is discussed in depth in the \Prog{gourd} maintainer documentation. \end{Description} @@ -236,7 +236,7 @@ Unless run with the \oOpt{-s} flag, this command will ask using interactive prompts to refine the template to your needs. - If the command is ran with the \oOpt{-s} flag these choices will not be offered and + If the command is run with the \oOpt{-s} flag these choices will not be offered and the default options will be picked for all queries. \subsubsection{Synopsis} @@ -253,15 +253,16 @@ Initializes the given directory with an example configuration from \Prog{gourd-tutorial(7)} (rather than a custom template for \File{gourd.toml}). \item[\Opt{\ddash list-examples}] - Instead of initializing a folder, this will make \Prog{gourd} list all of the available + Instead of initializing a folder, this will make \Prog{gourd} list all the available examples for the \emph{-e} option. \item[\OptArg{\ddash git}{=true|false}] - Whether or not to initialize an empty git repository in the newly created folder. + Whether to initialize an empty git repository in the newly created folder. \end{Description} \subsubsection{Listing Examples} If \Opt{\ddash list-examples} is used, \Prog{gourd} \Arg{init} will not initialize a new folder with - a configuration. The \Arg{directory} argument will be ignored. + a configuration. + The \Arg{directory} argument will be ignored. A list of available examples and their descriptions will be printed to the output and the program will exit. @@ -280,6 +281,7 @@ \oOptArg{-i}{ run-id} \oOpt{\ddash follow} \oOpt{\ddash full} + \oOpt{\ddash after-out} \oArg{experiment-id} \subsubsection{Options} @@ -298,6 +300,8 @@ The status will be continually displayed until all of the runs have finished. This is useful when it is known that the jobs will finish in a matter of minutes. + \item[\Opt{\ddash after-out}] + Use only with \OptArg{-i}{ run-id}, displays the raw afterscript output for that run. \end{Description} \subsubsection{Experiment status} @@ -315,24 +319,37 @@ succeeded or failed. \subsubsection{Afterscripts} - To postprocess the output of the runs, there are two options available: afterscipts and Slurm - postprocessing jobs. Afterscripts are scripts that run locally (so for DelftBlue they do not - get scheduled as separate jobs) and are thus meant for quick and computationally inexpensive postprocessing - (such as getting the first line of the output file). For long or complicated postprocessing - with a significant computational cost, we support Slurm postprocessing jobs. A program being - evaluated can have both an afterscript and a postprocessing Slurm job, one of them, or neither. - - An afterscript is optional and specified per program. To indicate the use of an afterscript, - the path to the script file needs to be specified in the \File{gourd.toml} under the chosen program. - Multiple programs can use the same script. Furthermore, if at least one program has an - afterscript, a path to a folder that will store the afterscript output needs to be specified - (once for the entire experiment, analogous to metrics and output paths). The afterscript can - be used to assign labels to runs as a means of specifying custom status. - - An afterscript should take as arguments the path to the input file and the path to a folder - for output. The output folder has been created and is empty. It is the responsibility of the - afterscript to create and write to any files in that directory. - + To postprocess the output of the runs, there are two options available: \emph{afterscipts} and \emph{pipelining}. + Afterscripts are scripts that run locally (so for DelftBlue they do + \emph{not} get scheduled as separate jobs). + + Afterscripts are meant for quick and computationally inexpensive postprocessing + (such as getting the first line of the output file). + For long or complicated postprocessing with a significant computational cost, look at \emph{Pipelining}. + + \begin{itemize} + \item An afterscript is optional and specified per program. + \item To indicate the use of an afterscript, the path to the script file needs to be specified in the \File{gourd.toml} under the chosen program. + \item Multiple programs can use the same script. + \item The afterscript can be used to assign labels to runs as a means of specifying custom status. + \end{itemize} + + \emph{How to design an afterscript:} + + The afterscript should be an \underline{executable} file. This can be a normal compiled executable, or possibly a shell/python script if you use the appropriate \emph{shebang} at the start of the file (check out https://en.wikipedia.org/wiki/Shebang\_(Unix) for details). + + \Prog{gourd} will pass the path to a file containing the main program's output to the afterscript as a command line argument. + The afterscript can then print any output to \texttt{stdout} + (via \texttt{print}, \texttt{printf}, \texttt{println}, \texttt{echo} or your preferred language's method), + and \Prog{gourd} will collect that and display it in + \Prog{gourd}~\Arg{status}~\Arg{-i}~\Arg{} + + \emph{What can you do with afterscript output} + + \begin{itemize} + \item use \Prog{labels}, check out the corresponding chapter for more details. + \item create custom metrics: read in \Prog{gourd}~\Arg{analyse} for how to do this. + \end{itemize} \subsection{GOURD CONTINUE} \subsubsection{Summary} @@ -353,28 +370,45 @@ By default, this is the most recent experiment. \end{Description} - \subsubsection{Postprocessing Slurm jobs} - As discussed when describing \Prog{gourd} \Arg{status}, to postprocess the output of the runs, - there are two options available: afterscipts and Slurm postprocessing jobs. Afterscripts are - scripts that run locally (so for DelftBlue they do not get scheduled as separate jobs) and are - thus meant for quick and non-complicated postprocessing (such as getting the first line of the - output file). For long and complicated postprocessing with a significant computational cost, - we support Slurm postprocessing jobs. A program being evaluated can have both an afterscript - and a postprocessing Slurm job, one of them, or neither. - - A postprocesing Slurm job (further called "postprocessing job") is optional and specified per - program. To indicate the use of a postprocessing job, a program needs to be specified under - "postprocessing programs" in gourd.toml. That program will have the path to the postprocessing - binary file. In addition, the name of this new postprocesing program needs to be specified in - the gourd.toml under the chosen regular program to indicate that this is the postprocessing - used. Multiple programs can use the same postprocessing program. Furthermore, if at least one - program has a postprocessing job, a path to a folder that will store the postprocesing job - output needs to be specified (once for the entire experiment, analogous to metrics and output - paths). - - As input the postprocessing binary will get the output of a run of a regular program that has - been specified to have this postprocessing. It will write its results to a file the way that - regular programs do. + \subsubsection{Pipelining} + Programs may be pipelined by specifying the \texttt{next} programs in the sequence: + \begin{verbatim} +[program.your_first_progarm] +binary = "./executable" +next = ["a_second_program", "another_second_prog"] + +[program.a_second_program] +binary = "./executable2" + +[program.another_second_prog] +binary = "./executable3" + \end{verbatim} + See the manual for \texttt{gourd.toml} for more details on configuration. + + In the example above, when the runs for \texttt{your\_first\_progarm} finish, running + \Prog{gourd} \Arg{continue} will start one run for \texttt{a\_second\_program} and one for + \texttt{another\_second\_prog}, both of which will receive as input (to \texttt{stdin}) the + output (\texttt{stdout}) of \texttt{your\_first\_progarm}. + % As discussed when describing \Prog{gourd} \Arg{status}, to postprocess the output of the runs, + % there are two options available: afterscipts and Slurm postprocessing jobs. Afterscripts are + % scripts that run locally (so for DelftBlue they do not get scheduled as separate jobs) and are + % thus meant for quick and non-complicated postprocessing (such as getting the first line of the + % output file). For long and complicated postprocessing with a significant computational cost, + % we support Slurm postprocessing jobs. A program being evaluated can have both an afterscript + % and a postprocessing Slurm job, one of them, or neither. + % + % A postprocesing Slurm job (further called ``postprocessing job'') is optional and specified per + % program. To indicate the use of a postprocessing job, a program needs to be specified under + % ``postprocessing programs'' in gourd.toml. That program will have the path to the postprocessing + % binary file. In addition, the name of this new postprocesing program needs to be specified in + % the gourd.toml under the chosen regular program to indicate that this is the postprocessing + % used. Multiple programs can use the same postprocessing program. Furthermore, if at least one + % program has a postprocessing job, a path to a folder that will store the postprocesing job + % output needs to be specified (once for the entire experiment, analogous to metrics and output + % paths). + % + % As input, the postprocessing binary will get the output of a run of the regular program it is + % postprocessing. The postprocess results will be written to a file, the same as regular programs. \subsection{GOURD CANCEL} @@ -422,34 +456,73 @@ \subsubsection{Summary} The \Prog{gourd} \Arg{analyse} command collects and processes metrics generated - when an experiment was run. It can produce a CSV data file or a "cactus plot" + when an experiment was run. It can produce a CSV data file or a ``cactus plot'' to compare how quickly different algorithms run. \subsubsection{Synopsis} \Prog{gourd} \Arg{analyse} - \oOpt{GLOBAL OPTIONS} - \oOptArg{-o}{ format} \oArg{experiment-id} + \Arg{table}|\Arg{plot} + \oOpt{GLOBAL OPTIONS} + \oOptArg{-o}{ path/to/file} + \oOptArg{-f}{ format options} \subsubsection{Options} \begin{Description}[Options] - \item[\Arg{experiment-id}] - The ID of an experiment to analyse. - By default, this is the most recent experiment. - \item[\OptArg{-o}{ format}, \OptArg{\ddash output}{ format}] - The format of the desired analysis output. There are three available: - \emph{csv} (the default), \emph{plot-png}, \emph{plot-svg}. They are described below. + \item[\Arg{experiment-id}] + The ID of an experiment to analyse. + By default, this is the most recent experiment. + \item[\OptArg{-o}{ path/to/file}, \OptArg{\ddash output}{ path/to/file}] + Pass the command's output to a file. + \item[\OptArg{-f}{ format options}, \OptArg{\ddash format}{ format options}] + Formatting options for the \Arg{table} and \Arg{plot} subcommands. \end{Description} \subsubsection{Metrics CSV} - Running \Prog{gourd} \Arg{analyse} \OptArg{-o}{ csv} will create a CSV file with + Running \Prog{gourd} \Arg{analyse} \Arg{table} will create a table with data about the status of the runs, metrics, and afterscript completion, unless there - are no runs that have completed already. + are no completed runs. + If the \Arg{-o} option is not passed, the table will be pretty-printed in the + command line, otherwise a CSV file will be saved to the specified path. The CSV generation will take into account all runs of the experiment. If \Prog{gourd} \Arg{analyse} is rerun, the CSV will be updated with the newest status of the runs. + The option \texttt{--format} takes a comma-separated list of columns to use + in the table. Without specifying this option, \Prog{gourd} will default to + \texttt{--format="program,slurm,fs-status,wall-time"}. The first column will + always contain the \texttt{run id}. + The possible options are: + \begin{description} + \item[\texttt{program}] program name + \item[\texttt{file}] the input file this run was executed with, if there was one + \item[\texttt{args}] command-line arguments passed to the program + \item[\texttt{group}] the input group, if there is one + \item[\texttt{label}] any label-associated status + \item[\texttt{afterscript}] afterscript status string + \item[\texttt{slurm}] run status retrieved from the slurm daemon + \item[\texttt{fs-status}] run status retrieved from the file system + \item[\texttt{exit-code}] program's exit code + \item[\texttt{wall-time}] total elapsed real (wall-clock) time + \item[\texttt{user-time}] CPU time spent in user mode + \item[\texttt{system-time}] CPU time spent in kernel (system-call) mode + \item[\texttt{max-rss}] peak resident set size (maximum RAM used) + \item[\texttt{ix-rss}] integral of shared memory size over the run (ru\_ixrss) + \item[\texttt{id-rss}] integral of unshared data segment size (ru\_idrss) + \item[\texttt{is-rss}] integral of unshared stack size (ru\_isrss) + \item[\texttt{min-flt}] number of minor page faults + \item[\texttt{maj-flt}] number of major page faults + \item[\texttt{n-swap}] total swap operations performed + \item[\texttt{in-block}] number of block input operations (disk reads) + \item[\texttt{ou-block}] number of block output operations (disk writes) + \item[\texttt{msg-sent}] number of inter-process messages sent + \item[\texttt{msg-recv}] number of inter-process messages received + \item[\texttt{n-signals}] number of signals delivered to the process + \item[\texttt{nv-csw}] voluntary context switches count + \item[\texttt{n-iv-csw}] involuntary context switches count + \end{description} + \subsubsection{Cactus plots} - Running \Prog{gourd} \Arg{analyse} \OptArg{-o}{ plot-png} will create a PNG picture of + Running \Prog{gourd} \Arg{analyse} \Arg{plot} will create a PNG picture of a cactus plot. The cactus plot is used to showcase the comparison of how many inputs each algorithm can finish running with in a given amount of time. @@ -458,54 +531,11 @@ This allows to see a visual comparison of the time each program takes - the more runs there are, the more informative the plot will result to be. The plot will take into account only the runs that have completed and have valid - RUsage data. If \Prog{gourd} \Arg{analyse} is rerun, the graph will be updated + RUsage data. If \Prog{gourd} \Arg{analyse} \Arg{plot} is rerun, the graph will be updated according to the newest available data. - Running \Prog{gourd} \Arg{analyse} \OptArg{-o}{ plot-svg} will create exactly the same - plot but in a \emph{svg} conformant format. - - \subsection{GOURD SET-LIMITS} - - \subsubsection{Summary} - The \Prog{gourd} \Arg{set-limits} command allows to update resource limits for programs. - The new resource limits will affect future runs that have these programs. - - \subsubsection{Synopsis} - \Prog{gourd} \Arg{set-limits} - \oOpt{GLOBAL OPTIONS} - \oOptArg{-p}{ program-name} - \oOpt{-a} - \oOptArg{--mem}{ memory-limit} - \oOptArg{--cpu}{ cpu-limit} - \oOptArg{--time}{ time-limit} - \oArg{experiment-id} - - \subsubsection{Options} - \begin{Description}[Options] - \item[\Arg{experiment-id}] - The ID of an experiment to modify limits of. - By default, this is the most recent experiment. - \item[\Opt{-a, \ddash all}] - Changes resource limits for all available programs, including postprocessing programs. - \item[\OptArg{-p, \ddash program}{ program-name}] - Changes resource limits for the specified program (simple or postprocesing). - \item[\OptArg{\ddash mem}{ memory-limit}] - Allows to specify the new memory limit (a number) instead of asking for it interactively. - \item[\OptArg{\ddash cpu}{ cpu-limit}] - Allows to specify the new cpu limit (a number) instead of asking for it interactively. - \item[\OptArg{\ddash time}{ time-limit}] - Allows to specify the new time limit (in humantime format) instead of asking for it - interactively. - \end{Description} - - \subsubsection{Programs} - Specifying a program to modify the limits for is required. - One can either specify the \Opt{-p} flag with a specific program - or \Opt{-a} to modify limits for all programs - - \subsubsection{Scripting} - When ran with \Opt{-s}, \Arg{set-limits} will not ask for the properties - not specified by command line options, instead it will exit with an error. + The option \texttt{--format} can be used to specify whether the plot output + should be in PNG or SVG format. \subsection{GOURD VERSION} diff --git a/docs/user/gourd.toml.5.tex b/docs/user/gourd.toml.5.tex index 184e22c..01b4278 100644 --- a/docs/user/gourd.toml.5.tex +++ b/docs/user/gourd.toml.5.tex @@ -13,10 +13,10 @@ \newcommand{\thecommand}{GOURD.TOML} \newcommand{\mansection}{1} \newcommand{\mansectionname}{File Formats Manual} -\newcommand{\mandate}{19 AUGUST 2024} -\setDate{19 AUGUST 2024} +\newcommand{\mandate}{25 MARCH 2025} +\setDate{25 MARCH 2025} \setVersionWord{Version:} -\setVersion{1.1.2} +\setVersion{1.2.0} \input{docs/user/latex2man_styling.tex} @@ -66,7 +66,7 @@ \item[boolean] Either \emph{true} or \emph{false}. \item[number] Zero or a positive number. \item[regex] A regular expression. - \item[duration] An human readable amount of time, for example: "2d 4h". + \item[duration] A human-readable amount of time, for example: "2d 4h". \item[list of T] A list of values of type T surrounded by []. \end{Description} @@ -76,38 +76,30 @@ \begin{Description}[Options]\setlength{\itemsep}{0cm} \item[\Opt{output\_path} = path] - This path specifies where to store the stdout, stderr, and afterscript - outputs for \emph{programs}. + This path specifies where to store the stdout and stderr outputs for \emph{programs}. \item[\Opt{metrics\_path} = path] Where to store the metrics for \Prog{gourd(1)} \Arg{status}. These metrics contain information such as: Wall clock time, - User time, System time, The amount of context switches etc. + User time, System time, the amount of context switches etc. \item[\Opt{experiments\_folder} = path] Where to store state of previously ran experiments. - Essentially this folder specifies where \Prog{gourd} will store - all of its information about experiments. If this folder is removed - \Prog{gourd} looses all information about past experiments. + Essentially this folder specifies where \Prog{gourd} will store all of its information about experiments. + If this folder is removed \Prog{gourd} looses all information about past experiments. \item[\Opt{wrapper?} = path] Defines the path to the \File{gourd\_wrapper} binary. \\ \\ - The default value is \emph{gourd\_wrapper}. (That is \Prog{gourd} - will look for it in the \texttt{\$PATH}) \\ \\ - If you installed \Prog{gourd} correctly this values should not be - changed. + The default value is \emph{gourd\_wrapper}. + (That is, \Prog{gourd} will look for it in the \texttt{\$PATH}) \\ \\ + If you installed \Prog{gourd} correctly this values should not be changed. \item[\Opt{input\_schema?} = path] - Defines the path to a optional machine generated input schema. \\ \\ + Defines the path to an optional machine generated input schema. \\ \\ For more information about this continue to the \textbf{INPUT SCHEMA} section. \\ \\ The default values is no input schema. - - \item[\Opt{warn\_on\_label\_overlap?} = bool] - Information about this can be found in the \textbf{LABELS} - section. \\ \\ - The default value is \emph{false}. \end{Description} \section{SLURM} @@ -127,12 +119,12 @@ The name under which runs for this experiment will be scheduled on Slurm. \item[\Opt{output\_folder} = path] - The folder where the raw slurm outputs will be stored. This can be useful - for debugging. + The folder where the raw slurm outputs will be stored. + This can be useful for debugging. \item[\Opt{partition} = string] - The partition on which this should be ran on the supercomputer. + The partition on which this should be run on the supercomputer. Running \Prog{gourd} \Arg{run} \Arg{slurm} with an invalid partition - will display all of the valid partitions. + will display all the valid partitions. \item[\Opt{array\_size\_limit?} = number] This specifies the limit of runs that can be put in one Slurm batch. \Prog{gourd} will work to split the workload such that this limit @@ -145,6 +137,20 @@ \item[\Opt{account} = string] Which account to use for running jobs on Slurm. For example one account available on DelftBlue is "Education-EEMCS-MSc-CS". + To get a list of available accounts on your cluster, + you can use Slurm's \Prog{sacctmgr}~\Arg{show}~\Arg{account} command + \item[\Opt{mail\_type} = string] + Choose one of Slurm's options for sending emails when a run's status changes. + Default is "NONE". Valid options are: + \texttt{"NONE", "BEGIN", "END", "FAIL", "REQUEUE", "ALL", "INVALID\_DEPEND", "STAGE\_OUT", "TIME\_LIMIT", "TIME\_LIMIT\_90", "TIME\_LIMIT\_80", "TIME\_LIMIT\_50", "ARRAY\_TASKS"} + \item[\Opt{mail\_user} = string] + Your email address, if you want to receive email updates from Slurm. + \item[\Opt{begin} = string] + Submit runs to the Slurm controller immediately, like normal, but tell the + controller to defer the allocation of the job until the specified time. + + Time may be of the form \texttt{HH:MM:SS} or \texttt{HH:MM} to run a job at a specific time of day, + or in the \texttt{now+} format (for example, \texttt{now+1hour}, or \texttt{now+32min}) \item[\Opt{additional\_args?} = list of string] Custom arguments for Slurm. \\ \\ By default there are no additional arguments. @@ -155,7 +161,7 @@ \begin{verbatim} [slurm] -experiment_name = "test experiment" +experiment_name = "my test experiment" output_folder = "./slurmout/" partition = "compute" account = "Education-EEMCS-MSc-CS" @@ -187,17 +193,10 @@ mem_per_cpu = 512 \end{verbatim} - \subsubsection{Postprocessing Resource Limit} - - If \Prog{gourd} is using postprocessing jobs a separate section - (with the same fields) called \\ - \texttt{[postprocess\_resource\_limits]} must be specified. - It holds the same fields. - \section{PROGRAMS} Multiple programs can be specified. - A program represents a compiled algorithm and is a combination of a binary file and parameters. + A program represents a compiled executable and is a combination of a binary file and parameters. Each program begins with \Arg{[programs.program-name]}, where \Arg{program-name} can be any unique name. \begin{Description}[Options]\setlength{\itemsep}{0cm} @@ -206,7 +205,7 @@ \item[\Opt{fetch} = fetched\_path] URL to the program executable. \item[\Opt{git} = git\_object] - See the \textbf{ALGORITHM VERSIONS} section for more information. \\ \\ + See the \textbf{PROGRAM VERSIONS} section for more information. \\ \\ \item[\Opt{arguments?} = list of string] Arguments to be passed to the executable. \\ \\ By default an empty list. @@ -223,7 +222,7 @@ By default, use the global resource limits. \end{Description} - Only one of \Opt{binary}, \Opt{fetch} must be specified. + Only one of \Opt{binary}, \Opt{fetch}, \Opt{git} must be specified. \subsection{EXAMPLE} @@ -454,7 +453,7 @@ \begin{itemize} \item Be executable. \item Will receive the path to the jobs output as the first CLI parameter. - \item Write its output to the path received as the second CLI parameter. + \item Print its output to the standard output \texttt{stdout}. \end{itemize} \subsection{EXAMPLE} @@ -463,23 +462,24 @@ [program.test_program] binary = "./algorithm" arguments = [] -afterscript = "./script.sh" +afterscript = "./script" \end{verbatim} After running the job the after script will be called as: \begin{verbatim} -script.sh path/to/job/stdout path/to/afterscript/output +script path/to/job/stdout \end{verbatim} And for example if: - in \textbf{script.sh}: + in \textbf{script}: \begin{verbatim} -cat $1 > $2 +#!/bin/sh +cat $1 \end{verbatim} - The afterscripts output will be the jobs output (ie. No postprocessing happened). + The afterscript's output will be exactly the jobs output (ie. No postprocessing happened). But these scripts may be more complex if the use case requires it. \section{LABELS} @@ -504,6 +504,11 @@ \item[\Opt{priority} = number] In the case that more than one label matches a run the \textbf{highest} priority label will be assigned. + Higher priority value = higher priority. + Default is 0. + Note that if two or more labels have the same priority and are both present + at the same time, the result is undefined behaviour. + Set `warn\_on\_label\_overlap` to `true` to prevent this. \item[\Opt{rerun\_by\_default?} = boolean] If true makes this label essentially mean `failure', in the sense that @@ -594,9 +599,9 @@ inside, then when there is a need for cleaning the cache this amounts to deleting the folder - \section{ALGORITHM VERSIONS} + \section{PROGRAM VERSIONS} - Algorithms may be fetched and compiled straight from a git repository. + Programs may be fetched and compiled straight from a git repository. The user in this case has to provide the commit ID of the desired HEAD, the build command and the path to the output binary. diff --git a/src/gourd/analyse/csvs.rs b/src/gourd/analyse/csvs.rs new file mode 100644 index 0000000..4ad4bcf --- /dev/null +++ b/src/gourd/analyse/csvs.rs @@ -0,0 +1,301 @@ +use std::time::Duration; + +use anyhow::Result; +use gourd_lib::experiment::Experiment; +use gourd_lib::measurement::Measurement; +use gourd_lib::measurement::RUsage; + +use crate::analyse::ColumnGenerator; +use crate::analyse::Table; +use crate::cli::def::CsvColumn; +use crate::cli::def::CsvFormatting; +use crate::cli::def::GroupBy; +use crate::status::ExperimentStatus; +use crate::status::FsState; +use crate::status::Status; + +/// Shorthand for creating a [`ColumnGenerator`] with a str header and a closure +/// body. +/// +/// Note that the closure must be coercible to a function pointer. +fn create_column( + header: &str, + body: fn(&Experiment, &X) -> Result, +) -> ColumnGenerator { + ColumnGenerator { + header: Some(header.to_string()), + body, + footer: |_, _| Ok(None), + } +} + +/// Same as [`create_column`], but with a footer closure. +fn create_column_full( + header: &str, + body: fn(&Experiment, &X) -> Result, + footer: fn(&Experiment, &[X]) -> Result>, +) -> ColumnGenerator { + ColumnGenerator { + header: Some(header.to_string()), + body, + footer, + } +} + +/// Shorthand to create a column generator for a metric that is derived from the +/// `rusage` +// We cannot use a higher order function here because [`ColumnGenerator`] takes +// an fn() -> .. and not a closure (impl Fn()), for conciseness and readability +// there. the downside is that you can't use any environment variables in the +// closure, and that includes arguments passed to the higher order function. +// Macros are evaluated before compilation and thus circumvent this issue. +macro_rules! rusage_metrics { + ($name:expr, $field:expr) => { + create_column_full( + $name, + |_, x| { + Ok(match &x.1.fs_status.completion { + FsState::Completed(Measurement { + rusage: Some(r), .. + }) => format!("{:?}", $field(r)), + _ => "N/A".to_string(), + }) + }, + |_, runs| { + let (total, n) = runs.iter().fold((0, 0), |(sum, count), run| { + match &run.1.fs_status.completion { + FsState::Completed(Measurement { + rusage: Some(r), .. + }) => (sum + $field(r), count + 1), + _ => (sum, count), + } + }); + + Ok(Some(format!("{:.2}", ((total as f64) / (n as f64))))) + }, + ) + }; +} + +/// Get a [`ColumnGenerator`] for every possible column of [`CsvColumn`]. +pub fn metrics_generators(col: CsvColumn) -> ColumnGenerator<(usize, Status)> { + match col { + CsvColumn::Program => create_column("program", |exp: &Experiment, x: &(usize, Status)| { + Ok(exp.get_program(&exp.runs[x.0])?.name.clone()) + }), + CsvColumn::File => create_column("input file", |exp, x: &(usize, Status)| { + Ok(exp.runs[x.0] + .input + .file + .as_ref() + .map_or("None".to_string(), |p| format!("{p:?}"))) + }), + CsvColumn::Args => create_column("input args", |exp, x: &(usize, Status)| { + Ok(format!("{:?}", &exp.runs[x.0].input.args)) + }), + CsvColumn::Group => create_column("group", |exp: &Experiment, x: &(usize, Status)| { + Ok(exp.runs[x.0].group.clone().unwrap_or("N/A".to_string())) + }), + CsvColumn::Label => create_column("label", |_, x| { + Ok(x.1 + .fs_status + .afterscript_completion + .clone() + .unwrap_or(Some("N/A".to_string())) + .unwrap_or("no label".to_string())) + }), + CsvColumn::Afterscript => create_column("afterscript", |exp, x| { + exp.runs[x.0] + .afterscript_output + .as_ref() + .map_or(Ok("N/A".to_string()), |p| Ok(p.trim().to_string())) + }), + CsvColumn::Slurm => create_column("slurm", |_, x| { + Ok(x.1 + .slurm_status + .map_or("N/A".to_string(), |x| x.completion.to_string())) + }), + CsvColumn::FsStatus => create_column("fs status", |_, x| { + Ok(format!("{:-}", x.1.fs_status.completion)) + }), + CsvColumn::ExitCode => ColumnGenerator { + header: Some("exit".to_string()), + body: |_, x: &(usize, Status)| { + Ok(match &x.1.fs_status.completion { + FsState::Completed(measurement) => { + format!("{:?}", measurement.exit_code) + } + _ => "N/A".to_string(), + }) + }, + footer: |_, _| Ok(None), + }, + CsvColumn::WallTime => create_column_full( + "wall time", + |_, x| { + Ok(match &x.1.fs_status.completion { + FsState::Completed(measurement) => { + format!("{:.5}s", measurement.wall_micros.as_secs_f32()) + } + _ => "N/A".to_string(), + }) + }, + |_, runs| { + let (dt, n) = runs.iter().fold((0, 0), |(sum, count), run| { + match &run.1.fs_status.completion { + FsState::Completed(m) => (sum + m.wall_micros.as_nanos(), count + 1), + _ => (sum, count), + } + }); + + Ok(Some(format!( + "{:.5}s", + Duration::from_nanos((dt.checked_div(n).unwrap_or_default()) as u64) + .as_secs_f32() + ))) + }, + ), + CsvColumn::UserTime => create_column_full( + "user time", + |_, x| { + Ok(match &x.1.fs_status.completion { + FsState::Completed(Measurement { + rusage: Some(r), .. + }) => format!("{:.5}s", r.utime.as_secs_f32()), + _ => "N/A".to_string(), + }) + }, + |_, runs| { + let (dt, n) = runs.iter().fold((0, 0), |(sum, count), run| { + match &run.1.fs_status.completion { + FsState::Completed(Measurement { + rusage: Some(r), .. + }) => (sum + r.utime.as_nanos(), count + 1), + _ => (sum, count), + } + }); + + Ok(Some(format!( + "{:.5}s", + Duration::from_nanos((dt.checked_div(n).unwrap_or_default()) as u64) + .as_secs_f32() + ))) + }, + ), + CsvColumn::SystemTime => create_column_full( + "system time", + |_, x| { + Ok(match &x.1.fs_status.completion { + FsState::Completed(Measurement { + rusage: Some(r), .. + }) => format!("{:.5}s", r.stime.as_secs_f32()), + _ => "N/A".to_string(), + }) + }, + |_, runs| { + let (dt, n) = runs.iter().fold((0, 0), |(sum, count), run| { + match &run.1.fs_status.completion { + FsState::Completed(Measurement { + rusage: Some(r), .. + }) => (sum + r.stime.as_nanos(), count + 1), + _ => (sum, count), + } + }); + + Ok(Some(format!( + "{:.5}s", + Duration::from_nanos((dt.checked_div(n).unwrap_or_default()) as u64) + .as_secs_f32() + ))) + }, + ), + + CsvColumn::MaxRSS => rusage_metrics!("max rss", |r: &RUsage| r.maxrss), + CsvColumn::IxRSS => rusage_metrics!("shared mem size", |r: &RUsage| r.ixrss), + CsvColumn::IdRSS => rusage_metrics!("unshared mem size", |r: &RUsage| r.idrss), + CsvColumn::IsRSS => rusage_metrics!("unshared stack size", |r: &RUsage| r.isrss), + CsvColumn::MinFlt => rusage_metrics!("soft page faults", |r: &RUsage| r.minflt), + CsvColumn::MajFlt => rusage_metrics!("hard page faults", |r: &RUsage| r.majflt), + CsvColumn::NSwap => rusage_metrics!("swaps", |r: &RUsage| r.nswap), + CsvColumn::InBlock => rusage_metrics!("block input operations", |r: &RUsage| r.inblock), + CsvColumn::OuBlock => rusage_metrics!("block output operations", |r: &RUsage| r.oublock), + CsvColumn::MsgSent => rusage_metrics!("IPC messages sent", |r: &RUsage| r.msgsnd), + CsvColumn::MsgRecv => rusage_metrics!("IPC messages received", |r: &RUsage| r.msgrcv), + CsvColumn::NSignals => rusage_metrics!("signals received", |r: &RUsage| r.nsignals), + CsvColumn::NVCsw => rusage_metrics!("voluntary context switches", |r: &RUsage| r.nvcsw), + CsvColumn::NIvCsw => rusage_metrics!("involuntary context switches", |r: &RUsage| r.nivcsw), + } +} + +/// Generate a [`Table`] of metrics for this experiment. +/// TODO: better documentation +pub fn metrics_table( + experiment: &Experiment, + header: Vec, + status_tuples: Vec<(usize, Status)>, +) -> Result { + let mut metrics_table = Table { + columns: 1, + header: Some(vec!["run id".into()]), + body: status_tuples + .iter() + .map(|(id, _)| vec![format!("{id}")]) + .collect(), + footer: Some(vec!["average".into()]), + }; + + for column_name in header { + let column = metrics_generators(column_name).generate(experiment, &status_tuples)?; + metrics_table.append_column(column); + } + + Ok(metrics_table) +} + +/// Generate a vector of [`Table`]s from an experiment and its status. +pub fn tables_from_command( + experiment: &Experiment, + statuses: &ExperimentStatus, + fmt: CsvFormatting, +) -> Result> { + let header = fmt.format.unwrap_or(vec![ + CsvColumn::Program, + CsvColumn::Slurm, + CsvColumn::FsStatus, + CsvColumn::WallTime, + ]); + + let mut groups: Vec> = vec![statuses.clone().into_iter().collect()]; + + for condition in fmt.group { + let mut temp = vec![]; + for g in groups { + match condition { + GroupBy::Group => { + g.chunk_by(|(a_id, _), (b_id, _)| { + experiment.runs[*a_id].group == experiment.runs[*b_id].group + }) + .for_each(|x| temp.push(x.to_vec())); + } + GroupBy::Input => { + g.chunk_by(|(a_id, _), (b_id, _)| { + experiment.runs[*a_id].input == experiment.runs[*b_id].input + }) + .for_each(|x| temp.push(x.to_vec())); + } + GroupBy::Program => { + g.chunk_by(|(a_id, _), (b_id, _)| { + experiment.runs[*a_id].program == experiment.runs[*b_id].program + }) + .for_each(|x| temp.push(x.to_vec())); + } + } + } + groups = temp; + } + + groups + .into_iter() + .map(|runs| metrics_table(experiment, header.clone(), runs)) + .collect() +} diff --git a/src/gourd/analyse/mod.rs b/src/gourd/analyse/mod.rs index bfd4ab2..8f776cf 100644 --- a/src/gourd/analyse/mod.rs +++ b/src/gourd/analyse/mod.rs @@ -1,150 +1,223 @@ use std::cmp::max; use std::collections::BTreeMap; +use std::fmt::Display; +use std::fmt::Formatter; +use std::io::Write; use std::path::Path; use std::time::Duration; -use anyhow::anyhow; use anyhow::Context; use anyhow::Result; use csv::Writer; use gourd_lib::bailc; -use gourd_lib::constants::PLOT_SIZE; use gourd_lib::experiment::Experiment; use gourd_lib::experiment::FieldRef; -use gourd_lib::measurement::RUsage; -use log::debug; -use plotters::prelude::*; -use plotters::style::register_font; -use plotters::style::BLACK; -use crate::status::FileSystemBasedStatus; use crate::status::FsState; -use crate::status::SlurmBasedStatus; use crate::status::Status; -/// Plot width, size, and data to plot. -type PlotData = (u128, u128, BTreeMap>); - -/// Collect and export metrics. -pub fn analysis_csv(path: &Path, statuses: BTreeMap) -> Result<()> { - let mut writer = Writer::from_path(path)?; +/// Export experiment data as CSV file +pub mod csvs; +/// Draw up plots of experiment data +pub mod plotting; + +/// Represent a human-readable table. +/// Universal between CSV exporting and in-line display. +/// +/// Since tables store the display strings, their entries are in essence +/// immutable. Cells are not meant to be read or modified, since that would +/// likely involve parsing the number in it, which is just unhygienic. +/// +/// You can append rows to a table with [`Table::append_column`], +/// or create new columns using [`ColumnGenerator`]s. +#[derive(Debug, Clone)] +pub struct Table { + /// Number of columns in the table. + pub columns: usize, + /// CSV-style table header. + pub header: Option>, + /// The table entries (vector of rows, each row is a vector of entries) + /// (`Vec>`). + pub body: Vec>, + /// An optional footer, can be used to aggregate statistics, for example. + pub footer: Option>, +} - let header = vec![ - "id".to_string(), - "file system status".to_string(), - "wall micros".to_string(), - "exit code".to_string(), - "RUsage".to_string(), - "afterscript output".to_string(), - "slurm completion".to_string(), - ]; +/// A column that can be appended to the end of a [`Table`]. +/// +/// Intended to be created through a [`ColumnGenerator`]. +#[derive(Debug, Clone)] +pub struct Column { + /// The text header of the column. Defaults to empty string + pub header: Option, + /// The row cells of this column + pub body: Vec, + /// The footer cell of this column. Defaults to empty string. + pub footer: Option, +} - writer.write_record(header)?; +/// Create a [`Column`] from a list of entries of type `X`. +#[derive(Debug, Clone)] +pub struct ColumnGenerator { + /// The text header of the column. Defaults to empty string + pub header: Option, + /// A function to convert a type `X` element into the content of its + /// equivalent row in the column body. + pub body: fn(&Experiment, &X) -> Result, + /// A footer cell that can hold info aggregated + /// from all the entries in the original list. + pub footer: fn(&Experiment, &[X]) -> Result>, +} - for (id, status) in statuses { - let fs_status = &status.fs_status; - let slurm_status = status.slurm_status; +impl ColumnGenerator { + /// Generate a column from a vector of entries. + pub fn generate(&self, exp: &Experiment, from: &[X]) -> Result { + Ok(Column { + header: self.header.clone(), + body: from + .iter() + .map(|x| (self.body)(exp, x)) + .collect::>>()?, + footer: (self.footer)(exp, from)?, + }) + } +} - let mut record = get_fs_status_info(id, fs_status); - record.append(&mut get_afterscript_output_info( - &status.fs_status.afterscript_completion, - )); - record.append(&mut get_slurm_status_info(&slurm_status)); +impl Table { + /// Get the width (in utf-8 characters) of the longest entry of each column + pub fn column_widths(&self) -> Vec { + let mut col_widths = vec![0; self.columns]; + + for row in self + .header + .iter() + .chain(self.body.iter()) + .chain(self.footer.iter()) + { + for (i, x) in col_widths + .clone() + .iter() + .zip(row.iter().map(|x| x.to_string().chars().count())) + .map(|(a, b)| *max(a, &b)) + .enumerate() + { + col_widths[i] = x; + } + } - writer.write_record(record)?; + col_widths } - writer.flush()?; - - Ok(()) -} + /// Write this table to a [`Writer`] + pub fn write_csv(&self, writer: &mut Writer) -> Result<()> { + if let Some(h) = &self.header { + writer.write_record(h)?; + } -/// Gets file system info for CSV. -pub fn get_fs_status_info(id: usize, fs_status: &FileSystemBasedStatus) -> Vec { - let mut completion = match fs_status.completion { - FsState::Pending => vec![ - "pending".to_string(), - "...".to_string(), - "...".to_string(), - "...".to_string(), - ], - FsState::Running => vec![ - "running".to_string(), - "...".to_string(), - "...".to_string(), - "...".to_string(), - ], - FsState::Completed(measurement) => { - vec![ - "completed".to_string(), - format!("{:?}", measurement.wall_micros), - format!("{:?}", measurement.exit_code), - format_rusage(measurement.rusage), - ] + for row in &self.body { + writer.write_record(row)?; } - }; - let mut res = vec![id.to_string()]; - res.append(&mut completion); + // the footer is omitted in csv output to make analysis easier. - res -} + Ok(()) + } -/// Formats RUsage of a run for the CSV. -pub fn format_rusage(rusage: Option) -> String { - if rusage.is_some() { - format!("{:#?}", rusage.unwrap()) - } else { - String::from("none") + /// Write this table to a file at the given path. + pub fn write_to_path(&self, path: &Path) -> Result<()> { + let mut writer = Writer::from_path(path).context("Failed to open file for writing")?; + self.write_csv(&mut writer)?; + writer.flush()?; + Ok(()) } -} -/// Gets slurm status info for CSV. -pub fn get_slurm_status_info(slurm_status: &Option) -> Vec { - if let Some(inner) = slurm_status { - vec![format!("{:#?}", inner.completion)] - } else { - vec!["...".to_string()] + /// Append a column to the table. + // Known issue: https://github.com/rust-lang/rust-clippy/issues/13185 + #[allow(clippy::manual_inspect)] + pub fn append_column(&mut self, column: Column) { + self.columns += 1; + self.header = self + .header + .as_mut() + .map(|h| { + h.push(column.header.clone().unwrap_or_default()); + h + }) + .cloned(); + debug_assert_eq!(self.body.len(), column.body.len()); + self.body = self + .body + .iter_mut() + .zip(column.body.iter()) + .map(|(a, b)| { + a.push(b.clone()); + a.clone() + }) + .collect(); + self.footer = self + .footer + .as_mut() + .map(|f| { + f.push(column.footer.clone().unwrap_or_default()); + f + }) + .cloned(); } } -/// Gets afterscript output info for CSV. -pub fn get_afterscript_output_info(afterscript_completion: &Option>) -> Vec { - if let Some(inner) = afterscript_completion { - if let Some(label) = inner { - vec![label.clone()] +impl Display for Table { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if f.sign_minus() { + // reduced output + for row in self.body.iter() { + writeln!(f)?; + for value in row.iter() { + write!(f, "{value}\t")?; + } + } } else { - vec![String::from("done, no label")] - } - } else { - vec![String::from("no afterscript")] - } -} + writeln!(f)?; + let col_widths = self.column_widths(); + if let Some(header) = &self.header { + for (width, value) in col_widths.iter().zip(header.iter()) { + write!(f, "| {value: , - experiment: Experiment, - is_png: bool, -) -> Result<()> { - let completions = get_completions(statuses, experiment)?; + for width in col_widths.iter() { + write!(f, "*-{}-", "-".repeat(*width))?; + } + writeln!(f, "*")?; + } - let data = get_data_for_plot(completions); + for row in self.body.iter() { + for (width, value) in col_widths.iter().zip(row.iter()) { + write!(f, "| {value: , - experiment: Experiment, + experiment: &Experiment, ) -> Result>> { let mut completions: BTreeMap> = BTreeMap::new(); @@ -192,102 +265,6 @@ pub fn get_completion_time(state: FsState) -> Result { } } -/// Get wall clock data for cactus plot. -pub fn get_data_for_plot(completions: BTreeMap>) -> PlotData { - let max_time = completions.values().flatten().max(); - let mut data = BTreeMap::new(); - - if max_time.is_some() { - let max_time = *max_time.unwrap(); - let mut max_count = 0; - - for (name, program) in completions { - let mut data_per_program = vec![]; - let mut already_finished = 0; - - for end in program { - if end > 0 { - data_per_program.push((end - 1, already_finished)); - } - - already_finished += 1; - data_per_program.push((end, already_finished)); - } - - data_per_program.push((max_time, already_finished)); - - max_count = max(max_count, already_finished); - - data.insert(name, data_per_program); - } - - (max_time, max_count, data) - } else { - (0, 0, data) - } -} - -/// Plot the results of runs in a cactus plot. -pub fn make_plot(plot_data: PlotData, backend: T) -> Result<()> -where - T: DrawingBackend, - ::ErrorType: 'static, -{ - debug!("Drawing a plot"); - - let (max_time, max_count, cactus_data) = plot_data; - - register_font( - "sans-serif", - FontStyle::Normal, - include_bytes!("../../resources/LinLibertine_R.otf"), - ) - .map_err(|_| anyhow!("Could not load the font"))?; - - let style = TextStyle::from(("sans-serif", 20).into_font()).color(&BLACK); - let root = backend.into_drawing_area(); - - root.fill(&WHITE)?; - - let mut chart = ChartBuilder::on(&root) - .margin(20) - .x_label_area_size(40) - .y_label_area_size(40) - .caption("Cactus plot", 40) - .build_cartesian_2d(0..max_time + 1, 0..max_count + 1)?; - - chart - .configure_mesh() - .light_line_style(WHITE) - .x_label_style(style.clone()) - .y_label_style(style.clone()) - .label_style(style.clone()) - .x_desc("Nanoseconds") - .y_desc("Runs") - .draw()?; - - for (idx, (name, datas)) in (0..).zip(cactus_data) { - chart - .draw_series(LineSeries::new( - datas, - Into::::into(Palette99::pick(idx)).stroke_width(3), - ))? - .label(name.to_string()) - .legend(move |(x, y)| { - Rectangle::new( - [(x - 5, y - 5), (x + 5, y + 5)], - Palette99::pick(idx).stroke_width(5), - ) - }); - } - - chart.configure_series_labels().label_font(style).draw()?; - - root.present()?; - - Ok(()) -} - #[cfg(test)] #[path = "tests/mod.rs"] mod tests; diff --git a/src/gourd/analyse/plotting.rs b/src/gourd/analyse/plotting.rs new file mode 100644 index 0000000..1354145 --- /dev/null +++ b/src/gourd/analyse/plotting.rs @@ -0,0 +1,146 @@ +use std::cmp::max; +use std::collections::BTreeMap; +use std::path::Path; +use std::path::PathBuf; + +use anyhow::anyhow; +use anyhow::Result; +use gourd_lib::constants::PLOT_SIZE; +use gourd_lib::experiment::Experiment; +use gourd_lib::experiment::FieldRef; +use log::debug; +use plotters::backend::BitMapBackend; +use plotters::backend::DrawingBackend; +use plotters::backend::SVGBackend; +use plotters::chart::ChartBuilder; +use plotters::drawing::IntoDrawingArea; +use plotters::element::Rectangle; +use plotters::prelude::*; +use plotters::style::register_font; +use plotters::style::Palette; + +use crate::analyse::get_completions; +use crate::cli::def::PlotType; +use crate::status::ExperimentStatus; + +/// Plot width, size, and data to plot. +pub(super) type PlotData = (u128, u128, BTreeMap>); + +/// Get data for plotting and generate plots. +pub fn analysis_plot( + path: &Path, + statuses: ExperimentStatus, + experiment: &Experiment, + plot_type: PlotType, +) -> Result { + let completions = get_completions(statuses, experiment)?; + + let data = get_data_for_plot(completions); + + match plot_type { + PlotType::Png => make_plot(data, BitMapBackend::new(&path, PLOT_SIZE))?, + PlotType::Svg => make_plot(data, SVGBackend::new(&path, PLOT_SIZE))?, + } + + Ok(path.into()) +} + +/// Get wall clock data for cactus plot. +pub fn get_data_for_plot(completions: BTreeMap>) -> PlotData { + let max_time = completions.values().flatten().max(); + let mut data = BTreeMap::new(); + + if let Some(mt) = max_time { + let max_time = *mt; + let mut max_count = 0; + + for (name, program) in completions { + let mut data_per_program = vec![]; + let mut already_finished = 0; + + for end in program { + if end > 0 { + data_per_program.push((end - 1, already_finished)); + } + + already_finished += 1; + data_per_program.push((end, already_finished)); + } + + data_per_program.push((max_time, already_finished)); + + max_count = max(max_count, already_finished); + + data.insert(name, data_per_program); + } + + (max_time, max_count, data) + } else { + (0, 0, data) + } +} + +/// Plot the results of runs in a cactus plot. +pub fn make_plot(plot_data: PlotData, backend: T) -> Result<()> +where + T: DrawingBackend, + ::ErrorType: 'static, +{ + debug!("Drawing a plot"); + + let (max_time, max_count, cactus_data) = plot_data; + + register_font( + "sans-serif", + FontStyle::Normal, + include_bytes!("../../resources/LinLibertine_R.otf"), + ) + .map_err(|_| anyhow!("Could not load the font"))?; + + let style = TextStyle::from(("sans-serif", 20).into_font()).color(&BLACK); + let root = backend.into_drawing_area(); + + root.fill(&WHITE)?; + + let mut chart = ChartBuilder::on(&root) + .margin(20) + .x_label_area_size(40) + .y_label_area_size(40) + .caption("Cactus plot", 40) + .build_cartesian_2d(0..max_time + 1, 0..max_count + 1)?; + + chart + .configure_mesh() + .light_line_style(WHITE) + .x_label_style(style.clone()) + .y_label_style(style.clone()) + .label_style(style.clone()) + .x_desc("Nanoseconds") + .y_desc("Runs") + .draw()?; + + for (idx, (name, datas)) in (0..).zip(cactus_data) { + chart + .draw_series(LineSeries::new( + datas, + Into::::into(Palette99::pick(idx)).stroke_width(3), + ))? + .label(name.to_string()) + .legend(move |(x, y)| { + Rectangle::new( + [(x - 5, y - 5), (x + 5, y + 5)], + Palette99::pick(idx).stroke_width(5), + ) + }); + } + + chart.configure_series_labels().label_font(style).draw()?; + + root.present()?; + + Ok(()) +} + +#[cfg(test)] +#[path = "tests/plotting.rs"] +mod tests; diff --git a/src/gourd/analyse/tests/mod.rs b/src/gourd/analyse/tests/mod.rs index f5be00a..41addf9 100644 --- a/src/gourd/analyse/tests/mod.rs +++ b/src/gourd/analyse/tests/mod.rs @@ -1,21 +1,10 @@ -use std::collections::BTreeMap; -use std::default::Default; -use std::fs; use std::time::Duration; -use csv::Reader; -use csv::StringRecord; -use gourd_lib::experiment::Environment; -use gourd_lib::experiment::InternalProgram; -use gourd_lib::experiment::Run; -use gourd_lib::experiment::RunInput; -use gourd_lib::measurement::Measurement; -use tempdir::TempDir; +use gourd_lib::measurement::RUsage; use super::*; -use crate::status::SlurmState; -static TEST_RUSAGE: RUsage = RUsage { +pub(crate) static TEST_RUSAGE: RUsage = RUsage { utime: Duration::from_micros(2137), stime: Duration::from_micros(2137), maxrss: 2137, @@ -35,318 +24,67 @@ static TEST_RUSAGE: RUsage = RUsage { }; #[test] -fn test_analysis_csv_unwritable() { - let tmp_dir = TempDir::new("testing").unwrap(); - - let output_path = tmp_dir.path().join("analysis.csv"); - - // By creating a directory, the path becomes unwritable - let _ = fs::create_dir(&output_path); - - assert!(analysis_csv(&output_path, BTreeMap::new()).is_err()); -} - -#[test] -fn test_analysis_csv_success() { - let tmp_dir = TempDir::new("testing").unwrap(); - - let output_path = tmp_dir.path().join("analysis.csv"); - let mut statuses = BTreeMap::new(); - statuses.insert( - 0, - Status { - slurm_file_text: None, - - fs_status: FileSystemBasedStatus { - completion: crate::status::FsState::Pending, - afterscript_completion: Some(Some(String::from("lol-label"))), - }, - slurm_status: None, - }, - ); - statuses.insert( - 1, - Status { - slurm_file_text: None, - - fs_status: FileSystemBasedStatus { - completion: FsState::Completed(Measurement { - wall_micros: Duration::from_nanos(0), - exit_code: 0, - rusage: None, - }), - afterscript_completion: None, - }, - slurm_status: Some(SlurmBasedStatus { - completion: SlurmState::Success, - exit_code_program: 0, - exit_code_slurm: 0, - }), - }, - ); - - analysis_csv(&output_path, statuses).unwrap(); - - let mut reader = Reader::from_path(output_path).unwrap(); - - let res1 = reader.records().next(); - let ans1 = StringRecord::from(vec![ - "0", - "pending", - "...", - "...", - "...", - "lol-label", - "...", - ]); - assert_eq!(res1.unwrap().unwrap(), ans1); - - let res2 = reader.records().next(); - let ans2 = StringRecord::from(vec![ - "1", - "completed", - "0ns", - "0", - "none", - "no afterscript", - "Success", - ]); - assert_eq!(res2.unwrap().unwrap(), ans2); - - assert!(tmp_dir.close().is_ok()); -} - -#[test] -fn test_analysis_png_plot_success() { - let tmp_dir = TempDir::new("testing").unwrap(); - let mut statuses = BTreeMap::new(); - let status_with_rusage = Status { - slurm_file_text: None, - fs_status: FileSystemBasedStatus { - completion: FsState::Completed(Measurement { - wall_micros: Duration::from_nanos(0), - exit_code: 0, - rusage: Some(TEST_RUSAGE), - }), - afterscript_completion: None, - }, - slurm_status: Some(SlurmBasedStatus { - completion: SlurmState::Success, - exit_code_program: 0, - exit_code_slurm: 0, - }), +fn test_table_display() { + let table: Table = Table { + columns: 2, + header: Some(vec!["hello".into(), "world".into()]), + body: vec![ + vec!["a".into(), "b b b b b".into()], + vec!["hi".into(), ":)".into()], + ], + footer: Some(vec!["bye".into(), "".into()]), }; - let mut status_no_rusage = status_with_rusage.clone(); - status_no_rusage.fs_status.completion = FsState::Completed(Measurement { - wall_micros: Duration::from_nanos(0), - exit_code: 0, - rusage: None, - }); - statuses.insert( - 0, - Status { - fs_status: FileSystemBasedStatus { - completion: crate::status::FsState::Pending, - afterscript_completion: Some(Some(String::from("lol-label"))), - }, - slurm_status: None, - slurm_file_text: None, - }, - ); - statuses.insert(1, status_no_rusage); - statuses.insert(2, status_with_rusage.clone()); - statuses.insert(3, status_with_rusage); - let run = Run { - program: 0, - input: RunInput { - file: None, - arguments: Vec::new(), - }, - err_path: Default::default(), - output_path: Default::default(), - metrics_path: Default::default(), - work_dir: Default::default(), - slurm_id: None, - afterscript_output_path: None, - rerun: None, - generated_from_input: None, - parent: None, - limits: Default::default(), - group: None, - }; - let experiment = Experiment { - runs: vec![run.clone(), run.clone(), run.clone(), run], - resource_limits: None, - creation_time: Default::default(), - home: Default::default(), - wrapper: "".to_string(), - inputs: Default::default(), - programs: vec![InternalProgram::default()], - output_folder: Default::default(), - metrics_folder: Default::default(), - seq: 0, - env: Environment::Local, - labels: Default::default(), - afterscript_output_folder: Default::default(), - slurm: None, - chunks: vec![], - groups: vec![], - }; - - let png_output_path = tmp_dir.path().join("analysis.png"); - analysis_plot(&png_output_path, statuses.clone(), experiment.clone(), true).unwrap(); - - assert!(&png_output_path.exists()); - assert!(fs::read(&png_output_path).is_ok_and(|r| !r.is_empty())); - - let svg_output_path = tmp_dir.path().join("analysis.svg"); - analysis_plot(&svg_output_path, statuses, experiment, false).unwrap(); - - assert!(&svg_output_path.exists()); - assert!(fs::read(&svg_output_path).is_ok_and(|r| !r.is_empty())); -} - -#[test] -fn test_analysis_csv_wrong_path() { - let tmp_dir = TempDir::new("testing").unwrap(); - - let output_path = tmp_dir.path().join(""); - let statuses = BTreeMap::new(); - - assert!(analysis_csv(&output_path, statuses).is_err()); - assert!(tmp_dir.close().is_ok()); + assert_eq!( + " +| hello | world | +*-------*-----------* +| a | b b b b b | +| hi | :) | +*-------*-----------* +| bye | | +", + table.to_string() + ) } #[test] -fn test_get_fs_status_info_pending() { - let fs_status = FileSystemBasedStatus { - completion: FsState::Pending, - afterscript_completion: None, +fn test_table_column_widths() { + let table: Table = Table { + columns: 2, + header: Some(vec!["hallo".into(), "world".into()]), + body: vec![ + vec!["a".into(), "b b b b b".into()], + vec!["hi".into(), ":)".into()], + ], + footer: Some(vec!["bye".into(), "".into()]), }; - let res = get_fs_status_info(0, &fs_status); - assert_eq!(res, vec!["0", "pending", "...", "...", "..."]); + assert_eq!(vec![5, 9], table.column_widths()) } #[test] -fn test_get_fs_status_info_running() { - let fs_status = FileSystemBasedStatus { - completion: FsState::Running, - afterscript_completion: None, +fn test_appending_columns() { + let column: Column = Column { + header: Some("hello".into()), + body: vec!["a".into(), "b b b b b".into()], + footer: Some("bye".into()), }; - let res = get_fs_status_info(0, &fs_status); - assert_eq!(res, vec!["0", "running", "...", "...", "..."]); -} - -#[test] -fn test_get_fs_status_info_completed() { - let fs_status = FileSystemBasedStatus { - completion: FsState::Completed(Measurement { - wall_micros: Duration::from_nanos(20), - exit_code: 0, - rusage: None, - }), - afterscript_completion: None, + let mut table: Table = Table { + columns: 1, + header: Some(vec!["hello".into()]), + body: vec![vec!["a".into()], vec!["hi".into()]], + footer: Some(vec!["bye".into()]), }; - let res = get_fs_status_info(0, &fs_status); - assert_eq!(res, vec!["0", "completed", "20ns", "0", "none"]); -} + table.append_column(column); -#[test] -fn test_format_rusage() { - let res = format_rusage(Some(TEST_RUSAGE)); - let ans = "RUsage {\n utime: 2.137ms,\n stime: 2.137ms,\n maxrss: 2137,\n ixrss: 2137,\n idrss: 2137,\n isrss: 2137,\n minflt: 2137,\n majflt: 2137,\n nswap: 2137,\n inblock: 2137,\n oublock: 2137,\n msgsnd: 2137,\n msgrcv: 2137,\n nsignals: 2137,\n nvcsw: 2137,\n nivcsw: 2137,\n}"; - assert_eq!(res, ans); -} - -#[test] -fn test_get_slurm_status_info() { - let slurm = SlurmBasedStatus { - completion: SlurmState::NodeFail, - exit_code_program: 42, - exit_code_slurm: 69, - }; - - assert_eq!( - get_slurm_status_info(&Some(slurm)), - vec![String::from("NodeFail")] - ); - assert_eq!(get_slurm_status_info(&None), vec![String::from("...")]); -} - -#[test] -fn test_get_afterscript_output_info() { - let afterscript = Some(Some(String::from("lol-label"))); - - assert_eq!( - get_afterscript_output_info(&afterscript), - vec![String::from("lol-label")] - ); assert_eq!( - get_afterscript_output_info(&Some(None)), - vec![String::from("done, no label")] - ); - assert_eq!( - get_afterscript_output_info(&None), - vec![String::from("no afterscript")] - ); -} - -#[test] -fn test_get_completion_time() { - let state = FsState::Completed(Measurement { - wall_micros: Duration::from_nanos(20), - exit_code: 0, - rusage: Some(TEST_RUSAGE), - }); - let res = get_completion_time(state).unwrap(); - - assert_eq!(Duration::from_micros(2137), res); -} - -#[test] -fn test_get_data_for_plot_exists() { - let mut completions: BTreeMap> = BTreeMap::new(); - completions.insert("first".to_string(), vec![1, 2, 5]); - completions.insert("second".to_string(), vec![1, 3]); - - let max_time = 5; - let max_count = 3; - - let mut data: BTreeMap> = BTreeMap::new(); - data.insert( - "first".to_string(), - vec![(0, 0), (1, 1), (1, 1), (2, 2), (4, 2), (5, 3), (5, 3)], - ); - data.insert( - "second".to_string(), - vec![(0, 0), (1, 1), (2, 1), (3, 2), (5, 2)], - ); - - let res = get_data_for_plot(completions); - assert_eq!((max_time, max_count, data), res); -} - -#[test] -fn test_get_data_for_plot_not_exist() { - let completions: BTreeMap> = BTreeMap::new(); - - assert_eq!((0, 0, BTreeMap::new()), get_data_for_plot(completions)); -} - -#[test] -fn test_make_plot() { - let tmp_dir = TempDir::new("testing").unwrap(); - let output_path = tmp_dir.path().join("plot.png"); - - let mut data: BTreeMap> = BTreeMap::new(); - data.insert( - "first".to_string(), - vec![(0, 0), (1, 1), (2, 2), (3, 2), (4, 2), (5, 3)], - ); - data.insert( - "second".to_string(), - vec![(0, 0), (1, 1), (2, 1), (3, 2), (4, 2), (5, 2)], - ); - - assert!(make_plot((5, 3, data), BitMapBackend::new(&output_path, (300, 300))).is_ok()); + " +| hello | hello | +*-------*-----------* +| a | a | +| hi | b b b b b | +*-------*-----------* +| bye | bye | +", + table.to_string() + ) } diff --git a/src/gourd/analyse/tests/plotting.rs b/src/gourd/analyse/tests/plotting.rs new file mode 100644 index 0000000..7855d0e --- /dev/null +++ b/src/gourd/analyse/tests/plotting.rs @@ -0,0 +1,156 @@ +use std::collections::BTreeMap; +use std::fs; +use std::time::Duration; + +use gourd_lib::experiment::Environment; +use gourd_lib::experiment::InternalProgram; +use gourd_lib::experiment::Run; +use gourd_lib::experiment::RunInput; +use gourd_lib::measurement::Measurement; +use tempdir::TempDir; + +use super::*; +use crate::cli::def::PlotType::Png; +use crate::cli::def::PlotType::Svg; +use crate::status::FileSystemBasedStatus; +use crate::status::FsState; +use crate::status::SlurmBasedStatus; +use crate::status::SlurmState; +use crate::status::Status; + +#[test] +fn test_get_data_for_plot_exists() { + let mut completions: BTreeMap> = BTreeMap::new(); + completions.insert("first".to_string(), vec![1, 2, 5]); + completions.insert("second".to_string(), vec![1, 3]); + + let max_time = 5; + let max_count = 3; + + let mut data: BTreeMap> = BTreeMap::new(); + data.insert( + "first".to_string(), + vec![(0, 0), (1, 1), (1, 1), (2, 2), (4, 2), (5, 3), (5, 3)], + ); + data.insert( + "second".to_string(), + vec![(0, 0), (1, 1), (2, 1), (3, 2), (5, 2)], + ); + + let res = get_data_for_plot(completions); + assert_eq!((max_time, max_count, data), res); +} + +#[test] +fn test_get_data_for_plot_not_exist() { + let completions: BTreeMap> = BTreeMap::new(); + + assert_eq!((0, 0, BTreeMap::new()), get_data_for_plot(completions)); +} + +#[test] +fn test_make_plot() { + let tmp_dir = TempDir::new("testing").unwrap(); + let output_path = tmp_dir.path().join("plot.png"); + + let mut data: BTreeMap> = BTreeMap::new(); + data.insert( + "first".to_string(), + vec![(0, 0), (1, 1), (2, 2), (3, 2), (4, 2), (5, 3)], + ); + data.insert( + "second".to_string(), + vec![(0, 0), (1, 1), (2, 1), (3, 2), (4, 2), (5, 2)], + ); + + assert!(make_plot((5, 3, data), BitMapBackend::new(&output_path, (300, 300))).is_ok()); +} + +#[test] +fn test_analysis_png_plot_success() { + let tmp_dir = TempDir::new("testing").unwrap(); + let mut statuses = BTreeMap::new(); + let status_with_rusage = Status { + slurm_file_text: None, + fs_status: FileSystemBasedStatus { + completion: FsState::Completed(Measurement { + wall_micros: Duration::from_nanos(0), + exit_code: 0, + rusage: Some(crate::analyse::tests::TEST_RUSAGE), + }), + afterscript_completion: None, + }, + slurm_status: Some(SlurmBasedStatus { + completion: SlurmState::Success, + exit_code_program: 0, + exit_code_slurm: 0, + }), + }; + let mut status_no_rusage = status_with_rusage.clone(); + status_no_rusage.fs_status.completion = FsState::Completed(Measurement { + wall_micros: Duration::from_nanos(0), + exit_code: 0, + rusage: None, + }); + statuses.insert( + 0, + Status { + fs_status: FileSystemBasedStatus { + completion: crate::status::FsState::Pending, + afterscript_completion: Some(Some(String::from("lol-label"))), + }, + slurm_status: None, + slurm_file_text: None, + }, + ); + statuses.insert(1, status_no_rusage); + statuses.insert(2, status_with_rusage.clone()); + statuses.insert(3, status_with_rusage); + let run = Run { + program: 0, + input: RunInput { + file: None, + args: Vec::new(), + }, + err_path: Default::default(), + output_path: Default::default(), + metrics_path: Default::default(), + work_dir: Default::default(), + slurm_id: None, + afterscript_output: None, + rerun: None, + generated_from_input: None, + parent: None, + limits: Default::default(), + group: None, + }; + let experiment = Experiment { + runs: vec![run.clone(), run.clone(), run.clone(), run], + resource_limits: None, + creation_time: Default::default(), + home: Default::default(), + wrapper: "".to_string(), + inputs: Default::default(), + programs: vec![InternalProgram::default()], + output_folder: Default::default(), + metrics_folder: Default::default(), + seq: 0, + env: Environment::Local, + labels: Default::default(), + slurm: None, + chunks: vec![], + groups: vec![], + }; + + let png_output_path = tmp_dir.path().join("analysis.png"); + analysis_plot(&png_output_path, statuses.clone(), &experiment, Png).unwrap(); + + assert!(&png_output_path.exists()); + assert!(fs::read(&png_output_path).is_ok_and(|r| !r.is_empty())); + + let svg_output_path = tmp_dir.path().join("analysis.svg"); + analysis_plot(&svg_output_path, statuses, &experiment, Svg).unwrap(); + + assert!(&svg_output_path.exists()); + assert!(fs::read(&svg_output_path).is_ok_and(|r| !r.is_empty())); +} diff --git a/src/gourd/chunks.rs b/src/gourd/chunks.rs index af68772..12c0be7 100644 --- a/src/gourd/chunks.rs +++ b/src/gourd/chunks.rs @@ -134,10 +134,11 @@ impl Chunkable for Experiment { for (task_id, run_id) in chunk.runs.iter().enumerate() { // because we schedule an array by specifying the run_id(s) in a list, // the sub id should be == run_id. - self.runs[*run_id].slurm_id = Some(format!("{}_{}", batch_id, task_id)); + self.runs[*run_id].slurm_id = Some(format!("{batch_id}_{task_id}")); } } + #[allow(clippy::nonminimal_bool)] fn unscheduled(&self, status: &ExperimentStatus) -> Vec<(usize, &Run)> { self.runs .iter() @@ -148,6 +149,7 @@ impl Chunkable for Experiment { && r.slurm_id.is_none() }) .filter(|(_, r)| !r.parent.is_some_and(|d| !status[&d].is_completed())) + // .filter(|(_, r)| r.parent.is_none_or(|d| status[&d].is_completed())) .collect() } diff --git a/src/gourd/cli/def.rs b/src/gourd/cli/def.rs index 0fd97e9..b41487e 100644 --- a/src/gourd/cli/def.rs +++ b/src/gourd/cli/def.rs @@ -1,11 +1,10 @@ use std::path::PathBuf; -use std::time::Duration; -use clap::builder::PossibleValue; use clap::ArgAction; use clap::Args; use clap::Parser; use clap::Subcommand; +use clap::ValueEnum; /// Structure of the main command (gourd). #[allow(unused)] @@ -27,7 +26,7 @@ pub struct Cli { #[arg(short, long, default_value = "./gourd.toml", global = true)] pub config: PathBuf, - /// Verbose mode, displays debug info. For even more try: -vv. + /// Verbose mode, prints debug info. For even more try: -vv. #[arg(short, long, global = true, action = ArgAction::Count)] pub verbose: u8, @@ -96,6 +95,10 @@ pub struct StatusStruct { /// Do not shorten output even if there is a lot of runs. #[arg(long)] pub full: bool, + + /// Display full afterscript output for a run. Use with -i + #[arg(long, requires = "run_id")] + pub after_out: bool, } /// Arguments supplied with the `continue` command. @@ -165,47 +168,141 @@ pub struct AnalyseStruct { #[arg(value_name = "EXPERIMENT")] pub experiment_id: Option, - /// The output format of the analysis. - /// For all formats see the manual. - #[arg(long, short, default_value = "csv", value_parser = [ - PossibleValue::new("csv"), - PossibleValue::new("plot-svg"), - PossibleValue::new("plot-png"), - ])] - pub output: String, + /// Plot analysis or create a table for the run metrics. + #[command(subcommand)] + pub subcommand: AnalyseSubcommand, + + /// If you want to save to a specific file + #[arg(short, long)] + pub output: Option, } -/// Arguments supplied with the `set-limits` command. -#[derive(Args, Debug, Clone)] -pub struct SetLimitsStruct { - /// The id of the experiment of which to change limits - /// [default: newest experiment] - #[arg(value_name = "EXPERIMENT")] - pub experiment_id: Option, +/// Enum for subcommands of the `run` subcommand. +#[derive(Subcommand, Debug, Clone)] +pub enum AnalyseSubcommand { + /// Generate a cactus plot for the runs of this experiment. + #[command()] + Plot { + /// What file format to make the cactus plot in. + /// Options are `png` (default), `svg` + #[arg(short, long, default_value = "png")] + format: PlotType, + + /// If you want to save to a specific file + #[arg(short, long)] + output: Option, + }, + + /// Generate tables for the metrics of the runs in this experiment. + #[command()] + Table(CsvFormatting), +} - /// The program for which to set resource limits. +/// Construct a CSV by specifying desired columns and any grouping of runs. +#[derive(Args, Debug, Clone)] +pub struct CsvFormatting { + /// Group together the averages based on a number of conditions. + /// + /// Specifying multiple conditions means that all equalities must hold. + #[arg(short, long, value_delimiter = ',', num_args = 0..)] + pub group: Vec, + + /// Choose which columns to include in the table. + #[arg(short, long, value_delimiter = ',', num_args = 1..)] + pub format: Option>, + + /// If you want to save to a specific file #[arg(short, long)] - pub program: Option, + pub output: Option, +} - /// Set resource limits for all programs. - #[arg( - short, - long, - conflicts_with_all = ["program"], - )] - pub all: bool, +/// Choice of grouping together runs based on equality conditions +#[derive(ValueEnum, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Copy)] +pub enum GroupBy { + /// Group together runs that have the same program. + Program, + /// Group together runs that have the same input. + Input, + /// Group together runs that have the same input group. + Group, +} - /// Take the resource limits from a toml file. - #[arg(long)] - pub mem: Option, +/// Enum for the columns that can be included in the CSV. +#[derive(ValueEnum, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Copy)] +pub enum CsvColumn { + /// The name of the program that was run. + Program, + /// The input file that was used. + File, + /// The arguments that were passed to the program. + Args, + /// The group that the run was in. + Group, + /// The afterscript that was run. + Label, + /// The afterscript output content. + Afterscript, + /// The slurm completion status of the run. + Slurm, + /// The metrics saved file for the run + FsStatus, + /// The run process exit code + ExitCode, + /// Process wall time + WallTime, + /// Process user time + UserTime, + /// Process system time + SystemTime, + /// Maximum resident set size + MaxRSS, + /// Integral shared memory size + IxRSS, + /// Integral unshared data size + IdRSS, + /// Integral unshared stack size + IsRSS, + /// Page reclaims (soft page faults) + MinFlt, + /// Page faults (hard page faults) + MajFlt, + /// Swaps + NSwap, + /// Block input operations + InBlock, + /// Block output operations + OuBlock, + /// IPC messages sent + MsgSent, + /// IPC messages received + MsgRecv, + /// Signals received + NSignals, + /// Voluntary context switches + NVCsw, + /// Involuntary context switches + NIvCsw, +} - /// Take the resource limits from a toml file. - #[arg(long)] - pub cpu: Option, +/// Enum for the output format of the analysis. +#[derive(ValueEnum, Debug, Clone, Default, Copy)] +pub enum PlotType { + /// Output an SVG cactus plot. + Svg, + + /// Output a PNG cactus plot. + #[default] + Png, +} - /// Take the resource limits from a toml file. - #[arg(long, value_parser = humantime::parse_duration)] - pub time: Option, +impl PlotType { + /// get the file extension for this plot type + pub fn ext(&self) -> &str { + match self { + PlotType::Svg => "svg", + PlotType::Png => "png", + } + } } /// Enum for root-level `gourd` commands. diff --git a/src/gourd/cli/log.rs b/src/gourd/cli/log.rs index 9df5521..ee6c89c 100644 --- a/src/gourd/cli/log.rs +++ b/src/gourd/cli/log.rs @@ -32,6 +32,6 @@ impl CologStyle for LogTokens { Level::Trace => style_from_fg(AnsiColor::Magenta), }; - format!("{}{}{:#}", style, msg, style) + format!("{style}{msg}{style:#}") } } diff --git a/src/gourd/cli/printing.rs b/src/gourd/cli/printing.rs index 1eac35f..6c3fa7f 100644 --- a/src/gourd/cli/printing.rs +++ b/src/gourd/cli/printing.rs @@ -45,19 +45,14 @@ pub fn print_version(script: bool) { let mut to_print = LOGO.replace( "{LINE1}", &format!( - " at version {}{}{:#} {GOURD_VERSION:?}", - PRIMARY_STYLE, - crate_version!(), - PRIMARY_STYLE + " at version {PRIMARY_STYLE}{}{PRIMARY_STYLE:#} {GOURD_VERSION:?}", + crate_version!() ), ); to_print = to_print.replace( "{LINE2}", - &format!( - "{}Technische Universiteit Delft 2024{:#}", - NAME_STYLE, NAME_STYLE, - ), + &format!("{NAME_STYLE}Technische Universiteit Delft 2024{NAME_STYLE:#}"), ); to_print = to_print.replace("{LINE3}", "Authored by:"); @@ -89,7 +84,7 @@ pub fn format_table(data: Vec>) -> String { let formatted_row: Vec = row .into_iter() .enumerate() - .map(|(i, item)| format!("{:width$}", item, width = max_widths[i])) + .map(|(i, item)| format!("{item:width$}", width = max_widths[i])) .collect(); if !result.is_empty() { result.push('\n'); diff --git a/src/gourd/cli/process.rs b/src/gourd/cli/process.rs index 26dc527..0a011e3 100644 --- a/src/gourd/cli/process.rs +++ b/src/gourd/cli/process.rs @@ -12,8 +12,10 @@ use colog::default_builder; use colog::formatter; use gourd_lib::bailc; use gourd_lib::config::Config; +use gourd_lib::constants::CMD_DOC_STYLE; use gourd_lib::constants::CMD_STYLE; use gourd_lib::constants::ERROR_STYLE; +use gourd_lib::constants::PATH_STYLE; use gourd_lib::constants::PRIMARY_STYLE; use gourd_lib::constants::TERTIARY_STYLE; use gourd_lib::ctx; @@ -32,10 +34,11 @@ use super::def::ContinueStruct; use super::def::RerunOptions; use super::log::LogTokens; use super::printing::get_styles; -use crate::analyse::analysis_csv; -use crate::analyse::analysis_plot; +use crate::analyse::csvs::tables_from_command; +use crate::analyse::plotting::analysis_plot; use crate::chunks::Chunkable; use crate::cli::def::AnalyseStruct; +use crate::cli::def::AnalyseSubcommand; use crate::cli::def::CancelStruct; use crate::cli::def::Cli; use crate::cli::def::GourdCommand; @@ -47,6 +50,7 @@ use crate::experiments::ExperimentExt; use crate::init::init_experiment_setup; use crate::init::list_init_examples; use crate::local::run_local; +use crate::post::afterscript::run_afterscripts_for_experiment; use crate::rerun; use crate::rerun::slurm::query_changing_resource_limits; use crate::slurm::checks::slurm_options_from_experiment; @@ -78,8 +82,8 @@ pub async fn parse_command() { if backtrace_enabled { eprintln!("{:?}", process_command(&command).await); } else if let Err(e) = process_command(&command).await { - eprintln!("{}error:{:#} {}", ERROR_STYLE, ERROR_STYLE, e.root_cause()); - eprint!("{}", e); + eprintln!("{ERROR_STYLE}error:{ERROR_STYLE:#} {}", e.root_cause()); + eprint!("{e}"); exit(1); } } @@ -182,7 +186,12 @@ pub async fn process_command(cmd: &Cli) -> Result<()> { ); } else { info!( - "Run {PRIMARY_STYLE}gourd status {}{PRIMARY_STYLE:#} to check on this experiment", + "Run {PRIMARY_STYLE} gourd status {} {PRIMARY_STYLE:#} to check on this experiment", + experiment.seq + ); + info!( + " or {PRIMARY_STYLE} gourd status {} -i {PRIMARY_STYLE:#}\ + to check on a specific run", experiment.seq ); } @@ -196,15 +205,41 @@ pub async fn process_command(cmd: &Cli) -> Result<()> { run_id, follow: blocking, full, + after_out, .. }) => { - let experiment = read_experiment(experiment_id, cmd, &file_system)?; + let mut experiment = read_experiment(experiment_id, cmd, &file_system)?; + // first run the afterscripts: + run_afterscripts_for_experiment(&mut experiment, &file_system)?; + // then get the statuses let statuses = experiment.status(&file_system)?; match run_id { Some(id) => { - display_job(&mut stdout(), &experiment, &statuses, *id)?; + // only print the afterscript output, + // in a script-readable format (nothing fancy) + if *after_out { + if let Some(Some(out)) = &experiment + .runs + .get(*id) + .map(|x| x.afterscript_output.clone()) + { + println!("{out}"); + } else { + // INFO: here we can detect if the run is supposed to have an + // afterscript ourselves. but for now the user can do it instead. + bailc!( + "could not print afterscript output",; + "there is no afterscript output for this run",; + "please make sure\n\ + 1. this run is supposed to have an afterscript, and\n\ + 2. that the afterscript prints its output correctly.\n", + ); + } + } else { + display_job(&mut stdout(), &experiment, &statuses, *id)?; + } } None => { info!( @@ -212,13 +247,15 @@ pub async fn process_command(cmd: &Cli) -> Result<()> { experiment.seq ); + let run_count = experiment.runs.len(); + if *blocking { blocking_status( &progress, &experiment, &mut file_system, *full, - experiment.runs.len(), + run_count, )?; } else { display_statuses(&mut stdout(), &experiment, &statuses, *full)?; @@ -250,66 +287,104 @@ pub async fn process_command(cmd: &Cli) -> Result<()> { } } + // handle plotting separately GourdCommand::Analyse(AnalyseStruct { experiment_id, - output, + subcommand: + AnalyseSubcommand::Plot { + format, + output: save_a, + }, + output: save_b, }) => { let experiment = read_experiment(experiment_id, cmd, &file_system)?; let statuses = experiment.status(&file_system)?; - // Checking if there are completed jobs to analyse. - let mut completed_runs = statuses + let out_path = + save_a + .clone() + .or(save_b.clone()) + .unwrap_or(experiment.home.join(format!( + "plot_{}.{}", + experiment.seq, + format.ext() + ))); + + if statuses .values() - .filter(|x| x.fs_status.completion.is_completed()); - - debug!("Starting analysis..."); + .filter(|x| x.fs_status.completion.is_completed()) + .count() + == 0 + { + bailc!( + "No runs have completed yet", ; + "There are no results to analyse.", ; + "Try again later. To see job status, use {CMD_STYLE}gourd status{CMD_STYLE:#}.", + ); + } if cmd.dry { - info!("Would have analysed the experiment (dry)"); - return Ok(()); + } else { + let out = analysis_plot(&out_path, statuses, &experiment, *format)?; + info!("Plot saved to:"); + println!("{PATH_STYLE}{}{PATH_STYLE:#}", out.display()); + // non-info printing can let scripts easily get the path from + // the last line. } + } - let mut output_path = experiment.home.clone(); + GourdCommand::Analyse(AnalyseStruct { + experiment_id, + subcommand: AnalyseSubcommand::Table(csv), + output: save, + }) => { + let experiment = read_experiment(experiment_id, cmd, &file_system)?; + let statuses = experiment.status(&file_system)?; - if completed_runs.next().is_some() { - match &output[..] { - "csv" => { - output_path.push(format!("analysis_{}.csv", experiment.seq)); - analysis_csv(&output_path, statuses).with_context(ctx!( - "Could not analyse to a CSV file at {:?}", - &output_path; "", - ))?; - } - "plot-png" => { - output_path.push(format!("plot_{}.png", experiment.seq)); - analysis_plot(&output_path, statuses, experiment, true) - .with_context(ctx!( - "Could not create a plot at {:?}", &output_path; "", ))?; - } - "plot-svg" => { - output_path.push(format!("plot_{}.svg", experiment.seq)); - analysis_plot(&output_path, statuses, experiment, false).with_context( - ctx!( - "Could not create a plot at {:?}", - &output_path; "", - ), - )?; + let tables = tables_from_command(&experiment, &statuses, csv.clone())?; + + if let Some(path) = &save.clone().or(csv.output.clone()) { + let count = tables.len(); + if cmd.dry { + info!("Would have saved {count} table(s) to {}", path.display()); + } else { + for table in tables { + table.write_to_path(path)?; } - _ => bailc!("Unsupported output format {}", &output; - "Use 'csv', 'plot-png', or 'plot-svg'.", ; "" ,), + info!("{count} Table{} saved to", if count > 1 { "s" } else { "" }); + println!("{PATH_STYLE}{}{PATH_STYLE:#}", path.display()); + // non-info printing can let scripts easily get the path + // from the last line. + } + } else if cmd.script { + for table in tables { + println!("{table:-}\n"); } } else { - bailc!( - "No runs have completed yet", ; - "There are no results to analyse.", ; - "Try later. To see job status, use {CMD_STYLE}gourd status{CMD_STYLE:#}.", + for table in tables { + info!( + "Table for {TERTIARY_STYLE}{}{TERTIARY_STYLE:#} runs", + table.body.len() + ); + info!("{table}"); + } + info!( + "Run with {CMD_STYLE} --output=\"path/to/location.csv\" {CMD_STYLE:#} \ + to save to a csv file" ); - }; - - info!("Analysis successful!"); - info!("Results have been placed in {:?}", &output_path); + if csv.format.is_none() { + info!( + "Hint: use the {CMD_DOC_STYLE} --format {CMD_DOC_STYLE:#} \ + flag to customise the table columns" + ); + info!( + "Example: {CMD_STYLE} \ + --format=\"program,group,wall-time,n-iv-csw\" {CMD_STYLE:#}" + ); + } + } } GourdCommand::Cancel(CancelStruct { @@ -341,7 +416,7 @@ pub async fn process_command(cmd: &Cli) -> Result<()> { .and_then(|x| { x.slurm_id .clone() - .ok_or(anyhow!("Could not find run {} on Slurm", id)) + .ok_or(anyhow!("Could not find run {id} on Slurm")) .with_context(ctx!( "You can only cancel runs that have been scheduled on Slurm.", ; "Run {CMD_STYLE}gourd status {}{CMD_STYLE:#} \ @@ -434,12 +509,12 @@ pub async fn process_command(cmd: &Cli) -> Result<()> { let selected_runs = rerun::runs::get_runs_from_rerun_options( run_ids, - &experiment, + &mut experiment, &mut file_system, cmd.script, )?; - trace!("Selected runs: {:?}", selected_runs); + trace!("Selected runs: {selected_runs:?}"); // NOTE: when rerunning we should only update the limits of the new runs, // and not of the whole experiment. diff --git a/src/gourd/experiments/dfs.rs b/src/gourd/experiments/dfs.rs index 537bd4e..bd71c56 100644 --- a/src/gourd/experiments/dfs.rs +++ b/src/gourd/experiments/dfs.rs @@ -2,6 +2,7 @@ use std::collections::VecDeque; use std::path::PathBuf; use anyhow::Context; +use anyhow::Result; use gourd_lib::bailc; use gourd_lib::experiment::Experiment; use gourd_lib::experiment::Run; @@ -26,7 +27,7 @@ pub(super) fn dfs( runs: &mut Vec, exp: &Experiment, fs: &impl FileOperations, -) -> anyhow::Result<()> { +) -> Result<()> { // Since the run amount can be in the millions I don't want to rely on tail // recursion, and we will just use unrolled dfs. let mut next: VecDeque = VecDeque::new(); @@ -61,7 +62,7 @@ pub(super) fn dfs( node, RunInput { file: input.input.clone(), - arguments: input.arguments.clone(), + args: input.arguments.clone(), }, Some(input_name.clone()), input.metadata.group.clone(), @@ -81,7 +82,7 @@ pub(super) fn dfs( node, RunInput { file: Some(pchild.1), - arguments: runs[pchild.0].input.arguments.clone(), + args: runs[pchild.0].input.args.clone(), }, None, None, // no groups for children diff --git a/src/gourd/experiments/mod.rs b/src/gourd/experiments/mod.rs index 2daeb39..ae88df9 100644 --- a/src/gourd/experiments/mod.rs +++ b/src/gourd/experiments/mod.rs @@ -9,7 +9,6 @@ use gourd_lib::bailc; use gourd_lib::config::Config; use gourd_lib::ctx; use gourd_lib::experiment::inputs::expand_inputs; -use gourd_lib::experiment::labels::Labels; use gourd_lib::experiment::programs::expand_programs; use gourd_lib::experiment::Environment; use gourd_lib::experiment::Experiment; @@ -100,15 +99,10 @@ impl ExperimentExt for Experiment { output_folder: fs.truncate_and_canonicalize_folder(&conf.output_path)?, metrics_folder: fs.truncate_and_canonicalize_folder(&conf.metrics_path)?, - afterscript_output_folder: fs - .truncate_and_canonicalize_folder(&conf.output_path.join("afterscripts"))?, env, resource_limits: conf.resource_limits, - labels: Labels { - map: conf.labels.clone().unwrap_or_default(), - warn_on_label_overlap: conf.warn_on_label_overlap, - }, + labels: conf.labels.clone().unwrap_or_default(), slurm, @@ -209,7 +203,7 @@ impl ExperimentExt for Experiment { folder: &Path, fs: &impl FileOperations, ) -> Result { - fs.try_read_toml(&folder.join(format!("{}.lock", seq))) + fs.try_read_toml(&folder.join(format!("{seq}.lock"))) } } diff --git a/src/gourd/experiments/run.rs b/src/gourd/experiments/run.rs index 5b4e1cb..515657a 100644 --- a/src/gourd/experiments/run.rs +++ b/src/gourd/experiments/run.rs @@ -21,40 +21,31 @@ pub fn generate_new_run( experiment: &Experiment, fs: &impl FileOperations, ) -> Result { + let seq = experiment.seq; Ok(Run { program, input: run_input, err_path: fs.truncate_and_canonicalize( &experiment .output_folder - .join(format!("{}/{}/{}/stderr", experiment.seq, program, run_id)), + .join(format!("{seq}/{program}/{run_id}/stderr")), )?, metrics_path: fs.truncate_and_canonicalize( &experiment .metrics_folder - .join(format!("{}/{}/{}/metrics", experiment.seq, program, run_id)), + .join(format!("{seq}/{program}/{run_id}/metrics")), )?, output_path: fs.truncate_and_canonicalize( &experiment .output_folder - .join(format!("{}/{}/{}/stdout", experiment.seq, program, run_id)), + .join(format!("{seq}/{program}/{run_id}/stdout")), )?, work_dir: fs.truncate_and_canonicalize_folder( &experiment .output_folder - .join(format!("{}/{}/{}/", experiment.seq, program, run_id)), + .join(format!("{seq}/{program}/{run_id}/")), )?, - afterscript_output_path: match experiment.programs[program].afterscript.as_ref() { - None => None, - Some(_) => Some( - fs.truncate_and_canonicalize_folder( - &experiment - .output_folder - .join(format!("{}/{}/{}/", experiment.seq, program, run_id)), - )? - .join("afterscript"), - ), - }, + afterscript_output: None, limits, slurm_id: None, rerun: None, diff --git a/src/gourd/init/builtin_examples.rs b/src/gourd/init/builtin_examples.rs index 1ba8ea7..be53a96 100644 --- a/src/gourd/init/builtin_examples.rs +++ b/src/gourd/init/builtin_examples.rs @@ -44,13 +44,13 @@ impl InitExample<'_> { file_system.write_archive(directory, archive)?; if !file_system.dry_run { - debug!("Entering the directory {:?}", directory); + debug!("Entering the directory {directory:?}"); let previous_dir = std::env::current_dir()?; std::env::set_current_dir(directory)?; let config_path = PathBuf::from("gourd.toml"); - debug!("Checking for a \"gourd.toml\" at {:?}.", config_path); + debug!("Checking for a \"gourd.toml\" at {config_path:?}."); match Config::from_file(Path::new("gourd.toml"), file_system) { Err(e) => { debug!("Configuration check failed: {}", e.root_cause()); @@ -105,6 +105,6 @@ pub fn get_examples() -> BTreeMap<&'static str, InitExample<'static>> { /// Retrieves a named example experiment, or [None]. pub fn get_example(id_input: &str) -> Option> { let id = id_input.to_string().replace(['.', '_', ' '], "-"); - debug!("Translating the example-id: {} to {}", id_input, id); + debug!("Translating the example-id: {id_input} to {id}"); get_examples().get(&id as &str).cloned() } diff --git a/src/gourd/init/interactive.rs b/src/gourd/init/interactive.rs index d644896..03f8c24 100644 --- a/src/gourd/init/interactive.rs +++ b/src/gourd/init/interactive.rs @@ -58,7 +58,6 @@ pub fn init_interactive( wrapper: WRAPPER_DEFAULT(), labels: None, input_schema: None, - warn_on_label_overlap: false, }; let custom_paths = if script_mode { diff --git a/src/gourd/init/mod.rs b/src/gourd/init/mod.rs index 27dcc57..f2c8376 100644 --- a/src/gourd/init/mod.rs +++ b/src/gourd/init/mod.rs @@ -65,7 +65,7 @@ pub fn init_experiment_setup( ) -> Result<()> { check_init_directory(directory)?; - info!("Creating an experimental setup at {:?}.", directory); + info!("Creating an experimental setup at {directory:?}."); match template { None => init_interactive(directory, script_mode, fs)?, @@ -82,15 +82,12 @@ pub fn init_experiment_setup( } if dry_run { - info!( - "The experimental setup would be ready in {:?} (dry)", - directory - ); + info!("The experimental setup would be ready in {directory:?} (dry)"); } else { info!(""); - info!("The experimental setup is ready in {:?}!", directory); + info!("The experimental setup is ready in {directory:?}!"); info!("To create an experiment, use these commands:"); - info!(" > {CMD_STYLE}cd {:?}{CMD_STYLE:#}", directory); + info!(" > {CMD_STYLE}cd {directory:?}{CMD_STYLE:#}"); info!(" > {CMD_STYLE}gourd run{CMD_STYLE:#}"); } @@ -103,32 +100,32 @@ pub fn init_experiment_setup( /// valid (even for relative paths such as "foo" that have "" as parent), /// and checking that the directory path itself does not yet exist. fn check_init_directory(directory: &Path) -> Result<()> { - debug!("Checking the init directory at {:?}", directory); + debug!("Checking the init directory at {directory:?}"); match directory.parent() { // The directory is "/", the next check will fail (already exists) None => {} // Check that the parent exists Some(parent) => { - debug!("Checking the parent directory at {:?}", parent); + debug!("Checking the parent directory at {parent:?}"); if !(parent.exists() || parent == Path::new("")) { bailc!( - "The parent directory, {:?}, does not exist.", parent; + "The parent directory, {parent:?}, does not exist.", ; "", ; "Ensure that you are asking 'gourd init' to \ initialize the directory at a valid path.", ) } - debug!("The parent directory, {:?}, is valid", parent) + debug!("The parent directory, {parent:?}, is valid",) } } if directory.exists() { bailc!( "The path exists.", ; - "A file or directory exists at {:?}.", directory; + "A file or directory exists at {directory:?}.", ; "Run 'gourd init' for a directory that does not yet exist.", ) } @@ -157,7 +154,7 @@ fn init_from_example( match get_example(id) { None => bailc!( "Invalid example name.", ; - "An example called \"{}\" does not exist.", id ; + "An example called \"{id}\" does not exist.", ; "Try a valid example, such as \"{}\". \ Use {CMD_STYLE}gourd init --list-examples{CMD_STYLE:#} for all options.", get_examples().iter().next().unwrap().0, diff --git a/src/gourd/local/mod.rs b/src/gourd/local/mod.rs index 907b055..911f63c 100644 --- a/src/gourd/local/mod.rs +++ b/src/gourd/local/mod.rs @@ -24,7 +24,7 @@ pub async fn run_local( let cmds = wrap(experiment, &status, env::consts::ARCH, fs)?; - trace!("About to run: {:#?}", cmds); + trace!("About to run: {cmds:#?}"); experiment.save(fs)?; diff --git a/src/gourd/local/runner.rs b/src/gourd/local/runner.rs index 62e6e99..9d1a7d1 100644 --- a/src/gourd/local/runner.rs +++ b/src/gourd/local/runner.rs @@ -41,21 +41,21 @@ pub async fn run_locally(tasks: Vec, force: bool, sequential: bool) -> } } else { error!("Couldn't start the wrapper: {join:?}"); - error!("Ensure that the wrapper is accesible. (see man gourd)"); + error!("Ensure that the wrapper is accessible. (see man gourd)"); process::exit(1); } } if sequential { for mut task in tasks { - trace!("Running task: {:?}", task); + trace!("Running task: {task:?}"); handle_output(task.output()); } } else { let mut set = JoinSet::new(); for mut task in tasks { - trace!("Queueing task: {:?}", task); + trace!("Queueing task: {task:?}"); set.spawn_blocking(move || task.output()); } diff --git a/src/gourd/post/afterscript.rs b/src/gourd/post/afterscript.rs index d25b9f4..addc0d5 100644 --- a/src/gourd/post/afterscript.rs +++ b/src/gourd/post/afterscript.rs @@ -1,91 +1,77 @@ -use std::fs; -use std::path::Path; -use std::path::PathBuf; -use std::process::ExitStatus; - use anyhow::anyhow; use anyhow::Context; use anyhow::Result; -use gourd_lib::bailc; use gourd_lib::ctx; use gourd_lib::experiment::Experiment; +use gourd_lib::file_system::FileOperations; use gourd_lib::resources::run_script; use log::debug; use log::trace; -/// Runs the afterscript on jobs that are completed and do not yet have an -/// afterscript output. -pub fn run_afterscript(run_id: usize, experiment: &Experiment) -> Result<()> { +/// For a run that: +/// * has finished +/// * its program has an afterscript +/// +/// this function will run said afterscript, and update the experiment +/// accordingly. +pub fn run_afterscript(run_id: usize, experiment: &mut Experiment) -> Result<()> { let run = &experiment.runs[run_id]; - let after_out_path = &run.afterscript_output_path; - let res_path = run.output_path.clone(); + let run_output_path = run.output_path.clone(); trace!("Checking afterscript for {run_id}"); - let after_output = after_out_path - .clone() - .ok_or(anyhow!("Could not get the afterscript information")) - .with_context(ctx!( - "Could not get the afterscript information", ; - "", - ))?; - let afterscript = &experiment.programs[run.program] .afterscript .clone() - .ok_or(anyhow!("Could not get the afterscript information")) - .with_context(ctx!( - "Could not get the afterscript information", ; - "", - ))?; + .ok_or(anyhow!("Could not get the afterscript information"))?; debug!("Running afterscript for {run_id}"); - let exit_status = - run_afterscript_for_run(afterscript, &res_path, &after_output, &run.work_dir)?; + let afterscript_output = run_script( + afterscript, + vec![&run_output_path.display().to_string()], + &run.work_dir, + ) + .with_context(ctx!( + "Tried running afterscript at {}", + &afterscript.display(); + "Ensure that the afterscript file is executable, otherwise check section GOURD STATUS > Afterscripts in `man gourd` for troubleshooting.", + ))?; - if !exit_status.success() { - bailc!("Afterscript failed with exit code {}", - exit_status - .code() - .ok_or(anyhow!("Status does not exist")) - .with_context(ctx!( - "Could not get the exit code of the execution", ; - "", - ))? ; "", ; "", ); - } + let afterscript_result = String::from_utf8_lossy(&afterscript_output.stdout) + .trim() + .to_string(); + debug!("stdout: {afterscript_result}"); + debug!( + "stderr: {}", + String::from_utf8_lossy(&afterscript_output.stdout).trim() + ); + + experiment.runs[run_id].afterscript_output = Some(afterscript_result); Ok(()) } -/// Runs the afterscript on given jobs. -pub fn run_afterscript_for_run( - after_path: &PathBuf, - res_path: &PathBuf, - out_path: &PathBuf, - work_dir: &Path, -) -> Result { - fs::metadata(res_path).with_context(ctx!( - "Could not find the job result at {:?}", &res_path; - "Check that the job result already exists", - ))?; +/// Run all the afterscripts that haven't been run yet for this experiment +/// +/// checks that the afterscript exists and hasn't already ran. +pub fn run_afterscripts_for_experiment( + experiment: &mut Experiment, + fs: &impl FileOperations, +) -> Result<()> { + for run_id in 0..experiment.runs.len() { + if experiment.runs[run_id].afterscript_output.is_none() + && experiment + .get_program(&experiment.runs[run_id])? + .afterscript + .is_some() + { + run_afterscript(run_id, experiment)?; + } + } - let args = vec![ - res_path.as_os_str().to_str().with_context(ctx!( - "Could not turn {res_path:?} into a string", ; - "", - ))?, - out_path.as_os_str().to_str().with_context(ctx!( - "Could not turn {out_path:?} into a string", ; - "", - ))?, - ]; + experiment.save(fs)?; - let exit_status = run_script(after_path, args, work_dir).with_context(ctx!( - "Could not run the afterscript at {after_path:?} with job results at {res_path:?}", ; - "Check that the afterscript is correct and job results exist at {:?}", res_path, - ))?; - - Ok(exit_status) + Ok(()) } #[cfg(test)] diff --git a/src/gourd/post/labels.rs b/src/gourd/post/labels.rs index 54a799e..862df2b 100644 --- a/src/gourd/post/labels.rs +++ b/src/gourd/post/labels.rs @@ -1,41 +1,30 @@ -use std::path::Path; - use anyhow::Result; use gourd_lib::experiment::Experiment; -use gourd_lib::file_system::FileOperations; use log::debug; use log::trace; use log::warn; /// Assigns a label to a run. pub fn assign_label( + run_id: usize, + source_text: &str, experiment: &Experiment, - source_file: &Path, - fs: &impl FileOperations, ) -> Result> { - debug!("Assigning label to {:?}", source_file); + debug!("Assigning label for text {source_text:?}"); let mut result_label: Option = None; - let text = fs.read_utf8(source_file)?; - let label_map = &experiment.labels.map; + let label_map = &experiment.labels; let mut keys = label_map.keys().collect::>(); keys.sort_unstable_by(|a, b| label_map[*b].priority.cmp(&label_map[*a].priority)); for l in keys { let label = &label_map[l]; - if label.regex.is_match(&text) { + if label.regex.is_match(source_text) { if let Some(ref r) = result_label { - trace!("{text} matches multiple labels: {r} and {l}"); - - if experiment.labels.warn_on_label_overlap { - warn!( - "The source file {:?} matches multiple labels: {} and {}", - source_file, r, l - ); - } + warn!("The afterscript for run {run_id:?} matches multiple labels: {r} and {l}"); } else { - trace!("{text} matches {l}"); + trace!("{source_text} matches {l}"); result_label = Some(l.clone()); } } diff --git a/src/gourd/post/tests/afterscript.rs b/src/gourd/post/tests/afterscript.rs index 452b637..55b0159 100644 --- a/src/gourd/post/tests/afterscript.rs +++ b/src/gourd/post/tests/afterscript.rs @@ -1,143 +1,64 @@ use std::fs; -use std::fs::File; use std::fs::Permissions; -use std::io::Read; use std::io::Write; use std::os::unix::fs::PermissionsExt; -use std::path::PathBuf; -use gourd_lib::config::Config; -use gourd_lib::experiment::Environment; -use gourd_lib::experiment::Experiment; -use gourd_lib::file_system::FileSystemInteractor; +use gourd_lib::config::UserInput; +use gourd_lib::config::UserProgram; use tempdir::TempDir; -use crate::experiments::ExperimentExt; -use crate::post::afterscript::run_afterscript_for_run; -use crate::post::labels::assign_label; +use crate::post::afterscript::run_afterscripts_for_experiment; +use crate::test_utils::create_sample_experiment; +use crate::test_utils::REAL_FS; -const PREPROGRAMMED_SH_SCRIPT: &str = r#"#!/bin/sh -tr '[a-z]' '[A-Z]' <$1 >$2 -"#; - -const PRE_PROGRAMMED_RESULTS: &str = r#"[package] -name = "gourd" -edition = "2021" - -[dependencies] +const PRE_PROGRAMMED_SH_SCRIPT: &str = r#"#!/bin/sh +echo "🩴" "#; #[test] fn test_run_afterscript_for_run_good_weather() { - let tmp_dir = TempDir::new("testing").unwrap(); - - let results_path = tmp_dir.path().join("results.toml"); - let results_file = fs::File::create(&results_path).unwrap(); - fs::write(&results_path, PRE_PROGRAMMED_RESULTS).unwrap(); - - let afterscript_path = tmp_dir.path().join("afterscript.sh"); - let afterscript_file = fs::File::create(&afterscript_path).unwrap(); - - fs::write(&afterscript_path, PREPROGRAMMED_SH_SCRIPT).unwrap(); - afterscript_file - .set_permissions(Permissions::from_mode(0o755)) - .unwrap(); - - drop(afterscript_file); - - let output_path = tmp_dir.path().join("afterscript_output.toml"); - - run_afterscript_for_run( - &afterscript_path, - &results_path, - &output_path, - tmp_dir.path(), - ) - .unwrap(); - - let mut contents = String::new(); - assert!(fs::File::open(output_path) - .unwrap() - .read_to_string(&mut contents) - .is_ok()); - assert_eq!(contents, PRE_PROGRAMMED_RESULTS.to_ascii_uppercase()); - - drop(results_file); - - assert!(tmp_dir.close().is_ok()); -} - -#[test] -fn test_run_afterscript_for_run_bad_weather() { - let tmp_dir = TempDir::new("testing").unwrap(); - - let results_path = tmp_dir.path().join("results.toml"); - let results_file = File::create(&results_path).unwrap(); - fs::write(&results_path, PRE_PROGRAMMED_RESULTS).unwrap(); - - let output_path = tmp_dir.path().join("afterscript_output.toml"); - - assert!(run_afterscript_for_run( - &PathBuf::from(""), - &results_path, - &output_path, - tmp_dir.path() - ) - .is_err()); - - drop(results_file); - - assert!(tmp_dir.close().is_ok()); -} - -#[test] -fn test_add_label_to_run() { - let fs = FileSystemInteractor { dry_run: true }; - let dir = TempDir::new("config_folder").expect("A temp folder could not be created."); - let file_pb = dir.path().join("file.toml"); - let config_contents = r#" - output_path = "./goose" - metrics_path = "./🪿/" - experiments_folder = "/tmp/gourd/experiments/" - [program.a] - binary = "/bin/sleep" - arguments = [] - afterscript = "/bin/echo" - [input.b] - arguments = ["1"] - [input.c] - arguments = ["2"] - [label.found_hello] - priority = 0 - regex = "hello" - [label.found_world] - priority = 1 - regex = "world" - "#; - let mut file = File::create(file_pb.as_path()).expect("A file could not be created."); - file.write_all(config_contents.as_bytes()) - .expect("The test file could not be written."); - let mut after_file = - File::create(dir.path().join("after.txt")).expect("A file could not be created."); - after_file - .write_all("hello".as_bytes()) - .expect("The test file could not be written."); - - let conf = Config::from_file(file_pb.as_path(), &fs).unwrap(); - let exp = - Experiment::from_config(&conf, chrono::Local::now(), Environment::Local, &fs).unwrap(); - assert!(conf.labels.is_some()); - assert_eq!( - assign_label(&exp, &dir.path().join("after.txt"), &fs).expect("tested fn failed"), - Some("found_hello".to_string()) + let dir = TempDir::new("after_test").unwrap(); + let script_path = dir.path().join("script"); + { + let mut script_file = fs::File::create(&script_path).unwrap(); + script_file + .write_all(PRE_PROGRAMMED_SH_SCRIPT.as_bytes()) + .unwrap(); + script_file + .set_permissions(Permissions::from_mode(0o755)) + .unwrap(); + } // drop script_file + let (mut sample, _) = create_sample_experiment( + [( + "test".into(), + UserProgram { + binary: Some(script_path.clone()), + fetch: None, + git: None, + arguments: vec![], + afterscript: Some(script_path.clone()), + resource_limits: None, + next: vec![], + }, + )] + .into(), + [( + "inp".into(), + UserInput { + file: None, + glob: None, + fetch: None, + group: None, + arguments: vec!["hi".into()], + }, + )] + .into(), ); - after_file - .write_all("hello world".as_bytes()) - .expect("The test file could not be written."); + run_afterscripts_for_experiment(&mut sample, &REAL_FS).unwrap(); - assert_eq!( - assign_label(&exp, &dir.path().join("after.txt"), &fs).expect("tested fn failed"), - Some("found_world".to_string()) - ); + assert!(sample.runs[0] + .afterscript_output + .as_ref() + .is_some_and(|o| o.contains("🩴"))); } diff --git a/src/gourd/post/tests/labels.rs b/src/gourd/post/tests/labels.rs new file mode 100644 index 0000000..5b4fc92 --- /dev/null +++ b/src/gourd/post/tests/labels.rs @@ -0,0 +1,54 @@ +use std::fs::File; +use std::io::Write; + +use gourd_lib::config::Config; +use gourd_lib::experiment::Environment; +use gourd_lib::experiment::Experiment; +use gourd_lib::file_system::FileSystemInteractor; +use tempdir::TempDir; + +use crate::experiments::ExperimentExt; +use crate::post::labels::assign_label; + +#[test] +fn test_add_label_to_run() { + let fs = FileSystemInteractor { dry_run: true }; + let dir = TempDir::new("config_folder").expect("A temp folder could not be created."); + let file_pb = dir.path().join("file.toml"); + let config_contents = r#" + output_path = "./goose" + metrics_path = "./🪿/" + experiments_folder = "/tmp/gourd/experiments/" + [program.a] + binary = "/bin/sleep" + arguments = [] + afterscript = "/bin/echo" + [input.b] + arguments = ["1"] + [input.c] + arguments = ["2"] + [label.found_hello] + priority = 0 + regex = "hello" + [label.found_world] + priority = 1 + regex = "world" + "#; + let mut file = File::create(file_pb.as_path()).expect("A file could not be created."); + file.write_all(config_contents.as_bytes()) + .expect("The test file could not be written."); + + let conf = Config::from_file(file_pb.as_path(), &fs).unwrap(); + let exp = + Experiment::from_config(&conf, chrono::Local::now(), Environment::Local, &fs).unwrap(); + assert!(conf.labels.is_some()); + assert_eq!( + assign_label(0, "hello", &exp).expect("tested fn failed"), + Some("found_hello".to_string()) + ); + + assert_eq!( + assign_label(1, "hello world", &exp).expect("tested fn failed"), + Some("found_world".to_string()) + ); +} diff --git a/src/gourd/post/tests/mod.rs b/src/gourd/post/tests/mod.rs index ec657dd..f1a6f9c 100644 --- a/src/gourd/post/tests/mod.rs +++ b/src/gourd/post/tests/mod.rs @@ -1,2 +1,4 @@ -/// Tests for the functionality of afterscripts and labels. +/// Tests for the functionality of afterscripts. pub mod afterscript; +/// Tests for labels. +pub mod labels; diff --git a/src/gourd/rerun/mod.rs b/src/gourd/rerun/mod.rs index 075f0a8..f4fff5d 100644 --- a/src/gourd/rerun/mod.rs +++ b/src/gourd/rerun/mod.rs @@ -39,9 +39,9 @@ impl Display for RerunStatus { match self { RerunStatus::NotFinished => write!(f, "Not finished"), RerunStatus::FinishedExitZero => write!(f, "Finished with exit code 0"), - RerunStatus::FinishedSuccessLabel(l) => write!(f, "Finished with label {}", l), - RerunStatus::FailedErrorLabel(l) => write!(f, "Failed with label {}", l), - RerunStatus::FailedExitCode(c) => write!(f, "Failed with exit code {}", c), + RerunStatus::FinishedSuccessLabel(l) => write!(f, "Finished with label {l}"), + RerunStatus::FailedErrorLabel(l) => write!(f, "Failed with label {l}"), + RerunStatus::FailedExitCode(c) => write!(f, "Failed with exit code {c}"), } } } diff --git a/src/gourd/rerun/runs.rs b/src/gourd/rerun/runs.rs index 75418f7..52653b0 100644 --- a/src/gourd/rerun/runs.rs +++ b/src/gourd/rerun/runs.rs @@ -15,7 +15,7 @@ use crate::status::ExperimentStatus; /// Get the list of runs to rerun from the rerun options. pub fn get_runs_from_rerun_options( run_ids: &Option>, - experiment: &Experiment, + experiment: &mut Experiment, file_system: &mut impl FileOperations, script: bool, ) -> Result> { @@ -79,7 +79,7 @@ pub(super) fn get_what_runs_to_rerun_from_experiment( match ask(Select::new("What would you like to do?", choices.clone()).prompt())?.as_str() { x if x == choices[1] => Ok::, anyhow::Error>(all_not_rerun), x if x == choices[0] => Ok::, anyhow::Error>(failed_runs), - x => unreachable!("got: {:?}", x), + x => unreachable!("got: {x:?}"), } } diff --git a/src/gourd/rerun/slurm.rs b/src/gourd/rerun/slurm.rs index 9d7f766..22ed835 100644 --- a/src/gourd/rerun/slurm.rs +++ b/src/gourd/rerun/slurm.rs @@ -49,9 +49,8 @@ pub fn query_changing_resource_limits( }); if query_yes_no(&format!( - "{} runs ran out of memory and {} runs ran out of time. \ + "{out_of_memory} runs ran out of memory and {out_of_time} runs ran out of time. \ Do you want to change the resource limits for their programs?", - out_of_memory, out_of_time ))? { query_changing_limits_for_programs(selected_runs, experiment)?; } @@ -101,18 +100,12 @@ pub(super) fn check_single_run_failed( } RerunStatus::FailedExitCode(c) => { - debug!( - "Scheduling rerun for run #{} that failed with exit code {}", - specific_run, c - ); + debug!("Scheduling rerun for run #{specific_run} that failed with exit code {c}"); Ok(*specific_run) } RerunStatus::FailedErrorLabel(l) => { - debug!( - "Scheduling rerun for run #{} that failed with label {}", - specific_run, l - ); + debug!("Scheduling rerun for run #{specific_run} that failed with label {l}"); Ok(*specific_run) } } @@ -201,9 +194,9 @@ pub fn query_changing_limits_for_programs( None, )?; - debug!("Updating resource limits for run {}", run_id); + debug!("Updating resource limits for run {run_id}"); trace!("Old resource limits: {:?}", program.limits); - trace!("New resource limits: {:?}", new_rss); + trace!("New resource limits: {new_rss:?}"); update_program_resource_limits(*run_id, experiment, new_rss)?; changed.push(program); diff --git a/src/gourd/slurm/handler.rs b/src/gourd/slurm/handler.rs index f07ce2d..88eaa93 100644 --- a/src/gourd/slurm/handler.rs +++ b/src/gourd/slurm/handler.rs @@ -97,11 +97,7 @@ where let mut counter = 0; for (chunk_id, chunk) in chunks_to_schedule.iter().enumerate() { - debug!( - "Scheduling chunk {} with {} runs", - chunk_id, - chunk.runs.len() - ); + debug!("Scheduling chunk {chunk_id} with {} runs", chunk.runs.len()); if let Err(e) = self.internal.schedule_chunk( &slurm_config, @@ -109,7 +105,7 @@ where experiment, &fs.canonicalize(&exp_path)?, ) { - error!("Could not schedule chunk #{}: {:?}", chunk_id, e); + error!("Could not schedule chunk #{chunk_id}: {e:?}"); break; } @@ -126,16 +122,16 @@ pub fn parse_optional_args(slurm_config: &SlurmConfig) -> String { let mut result = "".to_string(); if let Some(val) = &slurm_config.begin { - result.push_str(&format!("#SBATCH --begin={}\n", val)); + result.push_str(&format!("#SBATCH --begin={val}\n")); } if let Some(val) = &slurm_config.mail_type { assert!(MAIL_TYPE_VALID_OPTIONS.contains(&val.as_str())); - result.push_str(&format!("#SBATCH --mail-type={}\n", val)) + result.push_str(&format!("#SBATCH --mail-type={val}\n")) } if let Some(val) = &slurm_config.mail_user { - result.push_str(&format!("#SBATCH --mail-user={}\n", val)) + result.push_str(&format!("#SBATCH --mail-user={val}\n")) } if let Some(args) = &slurm_config.additional_args { diff --git a/src/gourd/slurm/interactor.rs b/src/gourd/slurm/interactor.rs index 5a6a39b..a7eeba1 100644 --- a/src/gourd/slurm/interactor.rs +++ b/src/gourd/slurm/interactor.rs @@ -36,29 +36,26 @@ pub fn format_slurm_duration(duration: Duration) -> String { let secs_rem = secs % 60; if secs == secs_rem { - return format!("{:0>2}", secs); + return format!("{secs:0>2}"); } let mins = secs / 60; let mins_rem = mins % 60; if mins == mins_rem { - return format!("{:0>2}:{:0>2}", mins, secs_rem); + return format!("{mins:0>2}:{secs_rem:0>2}"); } let hours = mins / 60; let hours_rem = hours % 24; if hours == hours_rem { - return format!("{:0>2}:{:0>2}:{:0>2}", hours, mins_rem, secs_rem); + return format!("{hours:0>2}:{mins_rem:0>2}:{secs_rem:0>2}"); } let days = hours / 24; - format!( - "{}-{:0>2}:{:0>2}:{:0>2}", - days, hours_rem, mins_rem, secs_rem - ) + format!("{days}-{hours_rem:0>2}:{mins_rem:0>2}:{secs_rem:0>2}") } /// An implementation of the SlurmInteractor trait for interacting with SLURM @@ -85,12 +82,12 @@ fn sacctmgr_limit(field: &str) -> Result { .arg("withassoc") .arg("where") .arg(format!("name={}", std::env::var("USER")?)) - .arg(format!("format={}", field)) + .arg(format!("format={field}")) .arg("--parsable2") .arg("--noheader"); let out = cmd.output()?; - debug!("Running {:?} gave {:?}", cmd, &out); + debug!("Running {cmd:?} gave {:?}", &out); Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) } @@ -161,7 +158,7 @@ impl SlurmInteractor for SlurmCli { debug!("Could not parse max array size from slurm: {e}"); bailc!( "Could not parse max array size from slurm", ; - "Slurm gave output for MaxArraySize: {}", out; + "Slurm gave output for MaxArraySize: {out}", ; "If this persists, you can manually add the limit to the slurm config as \ {CMD_DOC_STYLE}array_size_limit{CMD_DOC_STYLE:#}", ) @@ -261,7 +258,7 @@ set -x chunk_index ); - debug!("Sbatch file: {}", contents); + debug!("Sbatch file: {contents}"); let mut cmd = Command::new("sbatch") .arg("--parsable") @@ -293,7 +290,7 @@ set -x if !proc.status.success() { bailc!("Sbatch failed to run", ; - "Sbatch printed: {}", String::from_utf8(proc.stderr).unwrap(); + "Sbatch printed: {}", String::from_utf8_lossy(&proc.stderr); "Please ensure that you are running on slurm", ); } @@ -417,7 +414,7 @@ set -x let mut cancel = Command::new("scancel"); cancel.args(chunk); - debug!("Running cancel: {:?}", cancel); + debug!("Running cancel: {cancel:?}"); let output = cancel.output().with_context(ctx!( "Failed to cancel runs",; @@ -426,7 +423,7 @@ set -x if !output.status.success() { bailc!("Failed to cancel runs", ; - "scancel printed: {}", String::from_utf8(output.stderr).unwrap(); + "\"scancel\" printed: {}", String::from_utf8_lossy(&output.stderr); "", ); } diff --git a/src/gourd/slurm/tests/chunk.rs b/src/gourd/slurm/tests/chunk.rs index c5620e1..8b0aacc 100644 --- a/src/gourd/slurm/tests/chunk.rs +++ b/src/gourd/slurm/tests/chunk.rs @@ -165,10 +165,10 @@ fn create_chunks_greedy_test() { { let mut inputs = BTreeMap::new(); for i in 0..5 { - _ = inputs.insert(format!("Input_A_{}", i), input_a.clone()) + _ = inputs.insert(format!("Input_A_{i}"), input_a.clone()) } for i in 0..5 { - _ = inputs.insert(format!("Input_B_{}", i), input_b.clone()) + _ = inputs.insert(format!("Input_B_{i}"), input_b.clone()) } inputs.into() }, diff --git a/src/gourd/status/chunks.rs b/src/gourd/status/chunks.rs index 63f8ece..31385f2 100644 --- a/src/gourd/status/chunks.rs +++ b/src/gourd/status/chunks.rs @@ -69,10 +69,10 @@ pub fn print_scheduling(exp: &Experiment, starting: bool) -> Result<()> { " these ones are already sent to slurm{PRIMARY_STYLE:#}" )); - info!("{}", bar); + info!("{bar}"); if total_scheduled > 0 { - info!("{}", bar_lower); + info!("{bar_lower}"); } info!(""); diff --git a/src/gourd/status/fs_based.rs b/src/gourd/status/fs_based.rs index 542d86c..24f7376 100644 --- a/src/gourd/status/fs_based.rs +++ b/src/gourd/status/fs_based.rs @@ -9,7 +9,6 @@ use log::warn; use super::FileSystemBasedStatus; use super::StatusProvider; -use crate::post::afterscript::run_afterscript; use crate::post::labels::assign_label; use crate::status::FsState; @@ -29,15 +28,15 @@ where for (run_id, run) in experiment.runs.iter().enumerate() { trace!( - "Reading status for run {} from {:?}", - run_id, + "Reading status for run {run_id} from {:?}", run.metrics_path ); let metrics = match fs.try_read_toml::(&run.metrics_path) { Ok(x) => Some(x), Err(e) => { - trace!("Failed to read metrics: {:?}", e); + trace!("Failed to read metrics: {e:?}"); + None } }; @@ -52,15 +51,13 @@ where let mut afterscript_completion = None; - if run.afterscript_output_path.is_some() && completion.has_succeeded() { - afterscript_completion = match Self::get_afterscript_status(run_id, experiment, fs) - { + if run.afterscript_output.is_some() && completion.has_succeeded() { + afterscript_completion = match Self::get_afterscript_status(run_id, experiment) { Ok(status) => Some(status), Err(e) => { warn!( - "No output found for afterscript of run #{}: {e}\n\ + "No output found for afterscript of run #{run_id}: {e}\n\ Check that the afterscript correctly places the output in a file.", - run_id ); None } @@ -81,19 +78,11 @@ where impl FileBasedProvider { /// Get the completion of an afterscript. - pub fn get_afterscript_status( - run_id: usize, - exp: &Experiment, - fs: &impl FileOperations, - ) -> Result> { + pub fn get_afterscript_status(run_id: usize, exp: &Experiment) -> Result> { let run = &exp.runs[run_id]; - if let Some(file) = run.afterscript_output_path.clone() { - if !file.exists() { - run_afterscript(run_id, exp)?; - } - - assign_label(exp, &file, fs) + if let Some(text_output) = run.afterscript_output.clone() { + assign_label(run_id, &text_output, exp) } else { Ok(None) } diff --git a/src/gourd/status/mod.rs b/src/gourd/status/mod.rs index d69f977..df655ad 100644 --- a/src/gourd/status/mod.rs +++ b/src/gourd/status/mod.rs @@ -135,7 +135,8 @@ pub struct SlurmBasedStatus { pub exit_code_slurm: isize, } -/// All possible postprocessing statuses of a run. +/// The status of a single run. Contains [`FileSystemBasedStatus`], +/// and in case of running on slurm [`SlurmBasedStatus`] and [`SlurmFileOutput`] #[derive(Debug, Clone, PartialEq)] pub struct Status { /// Status retrieved from slurm. @@ -207,7 +208,7 @@ impl Status { }; let c = match &self.fs_status.afterscript_completion { Some(Some(label)) => { - if let Some(l) = &experiment.labels.map.get(label) { + if let Some(l) = &experiment.labels.get(label) { l.rerun_by_default } else { false @@ -242,6 +243,9 @@ pub trait StatusProvider { /// Instead of storing a possibly outdated status somewhere, every time it is /// needed it's fetched by the status module directly. +/// +/// Since this will actively fetch status, it can modify the experiment to cache +/// any potential intermediate results (eg afterscript outputs) pub trait DynamicStatus { /// Get an up-to-date [`ExperimentStatus`]. fn status(&self, fs: &impl FileOperations) -> Result; diff --git a/src/gourd/status/printing.rs b/src/gourd/status/printing.rs index c14bdb7..01d1037 100644 --- a/src/gourd/status/printing.rs +++ b/src/gourd/status/printing.rs @@ -1,4 +1,5 @@ use std::cmp::max; +use std::collections::btree_map::Entry; use std::collections::BTreeMap; use std::fmt::Display; use std::io::Write; @@ -6,6 +7,7 @@ use std::io::Write; use anyhow::anyhow; use anyhow::Context; use anyhow::Result; +use gourd_lib::constants::CMD_DOC_STYLE; use gourd_lib::constants::ERROR_STYLE; use gourd_lib::constants::NAME_STYLE; use gourd_lib::constants::PARAGRAPH_STYLE; @@ -27,7 +29,6 @@ use super::SlurmState; use super::Status; #[cfg(not(tarpaulin_include))] // There are no meaningful tests for an enum's Display implementation - impl Display for SlurmState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -59,29 +60,28 @@ impl Display for FsState { FsState::Pending => write!(f, "pending?"), FsState::Running => write!(f, "running!"), FsState::Completed(metrics) => { - if metrics.exit_code == 0 { + if f.sign_minus() { + // reduced output, guarantees similar length output to pending? and running! + write!(f, "completed") + } else if metrics.exit_code == 0 { if f.alternate() { write!( f, - "{}success{:#} {NAME_STYLE}wall clock time{NAME_STYLE:#}: {}", - PRIMARY_STYLE, - PRIMARY_STYLE, + "{PRIMARY_STYLE}success{PRIMARY_STYLE:#} {NAME_STYLE}wall clock time{NAME_STYLE:#}: {}", humantime::Duration::from(metrics.wall_micros) ) } else { write!( f, - "{}success{:#}, took: {}", - PRIMARY_STYLE, - PRIMARY_STYLE, + "{PRIMARY_STYLE}success{PRIMARY_STYLE:#}, took: {}", humantime::Duration::from(metrics.wall_micros) ) } } else { write!( f, - "{}failed, code: {}{:#}", - ERROR_STYLE, metrics.exit_code, ERROR_STYLE + "{ERROR_STYLE}failed, code: {}{ERROR_STYLE:#}", + metrics.exit_code, ) } } @@ -171,48 +171,52 @@ fn short_status( let mut by_program: BTreeMap = BTreeMap::new(); for (run_id, run_data) in runs.iter().enumerate() { - if !by_program.contains_key(&run_data.program.to_string()) { - by_program.insert(run_data.program.clone().to_string(), (0, 0, 0, 0)); - } + let prog = experiment.programs[run_data.program].name.clone(); + match by_program.entry(prog) { + Entry::Vacant(e) => { + e.insert((0, 0, 0, 0)); + } + Entry::Occupied(mut o) => { + let mut for_this_prog = *o.get(); - if let Some(for_this_prog) = by_program.get_mut(&run_data.program.to_string()) { - let status = statuses[&run_id].clone(); + let status = statuses[&run_id].clone(); - if status.is_completed() { - for_this_prog.0 += 1; - } + if status.is_completed() { + for_this_prog.0 += 1; + } - if status.has_failed(experiment) { - for_this_prog.1 += 1; - } + if status.has_failed(experiment) { + for_this_prog.1 += 1; + } - if status.is_scheduled() { - for_this_prog.2 += 1; - } + if status.is_scheduled() { + for_this_prog.2 += 1; + } - for_this_prog.3 += 1; + for_this_prog.3 += 1; + + o.insert(for_this_prog); + } } } for (prog, (completed, failed, sched, total)) in by_program { writeln!(f)?; - writeln!(f, "For program {}:", prog)?; + writeln!(f, "For program {prog}:")?; if experiment.env == Environment::Slurm { - writeln!(f, " {} jobs have been scheduled", sched)?; + writeln!(f, " {sched} jobs have been scheduled",)?; } else { - writeln!(f, " {} runs have been created", total)?; + writeln!(f, " {total} runs have been created",)?; } writeln!( f, - " ... {} of which have {TERTIARY_STYLE}completed{TERTIARY_STYLE:#}", - completed + " ... {completed} of which have {TERTIARY_STYLE}completed{TERTIARY_STYLE:#}", )?; writeln!( f, - " ... {} of which have {ERROR_STYLE}failed{ERROR_STYLE:#}", - failed + " ... {failed} of which have {ERROR_STYLE}failed{ERROR_STYLE:#}", )?; writeln!( f, @@ -233,12 +237,12 @@ fn format_input_name(exp: &Experiment, run: &Run, grouped: bool) -> String { if let Some(input_name) = &run.generated_from_input { input_name.clone() } else if let Some(parent_id) = run.parent { - format!("postprocessing of {}", parent_id) + format!("postprocessing of {parent_id}",) } else { unreachable!("A run cannot spawn out of thin air!"); } } else if let Some(input_name) = &run.generated_from_input { - format!("{} ({})", exp.programs[run.program].name, input_name) + format!("{} ({input_name})", exp.programs[run.program].name,) } else { // when this function was implemented this branch was unreachable, // but it is reasonable that this will change in the future, and not @@ -293,7 +297,7 @@ fn long_status( for (prog, prog_runs) in by_program { writeln!(f)?; - writeln!(f, "For program {}:", prog)?; + writeln!(f, "For program {prog}:")?; display_runs( false, @@ -309,7 +313,7 @@ fn long_status( for (prog, prog_runs) in grouped_runs { writeln!(f)?; - writeln!(f, "For group {}:", prog)?; + writeln!(f, "For group {prog}:")?; display_runs( true, @@ -343,13 +347,12 @@ fn display_runs( write!( f, - " {: >numw$}. {NAME_STYLE}{:.numw$}. {NAME_STYLE}{:.numw$}a {:.numw$}a {:.numw$}a {TERTIARY_STYLE}afterscript ran \ - successfully{TERTIARY_STYLE:#}", - run_id, - numw = longest_index, + " {run_id: >longest_index$}a {TERTIARY_STYLE}afterscript ran \ + successfully{TERTIARY_STYLE:#}" )?; writeln!(f)?; @@ -404,7 +404,7 @@ fn display_runs( Ok(()) } -/// Display the status of an experiment in a human readable from. +/// Display the status of an experiment in a human-readable from. #[cfg(not(tarpaulin_include))] // We can't test stdout pub fn display_job( f: &mut impl Write, @@ -412,9 +412,12 @@ pub fn display_job( statuses: &ExperimentStatus, id: usize, ) -> Result<()> { + use gourd_lib::constants::TRUNCATE_AFTERSCRIPT_OUTPUT; + use log::debug; + info!( - "Displaying the status of job {} in experiment {}", - id, exp.seq + "Displaying the status of job {id} in experiment {}", + exp.seq ); writeln!(f)?; @@ -444,11 +447,11 @@ pub fn display_job( writeln!( f, " {NAME_STYLE}arguments{NAME_STYLE:#}: {:?}\n", - run.input.arguments + run.input.args )?; if let Some(group) = &run.group { - writeln!(f, "{NAME_STYLE}group{NAME_STYLE:#}: {}", group)?; + writeln!(f, "{NAME_STYLE}group{NAME_STYLE:#}: {group}")?; } writeln!( @@ -470,8 +473,8 @@ pub fn display_job( if let Some(slurm_id) = &run.slurm_id { writeln!( f, - "scheduled on slurm as {TERTIARY_STYLE}{}{TERTIARY_STYLE:#}\nwith limits\n{}", - slurm_id, run.limits + "scheduled on slurm as {TERTIARY_STYLE}{slurm_id}{TERTIARY_STYLE:#}\nwith limits\n{}", + run.limits )?; if let Some(slurm_file) = &statuses[&id].slurm_file_text { @@ -486,14 +489,14 @@ pub fn display_job( "{NAME_STYLE}Slurm job stdout{NAME_STYLE:#} ({PATH_STYLE}{}{PATH_STYLE:#}): \"{PARAGRAPH_STYLE}{}{PARAGRAPH_STYLE:#}\"", slurm_out.display(), - slurm_file.stdout + slurm_file.stdout.trim() )?; writeln!( f, "{NAME_STYLE}Slurm job stderr{NAME_STYLE:#} ({PATH_STYLE}{}{PATH_STYLE:#}): \"{PARAGRAPH_STYLE}{}{PARAGRAPH_STYLE:#}\"", slurm_err.display(), - slurm_file.stderr + slurm_file.stderr.trim() )?; } } @@ -503,7 +506,7 @@ pub fn display_job( writeln!(f, "{status:#}")?; if let Some(Some(label_text)) = &status.fs_status.afterscript_completion { - let display_style = if exp.labels.map[label_text].rerun_by_default { + let display_style = if exp.labels[label_text].rerun_by_default { ERROR_STYLE } else { PRIMARY_STYLE @@ -517,12 +520,50 @@ pub fn display_job( writeln!(f)?; } else if let Some(None) = &status.fs_status.afterscript_completion { - writeln!( - f, - "{TERTIARY_STYLE}afterscript ran successfully{TERTIARY_STYLE:#}", - )?; + if let Some(mut out) = exp.runs[id].afterscript_output.clone() { + writeln!( + f, + "{TERTIARY_STYLE}afterscript ran successfully{TERTIARY_STYLE:#}", + )?; - writeln!(f)?; + let truncate_output = |x: &mut String| { + let mut touch = false; + if x.len() > TRUNCATE_AFTERSCRIPT_OUTPUT.0 { + touch = true; + x.truncate(TRUNCATE_AFTERSCRIPT_OUTPUT.0); + } + if x.lines().count() > TRUNCATE_AFTERSCRIPT_OUTPUT.1 { + *x = x.lines().take(TRUNCATE_AFTERSCRIPT_OUTPUT.1).collect(); + touch = true; + } + touch + }; + + if truncate_output(&mut out) { + debug!("truncating afterscript output for gourd status -i {id}"); + writeln!( + f, + "afterscript output was too long, run {CMD_DOC_STYLE} gourd status {} -i {id} --after-out {CMD_DOC_STYLE:#} to view entire output + +shortened output:\n{PARAGRAPH_STYLE}{out}[truncated]{PARAGRAPH_STYLE:#}", + exp.seq, + )?; + } else { + writeln!( + f, + "afterscript output:\n{PARAGRAPH_STYLE}{out}{PARAGRAPH_STYLE:#}", + )?; + } + + writeln!(f)?; + } else { + unreachable!( + "this is not supposed to happen, \ + please contact the developers on \ + https://github.com/ConSol-Lab/gourd/issues/new \ + with screenshots (AFT_COMPL_OUT_IS_NONE)" + ); + } } if let Some(new_id) = run.rerun { diff --git a/src/gourd/status/slurm_based.rs b/src/gourd/status/slurm_based.rs index e0c8c4c..26ebb8e 100644 --- a/src/gourd/status/slurm_based.rs +++ b/src/gourd/status/slurm_based.rs @@ -172,11 +172,11 @@ pub fn flatten_slurm_id(id: String) -> Result> { let end: usize = over_separators[1].parse()?; for run_id in begin..=end { - result.push(format!("{}_{}", batch_id, run_id)) + result.push(format!("{batch_id}_{run_id}")) } } else if over_separators.len() == 1 { let run_id: usize = over_separators[0].parse()?; - result.push(format!("{}_{}", batch_id, run_id)) + result.push(format!("{batch_id}_{run_id}")) } } } @@ -185,7 +185,7 @@ pub fn flatten_slurm_id(id: String) -> Result> { let batch_id = &captures[1]; let run_id = captures[2].parse::()?; - result.push(format!("{}_{}", batch_id, run_id)) + result.push(format!("{batch_id}_{run_id}")) } Ok(result) diff --git a/src/gourd/status/slurm_files.rs b/src/gourd/status/slurm_files.rs index 5878fab..a6ff48f 100644 --- a/src/gourd/status/slurm_files.rs +++ b/src/gourd/status/slurm_files.rs @@ -42,7 +42,7 @@ where ) { Ok(slurm_output) => slurm_output, Err(e) => { - debug!("Failed to read slurm output for run {}: {}", run_id, e); + debug!("Failed to read slurm output for run {run_id}: {e}"); "File not created yet".to_string() } }; @@ -53,7 +53,7 @@ where ) { Ok(slurm_output) => slurm_output, Err(e) => { - debug!("Failed to read slurm output for run {}: {}", run_id, e); + debug!("Failed to read slurm output for run {run_id}: {e}"); "File not created yet".to_string() } }; diff --git a/src/gourd/test_utils.rs b/src/gourd/test_utils.rs index 2d62842..f4b5729 100644 --- a/src/gourd/test_utils.rs +++ b/src/gourd/test_utils.rs @@ -19,8 +19,11 @@ use tempdir::TempDir; use crate::experiments::ExperimentExt; +/// a file system interactor that *will* touch files pub const REAL_FS: FileSystemInteractor = FileSystemInteractor { dry_run: false }; +/// compile the rust file provided into a binary, and place it in its own +/// tempdir pub fn get_compiled_example(contents: &str, extra_args: Option>) -> (PathBuf, PathBuf) { let tmp = TempDir::new("match").unwrap().into_path(); @@ -39,6 +42,7 @@ pub fn get_compiled_example(contents: &str, extra_args: Option>) -> (P (out, tmp) } +/// a template experiment pub fn create_sample_experiment( prog: BTreeMap, inputs: BTreeMap, @@ -55,7 +59,6 @@ pub fn create_sample_experiment( slurm: None, resource_limits: None, labels: Some(BTreeMap::new()), - warn_on_label_overlap: false, }; ( diff --git a/src/gourd/wrapper/mod.rs b/src/gourd/wrapper/mod.rs index 4826a4d..40a425b 100644 --- a/src/gourd/wrapper/mod.rs +++ b/src/gourd/wrapper/mod.rs @@ -51,11 +51,11 @@ pub fn wrap( verify_arch(&program.binary, arch, fs)?; - let mut cmd = Command::new(&experiment.wrapper); + let mut cmd = Command::new(shellexpand::tilde(&experiment.wrapper).to_string()); cmd.arg(experiment.file()) - .arg(format!("{}", chunk_index)) - .arg(format!("{}", task_id)); + .arg(format!("{chunk_index}")) + .arg(format!("{task_id}")); result.push(cmd); } diff --git a/src/gourd/wrapper/tests/mod.rs b/src/gourd/wrapper/tests/mod.rs index 73f8e2b..f1d3037 100644 --- a/src/gourd/wrapper/tests/mod.rs +++ b/src/gourd/wrapper/tests/mod.rs @@ -79,12 +79,7 @@ fn non_matching_arch() { assert!(format!("{}", err.root_cause()).contains("not match the expected architecture")) } - e => { - panic!( - "Did not return the correct architecture mismatch, was: {:?}", - e - ); - } + e => panic!("Did not return the correct architecture mismatch, was: {e:?}"), } } diff --git a/src/gourd/wrapper/tests/test_resources/num_returner.rs b/src/gourd/wrapper/tests/test_resources/num_returner.rs index 34ceecb..d9767c6 100644 --- a/src/gourd/wrapper/tests/test_resources/num_returner.rs +++ b/src/gourd/wrapper/tests/test_resources/num_returner.rs @@ -11,5 +11,5 @@ fn main() { let num: i32 = inpt.trim().parse().unwrap(); - println!("{}", num); + println!("{num}"); } diff --git a/src/gourd_lib/config/fetching.rs b/src/gourd_lib/config/fetching.rs index 6883f57..036427d 100644 --- a/src/gourd_lib/config/fetching.rs +++ b/src/gourd_lib/config/fetching.rs @@ -4,6 +4,7 @@ use anyhow::Context; use anyhow::Result; use git2::build::RepoBuilder; use log::debug; +use log::info; use super::GitProgram; use crate::config::FetchedResource; @@ -51,7 +52,7 @@ impl FetchedResource { /// Fetch a program from a git repository. pub fn fetch_git(program: &GitProgram) -> Result { - debug!("Fetching git program from {}", program.git_uri); + info!("Fetching git program from {}", program.git_uri); let repo_base = PathBuf::from(format!("./{}", program.commit_id)); @@ -78,7 +79,7 @@ pub fn fetch_git(program: &GitProgram) -> Result { let bc = program.build_command.clone(); - debug!("Running build command {}", bc); + info!("Running build command {bc}"); let augumented = vec!["-c", &bc]; diff --git a/src/gourd_lib/config/maps.rs b/src/gourd_lib/config/maps.rs index 031fbe5..90562b1 100644 --- a/src/gourd_lib/config/maps.rs +++ b/src/gourd_lib/config/maps.rs @@ -9,21 +9,28 @@ use anyhow::anyhow; use anyhow::Context; use anyhow::Result; use glob::glob; +use log::debug; +use log::warn; use super::UserInput; +use crate::constants::CMD_DOC_STYLE; +use crate::constants::CMD_STYLE; use crate::constants::GLOB_ESCAPE; +use crate::constants::HELP_STYLE; use crate::constants::INTERNAL_GLOB; use crate::constants::INTERNAL_PREFIX; +use crate::constants::PRIMARY_STYLE; +use crate::constants::WARNING_STYLE; use crate::ctx; use crate::file_system::FileOperations; /// This will take a path and canonicalize it. pub fn canon_path(path: &Path, fs: &impl FileOperations) -> Result { fs.canonicalize(path) - .map_err(|_| { + .map_err(|e| { + debug!("Canonicalize error: {e:?}"); anyhow!( - "failed to find {:?} with workdir {:?}", - path, + "failed to find {path:?} with workdir {:?}", current_dir().unwrap() ) }) @@ -35,7 +42,7 @@ pub fn canon_path(path: &Path, fs: &impl FileOperations) -> Result { /// # Examples /// ```toml /// [inputs.test_input] -/// arguments = [ "=glob=/test/**/*.jpg" ] +/// arguments = [ "path|/test/**/*.jpg" ] /// ``` /// /// May get expanded to: @@ -66,7 +73,8 @@ pub fn expand_argument_globs( let mut next_globset = HashSet::new(); for input_instance in &globset { - is_glob |= explode_glob_set(input_instance, arg_index, &mut next_globset, fs)?; + is_glob |= + explode_glob_set(input_instance, original, arg_index, &mut next_globset, fs)?; } swap(&mut globset, &mut next_globset); @@ -75,7 +83,7 @@ pub fn expand_argument_globs( if is_glob { for (idx, glob) in globset.iter().enumerate() { result.insert( - format!("{}{}{}{}", original, INTERNAL_PREFIX, INTERNAL_GLOB, idx), + format!("{original}{INTERNAL_PREFIX}{INTERNAL_GLOB}{idx}"), glob.clone(), ); } @@ -91,6 +99,7 @@ pub fn expand_argument_globs( /// argument and put the results in `fill`. fn explode_glob_set( input: &UserInput, + input_name: &str, // only used for warnings. arg_index: usize, fill: &mut HashSet, fs: &impl FileOperations, @@ -121,6 +130,20 @@ fn explode_glob_set( Ok(true) } else { + if Path::new(arg).iter().count() > 1 { + warn!( + " \n\ + It looks like you specified a path argument: \ + {WARNING_STYLE}{arg}{WARNING_STYLE:#} \ + in input {PRIMARY_STYLE}{input_name}{PRIMARY_STYLE:#}, \ + but did not prefix it with {CMD_DOC_STYLE} {GLOB_ESCAPE} {CMD_DOC_STYLE:#}\n\ + {HELP_STYLE}tip:{HELP_STYLE:#} Consider changing the argument to \ + {CMD_STYLE}\"{GLOB_ESCAPE}{arg}\"{CMD_STYLE:#} \ + in order to canonicalize the path and expand any globs.\n\n\ + {HELP_STYLE}You can safely ignore this warning.{HELP_STYLE:#}\ + " + ); + } fill.insert(input.clone()); Ok(false) } diff --git a/src/gourd_lib/config/mod.rs b/src/gourd_lib/config/mod.rs index a98c3e8..b87a7b6 100644 --- a/src/gourd_lib/config/mod.rs +++ b/src/gourd_lib/config/mod.rs @@ -7,12 +7,10 @@ use anyhow::Result; use serde::Deserialize; use serde::Serialize; -use crate::constants::AFTERSCRIPT_DEFAULT; use crate::constants::CMD_STYLE; use crate::constants::EMPTY_ARGS; use crate::constants::INTERNAL_PREFIX; use crate::constants::INTERNAL_SCHEMA_INPUTS; -use crate::constants::LABEL_OVERLAP_DEFAULT; use crate::constants::RERUN_LABEL_BY_DEFAULT; use crate::constants::WRAPPER_DEFAULT; use crate::error::ctx; @@ -63,7 +61,12 @@ pub struct UserProgram { pub arguments: Vec, /// The path to the afterscript, if there is one. - #[serde(default = "AFTERSCRIPT_DEFAULT")] + /// + /// Afterscripts are run after the main program has finished. + /// It can be used for a quick postprocess of the main program's output, + /// and the afterscript output can be used for labeling the job in `gourd + /// status`, or serving as a custom metric in CSV exporting. + #[serde(default)] pub afterscript: Option, /// Resource limits to optionally overwrite default resource limits. @@ -240,6 +243,7 @@ pub struct Label { /// The priority of the label. Higher numbers mean higher priority, and if /// label is present it will override lower priority labels, even if /// they are also present. + #[serde(default)] pub priority: u64, /// Whether using rerun failed will rerun this job- ie is this label a @@ -258,7 +262,6 @@ pub struct Label { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct Config { - // // Basic settings. /// The path to a folder where the experiment output will be stored. pub output_path: PathBuf, @@ -295,7 +298,10 @@ pub struct Config { // // Advanced settings. /// The command to execute to get to the wrapper. - #[serde(default = "WRAPPER_DEFAULT")] + #[serde( + default = "WRAPPER_DEFAULT", + skip_serializing_if = "wrapper_is_default" + )] pub wrapper: String, /// Allow custom labels to be assigned based on the afterscript output. @@ -311,11 +317,6 @@ pub struct Config { /// ``` #[serde(rename = "label")] pub labels: Option>, - - /// If set to true, will throw an error when multiple labels are present in - /// afterscript output. - #[serde(default = "LABEL_OVERLAP_DEFAULT")] - pub warn_on_label_overlap: bool, } // An implementation that provides a default value of `Config`, @@ -334,7 +335,6 @@ impl Default for Config { slurm: None, resource_limits: None, labels: Some(BTreeMap::new()), - warn_on_label_overlap: true, } } } @@ -362,13 +362,13 @@ impl Config { pub fn parse_schema_inputs( path_buf: &Path, mut inputs: BTreeMap, - fso: &impl FileOperations, + fs: &impl FileOperations, ) -> Result> { - let hi = fso.try_read_toml::(path_buf)?; + let hi = fs.try_read_toml::(path_buf)?; for (idx, input) in hi.inputs.iter().enumerate() { inputs.insert( - format!("{}{}{}", idx, INTERNAL_PREFIX, INTERNAL_SCHEMA_INPUTS), + format!("{idx}{INTERNAL_PREFIX}{INTERNAL_SCHEMA_INPUTS}"), input.clone(), ); } @@ -377,6 +377,12 @@ impl Config { } } +/// Is the wrapper at its default value. +/// Used for skipping serialisation. +fn wrapper_is_default(w: &String) -> bool { + w.eq(&WRAPPER_DEFAULT()) +} + #[cfg(test)] #[path = "tests/mod.rs"] mod tests; diff --git a/src/gourd_lib/config/parameters.rs b/src/gourd_lib/config/parameters.rs index 5e92074..9999f4e 100644 --- a/src/gourd_lib/config/parameters.rs +++ b/src/gourd_lib/config/parameters.rs @@ -22,13 +22,13 @@ pub fn validate_parameters(parameters: &BTreeMap) -> Result<( bailc!( "Parameter specified incorrectly", ; "Parameter can have either values or subparameters, not both", ; - "Parameter name {}", p_name + "Parameter name {p_name}", ); } else if p.sub.is_none() && p.values.is_none() { bailc!( "Parameter specified incorrectly", ; "Parameter must have either values or subparameters, currently has none", ; - "Parameter name {}", p_name + "Parameter name {p_name}", ); } } diff --git a/src/gourd_lib/config/tests/mod.rs b/src/gourd_lib/config/tests/mod.rs index 38ecbf0..1148668 100644 --- a/src/gourd_lib/config/tests/mod.rs +++ b/src/gourd_lib/config/tests/mod.rs @@ -36,7 +36,6 @@ fn breaking_changes_config_struct() { slurm: None, resource_limits: None, labels: Some(BTreeMap::new()), - warn_on_label_overlap: false, }; } @@ -75,7 +74,6 @@ fn breaking_changes_config_file_all_values() { slurm: None, resource_limits: None, labels: None, - warn_on_label_overlap: false, }, Config::from_file(file_pathbuf.as_path(), &REAL_FS).expect("Unexpected config read error.") ); @@ -112,7 +110,6 @@ fn breaking_changes_config_file_required_values() { slurm: None, resource_limits: None, labels: None, - warn_on_label_overlap: false, }, Config::from_file(file_pb.as_path(), &REAL_FS).expect("Unexpected config read error.") ); @@ -333,7 +330,6 @@ fn parse_valid_escape_hatch_file() { resource_limits: None, wrapper: WRAPPER_DEFAULT(), labels: None, - warn_on_label_overlap: false, }; assert_eq!(c1, c2); } diff --git a/src/gourd_lib/constants.rs b/src/gourd_lib/constants.rs index 3cd10c8..6dad626 100644 --- a/src/gourd_lib/constants.rs +++ b/src/gourd_lib/constants.rs @@ -9,7 +9,13 @@ use anstyle::Style; use crate::config::slurm::ResourceLimits; /// The version name for Gourd! -pub const GOURD_VERSION: &str = "Snake Gourd"; +/// Ensure it matches: +/// - Cargo.toml +/// - docs/maintainer/version-history/section.tex +/// - docs/user/gourd-tutorial.7.tex +/// - docs/user/gourd.1.tex +/// - docs/user/gourd.toml.5.tex +pub const GOURD_VERSION: &str = "Sponge Gourd"; /// The default path to the wrapper, that is, we assume `gourd_wrapper` is in /// $PATH. @@ -31,12 +37,18 @@ pub const LABEL_OVERLAP_DEFAULT: fn() -> bool = || false; pub const EMPTY_ARGS: fn() -> Vec = Vec::new; /// The prefix which will cause an argument to be interpreted as a glob. +/// Ensure matches: +/// - docs/user/gourd.toml.5 pub const GLOB_ESCAPE: &str = "path|"; /// The prefix which will cause an argument to be interpreted as a parameter. +/// Ensure matches: +/// - docs/user/gourd.toml.5 pub const PARAMETER_ESCAPE: &str = "param|"; /// The prefix which will cause an argument to be interpreted as a subparameter. +/// Ensure matches: +/// - docs/user/gourd.toml.5 pub const SUB_PARAMETER_ESCAPE: &str = "subparam|"; /// The internal representation of inputs generated from a schema @@ -83,7 +95,7 @@ pub const HELP_STYLE: Style = style_from_fg(AnsiColor::Green).bold().underline() /// Style of commands in doc messages pub const CMD_DOC_STYLE: Style = Style::new() .italic() - .bg_color(Some(Ansi(AnsiColor::Blue))) + .bg_color(Some(Ansi(AnsiColor::BrightBlue))) .fg_color(Some(Ansi(AnsiColor::Black))); /// Style of commands in help messages @@ -92,7 +104,7 @@ pub const CMD_STYLE: Style = Style::new() .bg_color(Some(Ansi(AnsiColor::Green))) .fg_color(Some(Ansi(AnsiColor::Black))); -/// Style of [`Path`]s and [`PathBuf`]s +/// Style of [`std::path::Path`]s and [`PathBuf`]s pub const PATH_STYLE: Style = Style::new() .italic() .fg_color(Some(Ansi(AnsiColor::BrightBlue))); @@ -123,6 +135,10 @@ pub const MAIL_TYPE_VALID_OPTIONS: [&str; 13] = [ /// The maximal amount of runs before status only shows a short summary. pub const SHORTEN_STATUS_CUTOFF: usize = 40; +/// The max (bytes, lines) of afterscript output that will be displayed in gourd +/// status of a run +pub const TRUNCATE_AFTERSCRIPT_OUTPUT: (usize, usize) = (500, 20); + /// Maximal number of individual prompts that the user can be asked when trying /// to rerun pub const RERUN_LIST_PROMPT_CUTOFF: usize = 15; diff --git a/src/gourd_lib/error.rs b/src/gourd_lib/error.rs index 2f282cb..c6f7bca 100644 --- a/src/gourd_lib/error.rs +++ b/src/gourd_lib/error.rs @@ -5,7 +5,7 @@ use crate::constants::HELP_STYLE; /// The error context structure, provides an explanation and help. /// -/// The first element of the structre is the errors "context". +/// The first element of the structure is the errors "context". /// The second element is the help message displayed to the user. /// /// Both have to implement [Display], and will be displayed when the error is diff --git a/src/gourd_lib/experiment/labels.rs b/src/gourd_lib/experiment/labels.rs deleted file mode 100644 index e933ce3..0000000 --- a/src/gourd_lib/experiment/labels.rs +++ /dev/null @@ -1,18 +0,0 @@ -use std::collections::BTreeMap; - -use serde::Deserialize; -use serde::Serialize; - -use crate::config::Label; - -/// Label information of an [`Experiment`]. -/// -/// (struct not complete) -#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)] -pub struct Labels { - /// The labels of the experiment. - pub map: BTreeMap, - - /// Throw an error when multiple labels are present in afterscript output. - pub warn_on_label_overlap: bool, -} diff --git a/src/gourd_lib/experiment/mod.rs b/src/gourd_lib/experiment/mod.rs index e43bab7..6cc5202 100644 --- a/src/gourd_lib/experiment/mod.rs +++ b/src/gourd_lib/experiment/mod.rs @@ -14,23 +14,19 @@ use crate::config::slurm::ResourceLimits; use crate::config::slurm::SlurmConfig; use crate::config::Label; use crate::ctx; -use crate::experiment::labels::Labels; use crate::file_system::FileOperations; -/// Dealing with [`UserInput`]s and [`InternalInput`]s +/// Dealing with [`crate::config::UserInput`]s and [`InternalInput`]s pub mod inputs; -/// Everything related to [`Label`]s -pub mod labels; - -/// Dealing with [`UserProgram`]s and [`InternalProgram`]s +/// Dealing with [`crate::config::UserProgram`]s and [`InternalProgram`]s pub mod programs; -/// A string referencing a [`UserProgram`], [`InternalProgram`], [`UserInput`] -/// or [`InternalInput`]. +/// A string referencing a [`crate::config::UserProgram`], [`InternalProgram`], +/// [`crate::config::UserInput`] or [`InternalInput`]. pub type FieldRef = String; -/// The internal representation of a [`UserInput`] +/// The internal representation of a [`crate::config::UserInput`] #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] pub struct InternalInput { /// A file to pass the contents into `stdin` @@ -57,13 +53,13 @@ pub struct Metadata { pub group: Option, } -/// The internal representation of a [`UserProgram`] +/// The internal representation of a [`crate::config::UserProgram`] #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)] pub struct InternalProgram { /// The name given to this program by the user. pub name: String, - /// The [`Executable`] of this program (absolute path to it) + /// The executable of this program (absolute path to it) pub binary: PathBuf, /// An executable afterscript to run on the output of this program @@ -83,6 +79,12 @@ pub struct InternalProgram { /// The input for a [`Run`], exactly as will be passed to the wrapper for /// execution. +/// +/// `file`: [`Option`]<[`PathBuf`]> - A file whose contents to be passed into +/// the program's `stdin` +/// +/// `args`: [`Vec`]<[`String`]> - Command line arguments for this binary +/// execution. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct RunInput { /// A file whose contents to be passed into the program's `stdin` @@ -90,9 +92,9 @@ pub struct RunInput { /// Command line arguments for this binary execution. /// - /// Holds the concatenation of [`UserProgram`] specified arguments and - /// [`UserInput`] arguments. - pub arguments: Vec, + /// Holds the concatenation of [`crate::config::UserProgram`] specified + /// arguments and [`crate::config::UserInput`] arguments. + pub args: Vec, } /// Describes a matching between an algorithm and an input. @@ -114,8 +116,8 @@ pub struct Run { /// The path to the metrics file. pub metrics_path: PathBuf, - /// The path to afterscript output, if there is an afterscript. - pub afterscript_output_path: Option, + /// When the afterscript has been run, it's stdout is stored here. + pub afterscript_output: Option, /// The working directory of this run. pub work_dir: PathBuf, @@ -178,9 +180,6 @@ pub struct Experiment { /// The path to a folder where the metrics output will be stored. pub metrics_folder: PathBuf, - /// The path to a folder where the afterscript output will be stored. - pub afterscript_output_folder: PathBuf, - /// Global resource limits that will apply to _newly created chunks_. pub resource_limits: Option, @@ -188,7 +187,7 @@ pub struct Experiment { pub env: Environment, /// Labels used in this experiment. - pub labels: Labels, + pub labels: BTreeMap, /// If running on a SLURM cluster, the job configurations. pub slurm: Option, @@ -231,7 +230,6 @@ impl Experiment { /// Get the label by name. pub fn get_label(&self, name: &String) -> Result