Makefile Intermediate Files

5 minute read

Updated:

I learned about intermediate files in make. Before I explain what they are, let’s go over some concepts and terminology of make.

Here are some reference documentation:

What are Dependencies

In makefile targets, you can specify dependencies that need to be ran before the recipe begins. This is a way to abstract and conceptually contain dependencies.

A good example is that you want to run tests, which requires setting up a virtualenv with test tooling. The recipe for building a debug package or installation would also require the virtualenv dependency. By abstracting out the virtualenv recipe to its own target, both test and package targets can depend on and reuse the same dependency.

Speeding Up Builds Using Dependencies

make dependencies are designed to assist in build optimization. When building a target, make will check the dependency tree to decide which dependencies to build and which can be skipped. This is done by comparing file modification timestamps:

  • if the dependency has not been modified since the target was last built, there have been no changes to warrant rebuilding the recipe.
  • if the dependency has a newer timestamp than target, the target is stale because there have been changes that are not in current target

This dependency checking and skipping is purposefully designed to avoid recompiling entire program. For C programs, only the object files for modified source code are rebuilt. All other existing objects files can be reused for the final linking step in compilation, greatly speeding things up.

Phony Targets

A target can be labelled as .PHONY, which means that the target does not output a matching physical file. An example is the test target, which is used to trigger test run. It doesn’t create a test file.

Because they don’t correspond to files on disk, we don’t want make to check the timestamp for dependency management. Usually this will work fine because the file will never exist and will always need to be “built”. But if somehow the file were erroneously created, make would incorrectly start checking the timestamp. Things working by coincidence is not great situation to be in.

Marking the target explicitly as .PHONY denotes to make that it should always run the recipe. It should ignore any file matching the target name, it was never our intention for the target to output a file.

Pattern Rules

You can parameterize recipes using patterns. i.e test_% and venv_% can be setup such that you can run make test_py27 test_py36. The % character is referred to as the “stem” of the pattern and has the $* special variable assigned to it.

Examples of things that you might want to parametrize:

  • General compiling of source into object files
  • Testing different versions of python
  • Building binaries for different distributions
  • Cross-compiling to different platforms

Intermediate Files

And now, we get to intermediate files. Intermediate files are dependencies that don’t need to exist before running make, and don’t need to exist after make finishes running.

C object files are an example of this: you need source files to exist before running make and you expect the target binary to be produced afterwards. Object files need to only exist “intermediately” as part of the build process.

What Doesn’t Exist Before, Will Not Exist After

make follows this conceptual invariant, which helps to explain a lot of design behaviour. If an intermediate file did not exist before building, it will be cleaned up afterwards. If it did exist before building, then it will continue to exist afterwards.

Dependency Handling

Intermediate files are interesting, with respect to dependency management. Again, it makes much more sense if you internalize the invariant.

If the intermediate dependency does not exist, make will continue checking the upstream dependency (some of which might be regular dependency or phony):

  • If upstream dependencies needs to be built, then the intermediate file will also need to be rebuilt.
  • If upstream dependencies are up-to-date, then make will skip building the intermediate dependency altogether.

If the intermediate dependency exists on disk, then make will treat it as a normal file and do modified timestamp checking.

How Do You Even Get Intermediate Dependency

Target and dependencies are generally not intermediate. If you reference the target/dependency explicitly anywhere in the Makefile, it will become real. Just like Pinocchio.

If you want to make a target intermediate, mark it as dependency of the special target .INTERMEDIATE:.

The other way to get intermediate files was a huge gotcha to me. It’s with patterns. If you don’t reference the dependency in the Makefile explicitly, it will end up being intermediate. i.e. package_%.zip will be intermediate. If you add a target or dependency on package_linux.zip, then it won’t. This behaviour is unintuitive.

Argh, I Have Pattern Rules and Don’t Want Them To Be Intermediate

You can explicitly add targets for them. That would make them real, regular targets.

But sometimes that’s not feasible, we might be talking about dozens of patterns here. Mark them as dependency of another special target, .PRECIOUS. This tells make to not perform clean up.

NOTE: .PRECIOUS is the only special target that understands patterns. Literally the only special target. This is a gotcha of a gotcha.

How Are Intermediate Dependencies Handled During Error

If a build is canceled or errors out, what happens? make doesn’t have a great user story for handling intermediate files. It feels like they just slapped on hacks and features until they got something sort of working.

If a build is interrupted, make will clean up intermediate files. Remember, this follows the invariant. But this is annoying because sometimes a recipe fails because the intermediate dependency was incorrectly built.

It makes sense why make wants to clean up files as the default behaviour, even though it would be helpful for post-mortem debugging. make depends on the file modification timestamp. A file created during an partial, incomplete build would break this mechanism. Because it’s timestamp is newer than dependency, it would not cause rebuilding as appropriate.

.SECONDARY

So what if you have an intermediate file that you want to keep after the build but is meaningless as an intermediate file. That is, you only want to keep the intermediate file of successful builds.

.PRECIOUS will blindly keep all intermediate files, even half-built ones. .INTERMEDIATE will remove all intermediate files.

The answer comes to us in the form of .SECONDARY special target. This probably does what you want, all the time:

  • If the build is killed (error or ctrl-c), then treat as intermediate file, which means automatic cleanup
  • If the build succeeds, treat as precious and keep the artifact.

Updated: