Learning Objectives

The objective of this guide is to introduce you to Make, the tool we use in our group to build code, run scripts (typically inside a Docker container), and run experiments in parallel. At the end of this guide, you will be able to write you own Makefiles—used to define Make commands, called targets—and use them to make building code and running experiments faster and easier.

Why Make?

Make is a tool typically used for building code. It's a utility that's commonly installed on Linux machines. In particular, it is useful for automating running of code and running scripts while taking on user-specified parameters.

<aside> 📘 Make is usually used to build binaries for large code bases, where lots of command line options are possible, but not often needed. We will be using Make as a tool to both build code, but also run that code, often with the intent of running simulation experiments, running unit tests, or generating figures. It is a very flexible tool.

</aside>

I have found that Make is a really useful tool for keeping track of parameters and running code, even in reasonably large repositories and projects. My typical workflow will involve writing scripts in Python and then running those scripts with different command line arguments via make. For example, I may have a Python script that runs like this python -m scripts.some_script --experiment_name "An Example Experiment" --seed 101. If I want to run that script for many different random seeds, a common configuration, I may want to loop through many different seed values outside of Python and, ideally, run a number of these examples in parallel. With Make, that's easy: a separate "make target" can be written for each script and then the scripts can be run in parallel. Defined properly, running make -j8 run-example-experiment will run 8 parallel "jobs", each corresponding to a different random seed.

As the complexity of experiments grows, more and more arguments may need to be passed to the scripts as various configuration options. I store the default values of these arguments in a Makefile—the configuration file that specifies how Make is run—so that they can be reused across different scripts and examples. This is even more valuable as much of our group's code is run inside Docker containers, which require even more configuration options. With make, running code inside Docker containers is abstracted away, so running make -j8 run-example-experiment will happen inside Docker without you (the user) needing to worry about the details.

In this guide, I'll introduce the basics of Make and walk you through how to make more complex examples for performing more sophisticated operations and accept options at the command line.

A simple Make example

Here is a minimal working example. In a new folder, create a file called Makefile and add the following lines of code to it:

hello-world.txt:
	echo "Hello World" >> hello-world.txt

<aside> ⚠️ Note: the space proceeding the echo is actually a tab. If you do not use tabs to indent, this code will not run properly!

</aside>

Now at the command line type make hello-world.txt. Upon doing this, you will see that Make repeats your command to you (so it will print out the echo ...) and then terminates. Running ls to view the contents of the current directory, you will see that there exists a file called hello-world.txt that contains the string "Hello World". The syntax is as follows: unindented portions of code followed by a :, signal a definition of a Make target. When you run make followed by the target name, the indented block of code underneath it will be run. By default, make repeats your commands back to you, a feature I find rather annoying. Prepend the line (after the tab) with an @ to suppress this "feature".

If you run make hello-world.txt, again, you may notice that something different happens: Make tells you that it's up-to-date and that nothing needs to be done. This is because, by default, Make is designed for build systems, which means that if a file is already built and unchanged from the last time you ran Make, it will not run it again (to save on computation). For our purposes, we typically want to use make to run experimental code from scratch, so we'll be making .PHONY make targets: a signal that these targets do not correspond to a file of the same name and run regardless. Update your Makefile to look as follows:

.PHONY: hello
hello:
	@echo "Hello!"

Running make hello will output Hello! to the terminal. Notice also that command itself is not printed to the terminal, due to the presence of the @ character.

An example for accepting command line arguments

Make also allows one to easily add command line arguments using a Bash-like syntax. For example, look at the following Makefile:

.PHONY: hello-name
hello-name:
	@echo "Hello $(MY_NAME)!"