package
3.3.0+incompatible
Repository: https://github.com/google/agi.git
Documentation: pkg.go.dev

# README

Dependency graph

The dependency graph tracks dependencies between the commands of a capture.

This doc is an overview of the dependency graph internals. As the code evolves, the few code snippets used here may not match the latest version, but should still help to understand the high-level approach.

In general, Command B depends on command A if command A has some side-effect to the API state or some resource (image, buffer...) memory that impacts command B.

To take a small Vulkan example:

vkCmdSetScissor(...);  // Update the scissor value in Vulkan state
vkCmdDraw(...);        // Draw is affected by the scissor value

Here, vkCmdSetScissor() updates the scissor value of the associated graphics pipeline in the Vulkan API state, then vkCmdDraw() performs a draw that is impacted by this scissor value. Hence, vkCmdDraw() depends on vkCmdSetScissor().

The dependency graph establishes dependencies between the commands of a trace by tracking each command's accesses to memory and the API state. It was introduced to help with dead-code elimination (i.e. removing irrelevant commands from a capture for a given replay), but it might be relevant to other use-cases.

A dependency graph example

The dependency graph is meant to be used internally and not directly presented to the end user. Still, in order to understand it, it is good to see what it looks like some simple examples.

You can view the dependency graph of a given capture using the create_graph_visualization gapit verb, e.g.:

./gapit create_graph_visualization -format dot -out depgraph.dot com.example.gfxtrace

Warning: the dependency graph gets quickly fairly big on traces of real games, you might not be able to easily explore them using a DOT viewer.

The dependency graph of a trace of one frame of Sascha Willems triangle demo looks like this (as of October 2020):

Dependency graph of a simple Vulkan triangle demo

We can see that vkQueuePresent has a dependency edge pointing to vkCmdEndRenderPass, which reflects the fact that the render pass must be executed before the frame is presented. Now, vkCmdEndRenderPass itself transitively depends on all commands of the command buffer that was submitted, eventually leading to vkQueueSubmit. Also, vkCmdDrawIndexed depends on the several commands that performs all the required setup (vkCmdBindPipeline, vkCmdBindIndexBuffer, etc), but these setup commands do not depend on each other, as they can be performed in any order.

Structure of the dependency graph

The DependencyGraph interface defines possible operations on a dependency graph, and it is implemented by the dependencyGraph type.

The graph nodes are defined by the Node interface, which is implemented by two kinds of nodes: CmdNode represents an API command or subcommand, and ObsNode represents a memory observation. The graph also stores dependencies between the nodes: these dependencies are untyped, they are just represented by an ordered pair of node IDs, such that there is a source node and a target node.

For all nodes, the graph stores the node accesses to API state and memory in a NodeAccesses type, which is retrievable via GetNodeAccesses(). Note that these accesses are saved only if the SaveNodeAccesses field of the DependencyGraphConfig object used at graph creation time is set to true. There are several kinds of node accesses, the main two are FragmentAccess which represent an access to a part (a "fragment") of the API state, and MemoryAccess which represents an access to some memory.

Creation of the dependency graph

The dependency graph is created by mutating all the capture commands and monitoring their accesses to API state and memory.

Approach overview

Let's give a high-level, conceptual illustration of this process: consider we have a memory made of two slots, each slot being an addressable part of the memory. When building the dependency graph, for each slot we remember the ID of the node that was the latest to write to this memory. Say during mutation we are at a point where the latest node to write to slot 1 was node 11, and the latest to write to slot 2 was 22:

slot1: 11
slot2: 22

Now, we mutate the next command whose CmdNode ID is 33. This command reads from slot1, and read-writes to slot2. Since it reads from both slots, it depends on both node 11 and 22. As it writes to slot2, at the end of the command mutation, the memory accesses tracker is updated to:

slot1: 11
slot2: 33

This makes sure that the next command to read from slot2 will depend on 33.

Now, a single command may have multiple read/write to the same slot: we should not update the memory access tracker as soon as there is a write, otherwise a subsequent read by the same command would create a circular dependency to the node itself. To avoid this, all read/writes of a single command are temporarily stored in a list of pending accesses, which are flushed (see the various Flush() functions) when the command ends.

In practice

In practice, in BuildDependencyGraph(), a dependencyGraphBuilder is created and passed as the api.StateWatcher to each command's Mutate(). The api.StateWatcher interface lists callbacks that are invoked during mutation, including when a command or subcommand starts or ends, and whenever it does an API state access (OnReadFrag()/OnWriteFrag()) or a memory access (OnReadSlice()/OnWriteSlice() and OnReadObs()/OnWriteObs()).

The dependencyGraphBuilder builder object contains itself some dedicated sub-watchers like a FragWatcher and a MemWatcher, which focus on API state and memory, respectively. These watcher accumulate pending accesses of a command and its potential subcommands (so there can be several nodes pending), and dependencies are established when a top-level command ends: OnEndCmd() calls AddDependencies() that calls AddNodeDependencies() on each pending node.

The tracking of API state fragment and memory accesses is done in an API-agnostic way, there is no direct correlation with e.g. Vulkan state or objects. This tracking is also more complex than the simplified memory slots used to illustrate the approach. Basically, a memory access is defined by an interval.U64Span within a given memory.PoolID, and an API state fragment access is defined by some api.RefID and api.Fragment. In both cases, the high-level approach presented above applies.

Forward dependencies

Forward dependencies are a special kind of dependency that applies to API commands that come in pairs. For instance, vkBeginCommandBuffer() and vkEndCommandBuffer() come in pairs: if you have one, you should have the other.

In practice, alongside the FragWatcher and the MemWatcher, the dependency graph buidler also has a ForwardWatcher which handles forward dependencies via three callbacks: OpenForwardDependency(), CloseForwardDependency() and DropForwardDependency().

Most forward dependencies are only concerned with open and close operations. For instance, the mutation of vkBeginCommandBuffer() triggers OpenForwardDependency() that creates an open forward dependency, and vkEndCommandBuffer() triggers CloseForwardDependency() that closes it, marking the dependency between these two commands. Open and close are matched on a dependencyID that, as empty interface, could be anything. In the case of vkBegin/EndCommandBuffer, the command buffer Vulkan handle is used as a dependencyID. Other kinds of command pairs are the vkCreate*() and vkDestroy*() ones.

DropForwardDependency() is handy for Vulkan fences: the signal and wait operations of a fence usually come in pairs, but a vkResetFences() can also unsignal fences. In this case, mutation of vkResetFences() triggers a DropForwardDependency() on all relevant fences, to mark that the fence resetting disallows a signal/wait dependency, without depending itself on previous signal operations.

# Packages

# Functions

DCECapture returns a new capture containing only the requested commands and their dependencies.
NewDCEBuilder creates a new DCEBuiler using the specified dependency graph.

# Constants

DEP: read/write relevant for dependencies.
PLAIN: just plain read/write accesses.
Combined values.

# Structs

CmdNode is a dependency node corresponding to an API call.
DCEBuilder tracks the data necessary to perform dead-command-eliminition on a capture.
Information about what sort of data to store in a dependency graph.
NodeIDSorter is a structure to use for sorting NodeIDs in the sort package.
ObsNode is a dependency node corresponding to a memory observation.

# Interfaces

DependencyGraph stores the dependencies among api calls and memory observations,.
Node represents a node in the dependency graph, and holds data about the associated command or memory observation.

# Type aliases

AccessMode is a bitfield that records read/write accesses.
NodeID identifies a node in a dependency graph.