6.2.7 Directory Outputs and Tree Artifacts
recommendedA directory output can contain few bytes and still be expensive to reuse. A remote system must represent its shape, discover or retrieve its children, and make the files available where they are consumed. Ten thousand tiny files and one file with the same total byte count therefore exercise very different work.
4.2.2 Actions explains how a rule declares a directory output as a tree artifact. Here the tree is already correct; the question is whether its shape makes remote reuse economical.
A directory is structure plus content
For remote operation, reason about a tree along at least two dimensions:
- Content volume: total bytes that may be hashed, uploaded, downloaded, or materialized.
- Structural volume: file and directory count, depth, fan-out, metadata, traversal, requests, and filesystem operations.
REAPI can publish the same output-directory shape in two representations. In
the protocol vendored by Bazel 9.1.0, Command.output_directory_format requests
one of three forms:1
TREE_ONLYusesOutputDirectory.tree_digest, which addresses one encodedTreeblob in CAS. TheTreebundles its root and childDirectorymessages; files named by those messages remain separate content blobs.DIRECTORY_ONLYusesOutputDirectory.root_directory_digest, which addresses the root of a Merkle hierarchy of separately storedDirectoryobjects. Their file contents are also separate CAS blobs.TREE_AND_DIRECTORYpublishes both forms. The root-directory digest must match the root contained in theTree.
The worker may return a superset of the requested forms, and the protocol says
an unsupported format should fall back to TREE_ONLY. A client reusing an
output therefore follows the fields that are actually present in the
ActionResult and obtains the required directory metadata and file blobs under
its output policy.1
When that generated directory later becomes an input to another action, the
downstream Action.input_root_digest addresses a Directory root for the whole
input tree. If the output already has a root_directory_digest, its separately
stored Directory objects may be reused directly as a subtree of that input
Merkle hierarchy. With only tree_digest, the required individually addressed
Directory representation must first be made available; file blobs and
directory objects already present in CAS can be reused where their digests
match.1
GetTree accepts a root Directory digest and recursively returns the
Directory nodes below it. That root may be an output's
root_directory_digest when present, or an action's input_root_digest; it is
never an OutputDirectory.tree_digest. A service or worker can therefore use
GetTree to expand the Directory representation, while a consumer of the
compact output form reads the encoded Tree representation instead.1
Neither representation turns a directory into one ordinary file blob. The producer, service, and consumer still handle enough metadata to reconstruct or traverse its hierarchy. Depending on the execution and output mode, the dominant work may occur while enumerating the tree, checking for present objects, transferring metadata or file blobs, staging an executor filesystem, downloading outputs, or opening many files locally.
This is why “the output is only 40 MB” is not a performance diagnosis. A wide tree of small files can spend little time transferring payload bytes yet spend substantial time in metadata operations and per-file materialization. A deeply nested tree can make traversal visible even when most content is already present. A single large blob reverses that balance.
The JavaScript ecosystem supplies a concrete warning. rules_js documents a
remote-cache symptom in which npm extraction produces a high-file-count
directory output: extraction itself can be cheap while uploading and fetching
that output is uneconomical.2 The lesson generalizes to generated source
trees, expanded archives, Python environments, and other outputs containing
many small files; the decision still requires measurement for the actual tree
and consumers.
Locate the expensive stage
Apply the cost decomposition from 6.2.5 When a Cache Hit Is Slower, but add structural measurements. Compare matched runs using the same revision, action identity, platform, cache state, output mode, and consumer workload.
Record at least:
| Evidence | What it distinguishes |
|---|---|
| Total bytes and file/directory counts | Payload volume from structural volume |
| Tree depth and fan-out distribution | Flat high-cardinality trees from deep traversal |
| Cache lookup and object-presence requests | Identity lookup from child-object discovery |
| Directory enumeration time | Metadata traversal from content transfer |
| Upload/download bytes and request counts | Large payloads from many small operations |
| Local materialization time and filesystem operations | Network reuse from the cost of creating the local tree |
| Time to first useful consumer access | Eager materialization from demand-driven access |
| Uncached action duration | Reuse cost from recomputation cost |
Do not compare only the producer's action duration. A cache hit avoids execution but may move the cost into lookup, traversal, transfer, or the first downstream consumer. Measure through the boundary that matters to the journey: for example, until the next compile can read the tree, not merely until an action-result lookup succeeds.
Use a small matrix instead of one “warm versus cold” number:
- Produce the tree without remote reuse and capture its execution time, bytes, count, depth, and fan-out.
- Reuse it on a fresh matched client and measure lookup, tree/object requests, transfer, materialization, and end-to-end consumer latency.
- Repeat with a shape-controlled tree of similar bytes but fewer files, or similar file count but different bytes. This reveals which dimension matters.
- Repeat with a small change in one branch. Observe whether unchanged content avoids transfer and whether enumeration or materialization still dominates.
Report distributions by tree class rather than one average. One generated tree on the critical path can matter more than thousands of cheap scalar outputs.
Choose the intervention at the measured boundary
If payload transfer dominates, use the ordinary cache-hit economic decisions from 6.2.5 When a Cache Hit Is Slower. If traversal or request fan-out dominates, improve the proven directory-enumeration path in the chosen backend or reduce needless tree cardinality at the producer. If local materialization dominates, evaluate an output-handling mode that avoids eagerly creating files the journey will not read. If recomputation is cheaper than the complete reuse path, do not assume that caching this action class is beneficial merely because it can hit.
Preserve semantic boundaries when changing the output shape. Packing a tree into an archive or filesystem image replaces many remote objects with one, but it also changes how downstream actions access files, how small changes invalidate content, what tools and mounts are required, and which platforms can consume the result. It is a design change, not a transparent cache tuning knob.
Two implementation patterns are often discussed for very large input trees:
- A backend may cache a flattened traversal or parallelize directory walking.
A product-specific “TreeCache” is an implementation optimization, not part of
the portable Remote Execution API contract. Stripe reported that its own
cache plus parallel-walk implementation reduced a large input-tree
GetTreecase from more than 10 seconds to roughly 500 milliseconds; that is a workload and backend result, not a general expectation.3 - A deployment may package a stable directory as a SquashFS image and arrange for executors to mount it. SquashFS, mount properties, OverlayFS integration, and executor support are deployment-specific; Bazel does not automatically replace tree artifacts with filesystem images. Stripe's case converted a 108,000-file dependency directory into one image and reported lower memory and fewer hard-link operations, but required custom executor mounting behavior.4
Adopt either pattern only after a backend- and platform-specific benchmark shows that it removes the measured bottleneck without breaking action identity, hermeticity, output accessibility, or recovery. Otherwise prefer the simpler portable representation and focus on the producer or consumer whose file shape creates the cost.
A directory output costs more than its payload bytes. File count, depth, fan-out, metadata traversal, request count, executor staging, and local materialization can dominate a remote hit. Measure the whole producer-to-consumer path with matched cohorts and shape-controlled comparisons before changing cache policy or output representation.
Input-tree traversal caches and SquashFS mounts can be valid implementation-specific responses, but neither is a portable property of Bazel tree artifacts or REAPI. Use them only when backend-specific evidence identifies the boundary they fix and the new portability and invalidation trade-offs are acceptable.
Check your understanding · 2 questions
1.Match each directory-output measure to the cost it helps distinguish:
Drag each answer onto the matching prompt, or click an answer and then click a prompt
2.When should a team adopt a tree traversal cache or SquashFS-style delivery?
Select one answer
Footnotes
-
Remote APIs — protocol contracts for caching and remote execution — Bazel 9.1.0's
Command.OutputDirectoryFormat,OutputDirectory.tree_digestandroot_directory_digest, encodedTreeand individualDirectoryforms,Action.input_root_digest, and theGetTreerequest boundary ↩1 ↩2 ↩3 ↩4 -
rules_js — pnpm-based JavaScript rules and diagnostics — remote-cache guidance for high-file-count npm extraction outputs ↩
-
Fast Builds, Secure Builds. Choose Two. —
GetTreetraversal cost and Stripe's bespoke TreeCache and parallel-walk result ↩ -
Remote execution, the DIY edition — Stripe's SquashFS/OverlayFS implementation case and measured high-cardinality directory results ↩