Unity’s C# job system is a powerful tool, but it can be difficult to understand how various jobs, their dependencies on each other, and the data they use all work together to accomplish a task. Today we’ll create a little tool that visualizes and generates job graphs so it’s much easier to understand them and easier to build larger, more powerful graphs.

Planning

Today’s primary goal is to be able to visualize job graphs so they’re more easily understood than a series of lines of C# text. We’ll do this by adopting the DOT language. DOT is designed to describe a graph and is consumed by programs like Graphviz that render the graph. Graphviz is a free, open source program available on all major operating systems so it should be easy to integrate into any workflow.

Here’s what our DOT file description of a Unity job graph will look like:

digraph "MyApp.Jobs::GeneratedJobGraph.Schedule"
{
    // Inputs
    i1 [ comment="NativeArray<float>", shape="cylinder" ]
    i2 [ comment="NativeArray<int>", shape="cylinder" ]
    i3 [ comment="NativeArray<float>", shape="cylinder" ]
    i4 [ comment="NativeArray<int>", shape="cylinder" ]
    i5 [ comment="NativeArray<float>", shape="cylinder" ]
 
    // Jobs
    a1 [ comment="JobTypes.JobA", shape="ellipse" ]
    i1 -> a1 [ label="Input1" ]
    a2 [ comment="JobTypes.JobA", shape="ellipse" ]
    i2 -> a2 [ label="Input2" ]
    b [ comment="JobTypes.JobB", shape="ellipse" ]
    i3 -> b [ label="Input3" ]
    c [ comment="JobTypes.GenericJob<int>", shape="ellipse" ]
    i4 -> c [ label="Input4" ]
    d [ comment="JobTypes.JobD", shape="ellipse" ]
    i5 -> d [ label="Input5" ]
 
    // Flow
    a1 -> b
    a2 -> b
    b -> c
    b -> d
}

Here’s how it looks rendered by Graphviz:

Unity job graph rendered by GraphViz

Each cylinder in the graph represents input data and each ellipse represents a job. The name on the cylinder is the name of the parameter to pass into the job graph-scheduling method. The name on the ellipse is the name of the job. The name on the arrows between input data and jobs is the name of the field to set on the job.

To break down the DOT file further, here are its individual parts:

  • Header: digraph "Namespace1.Namespace2::Class.Method"
  • Inputs: ParamName [comment = "ParamType", shape = "cylinder" ]
  • Jobs: JobName [comment = "JobType", shape = "ellipse" ]
  • Job Inputs: ParamName -> JobName [ label="FieldName" ]
  • Dependencies: DependencyJobName -> DependentJobName

Once we have the job graph written in DOT and visualized by Graphviz, we then need to get it into C# code for Unity to compile and execute. We'll do this by writing a tool whose input is DOT and whose output is C#. The C# can then be put into the Unity project for compilation.

Since there are two files describing the same behavior, we need to keep them in sync. This means we need to make sure that it's quick and easy to generate the C# source for a DOT graph. To this end, we'll write the code generator in pure C# so that it can be run either inside Unity as an editor script or outside of Unity as a command-line program.

Implementation

The implementation comes in three main parts: parsing, graph building, and code generation. Parsing is mostly a matter of designing and executing the appropriate regular expressions to make sense of the DOT file. The output of parsing is a series of data structures describing the header, nodes, dependencies, and so forth.

Graph building is a matter of establishing in-memory links between the jobs and their inputs. The graph may have multiple roots and multiple leaves and each node may have multiple parents and multiple children. A breadth-first traversal is necessary to ensure that dependencies are executed before the jobs that depend on them.

Finally, the graph must be converted to C# for output. This involves a lot of string building to form the source code, but is relatively straightforward since the graph has already been set up. Two methods are output: one where the root nodes have no dependency and one where they have a user-supplied dependency JobHandle. The dependency version is implemented by adding a "fake" node to the graph to represent the user-supplied job.

One last detail is the inclusion of a Main function. This allows the generator to be run outside of Unity as a command-line app. On my machine, it runs in about 130 milliseconds with the above script, so it's quick and easy to fit into any workflow or build process.

Here's the source code for the generator:

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
 
/// <summary>
/// Utilities to generate C# source code representing a Unity job graph
/// </summary>
/// 
/// <author>
/// Jackson Dunstan, https://JacksonDunstan.com/articles/5045
/// </author>
/// 
/// <license>
/// MIT
/// </license>
public static class JobGraphGenerator
{
    /// <summary>
    /// Types of parse matches
    /// </summary>
    private enum MatchType
    {
        /// <summary>
        /// No match found
        /// </summary>
        NotFound,
 
        /// <summary>
        /// A match was found but skipped
        /// </summary>
        Skip,
 
        /// <summary>
        /// A valid match was found
        /// </summary>
        Found
    }
 
    /// <summary>
    /// Header of the DOT file
    /// </summary>
    private struct Header
    {
        /// <summary>
        /// Namespace the generated class should be in
        /// </summary>
        public readonly string Namespace;
 
        /// <summary>
        /// Name of the generated class
        /// </summary>
        public readonly string Class;
 
        /// <summary>
        /// Method to generate within the class
        /// </summary>
        public readonly string Method;
 
        /// <summary>
        /// Create the header
        /// </summary>
        /// 
        /// <param name="namespaceName">
        /// Namespace the generated class should be in
        /// </param>
        /// 
        /// <param name="className">
        /// Name of the generated class
        /// </param>
        /// 
        /// <param name="methodName">
        /// Method to generate within the class
        /// </param>
        public Header(
            string namespaceName,
            string className,
            string methodName)
        {
            Namespace = namespaceName;
            Class = className;
            Method = methodName;
        }
    }
 
    /// <summary>
    /// A node of a graph. Used for both jobs and parameters.
    /// </summary>
    private class Node
    {
        /// <summary>
        /// Name of the variable to generate
        /// </summary>
        public readonly string Name;
 
        /// <summary>
        /// Type of the variable to generate
        /// </summary>
        public readonly string Type;
 
        /// <summary>
        /// For job nodes, the job handle
        /// </summary>
        public string JobHandle;
 
        /// <summary>
        /// For job nodes, the node's dependencies or null if none
        /// </summary>
        public List<Node> Parents;
 
        /// <summary>
        /// For job nodes, the nodes with this node as a dependency or null if
        /// there are no such nodes
        /// </summary>
        public List<Node> Children;
 
        /// <summary>
        /// For job nodes, a flag to indicate whether this node has been visited
        /// during traversal of the graph it's in. Cleared after traversal.
        /// </summary>
        public bool Visited;
 
        /// <summary>
        /// Create the node
        /// </summary>
        /// 
        /// <param name="name">
        /// Name of the variable to generate
        /// </param>
        /// 
        /// <param name="type">
        /// Type of the variable to generate
        /// </param>
        public Node(string name, string type)
        {
            Name = name;
            Type = type;
            JobHandle = null;
            Parents = null;
            Children = null;
            Visited = false;
        }
    }
 
    /// <summary>
    /// Input to a job (i.e. one of its fields)
    /// </summary>
    private struct JobInput
    {
        /// <summary>
        /// Name of the parameter to set to the job's field
        /// </summary>
        public readonly string Param;
 
        /// <summary>
        /// Name of the job variable
        /// </summary>
        public readonly string Job;
 
        /// <summary>
        /// Name of the field to set to the parameter
        /// </summary>
        public readonly string Field;
 
        /// <summary>
        /// Create the job input
        /// </summary>
        /// 
        /// <param name="param">
        /// Name of the parameter to set to the job's field
        /// </param>
        /// 
        /// <param name="job">
        /// Name of the job variable
        /// </param>
        /// 
        /// <param name="field">
        /// Name of the field to set to the parameter
        /// </param>
        public JobInput(string param, string job, string field)
        {
            Param = param;
            Job = job;
            Field = field;
        }
    }
 
    /// <summary>
    /// A connection between two nodes, which establishes a dependency
    /// </summary>
    private struct Connection
    {
        /// <summary>
        /// Variable name of the dependency
        /// </summary>
        public readonly string From;
 
        /// <summary>
        /// Variable name of the node that depends on the dependency
        /// </summary>
        public readonly string To;
 
        /// <summary>
        /// Make the connection
        /// </summary>
        /// 
        /// <param name="from">
        /// Variable name of the dependency
        /// </param>
        /// 
        /// <param name="to">
        /// Variable name of the node that depends on the dependency
        /// </param>
        public Connection(string from, string to)
        {
            From = from;
            To = to;
        }
    }
 
    /// <summary>
    /// A parsed value and information about its parsing
    /// </summary>
    private struct Parsed<T>
    {
        /// <summary>
        /// The parsed value or default(T) if not found
        /// </summary>
        public readonly T Value;
 
        /// <summary>
        /// Type of parsing match
        /// </summary>
        public readonly MatchType MatchType;
 
        /// <summary>
        /// Index after the parsed string
        /// </summary>
        public readonly int EndIndex;
 
        /// <summary>
        /// Create the parsed value
        /// </summary>
        /// 
        /// <param name="value">
        /// The parsed value or default(T) if not found
        /// </param>
        /// 
        /// <param name="matchType">
        /// Type of parsing match
        /// </param>
        /// 
        /// <param name="endIndex">
        /// Index after the parsed string
        /// </param>
        public Parsed(T value, MatchType matchType, int endIndex)
        {
            Value = value;
            MatchType = matchType;
            EndIndex = endIndex;
        }
    }
 
    /// <summary>
    /// Regular expression for the header of the DOT file. Example:
    ///   digraph "MyApp.Jobs::GeneratedJobGraph.Schedule"
    /// Captures the namespace, type, and method names
    /// </summary>
    private static readonly Regex HeaderRegex = new Regex(
        "digraph\\s+\"?(?:([a-zA-Z0-9]+)\\.?(?:::)?)+\"?");
 
    /// <summary>
    /// Regular expression for a node (job or parameter) in the DOT file.
    /// 
    /// Example parameter:
    ///   i1 [ comment="NativeArray&lt;float&gt;", shape="cylinder" ]
    /// Captures the parameter name, type/comment, and shape
    /// 
    /// Example job:
    ///   a1 [ comment="JobTypes.JobA", shape="ellipse" ]
    /// Captures the node name, type/comment, and shape
    /// 
    /// Note that shape distinguishes between parameters and jobs
    /// </summary>
    private static readonly Regex NodeRegex = new Regex(
        "([a-zA-Z0-9_]+)\\s*\\[\\s*(?:([a-zA-Z0-9_<>\\.:]+)\\s*=\\s*\"([a-zA-Z0-9_<>\\.:]+)\"(?:, )?)+\\s*\\]");
 
    /// <summary>
    /// Regular expression for an input to a job in a DOT file. Example:
    ///   i1 -> a1 [ label="Input1" ]
    /// Captures the parameter, job variable name, and job field
    /// </summary>
    private static readonly Regex JobInputRegex = new Regex(
        "([a-zA-Z0-9_]+)\\s*->\\s*([a-zA-Z0-9_]+)\\s*\\[\\s*label\\s*=\\s*\"([a-zA-Z0-9]+)\"\\s*\\]");
 
    /// <summary>
    /// Regular expression for a connection between jobs in a DOT file. Example:
    ///   a1 -> b
    /// Captures the variable names of the dependency job and the job that
    /// depends on it.
    /// </summary>
    private static readonly Regex ConnectionRegex = new Regex(
        "([a-zA-Z0-9_]+)\\s*\\-\\>\\s*([a-zA-Z0-9_]+)\\s*$",
        RegexOptions.Multiline);
 
    /// <summary>
    /// One level of indendation in the output C# file
    /// </summary>
    private const string Indent = "    ";
 
    /// <summary>
    /// The shape capture of a parameter node
    /// </summary>
    private const string ParamShape = "cylinder";
 
    /// <summary>
    /// The shape capture of a job node
    /// </summary>
    private const string JobShape = "ellipse";
 
    /// <summary>
    /// Parse the header of the DOT file
    /// </summary>
    /// 
    /// <returns>
    /// The parsed header
    /// </returns>
    /// 
    /// <param name="dot">
    /// The DOT file to parse
    /// </param>
    private static Parsed<Header> ParseHeader(string dot)
    {
        Match match = HeaderRegex.Match(dot);
 
        // Concatenate the namespace
        StringBuilder namespaceBuilder = new StringBuilder();
        CaptureCollection headerCaptures = match.Groups[1].Captures;
        for (int i = 0; i < headerCaptures.Count - 2; ++i)
        {
            namespaceBuilder.Append(headerCaptures[i]);
            if (i != headerCaptures.Count - 3)
            {
                namespaceBuilder.Append('.');
            }
        }
 
        return new Parsed<Header>(
            new Header(
                namespaceBuilder.ToString(),
                headerCaptures[headerCaptures.Count - 2].ToString(),
                headerCaptures[headerCaptures.Count - 1].ToString()),
            MatchType.Found,
            match.Index + match.Length);
    }
 
    /// <summary>
    /// Parse the next node (parameter or job) of a DOT file
    /// </summary>
    /// 
    /// <returns>
    /// The parsed node
    /// </returns>
    /// 
    /// <param name="dot">
    /// DOT file to parse
    /// </param>
    /// 
    /// <param name="startIndex">
    /// Index into the DOT file to start parsing at
    /// </param>
    /// 
    /// <param name="shape">
    /// Shape to match. Distinguishes between matching parameters and jobs.
    /// </param>
    private static Parsed<Node> ParseNode(
        string dot,
        int startIndex,
        string shape)
    {
        Match match = NodeRegex.Match(dot, startIndex);
        if (!match.Success)
        {
            return new Parsed<Node>(
                default(Node),
                MatchType.NotFound,
                dot.Length);
        }
 
        string name = match.Groups[1].ToString();
        CaptureCollection keyCaptures = match.Groups[2].Captures;
        CaptureCollection valueCaptures = match.Groups[3].Captures;
 
        string type = null;
        bool isCorrectShape = false;
        for (int i = 0; i < keyCaptures.Count; ++i)
        {
            string key = keyCaptures[i].ToString();
            string value = valueCaptures[i].ToString();
            if (key == "shape")
            {
                isCorrectShape = value == shape;
            }
            if (key == "comment")
            {
                type = value;
            }
        }
 
        if (!isCorrectShape)
        {
            return new Parsed<Node>(
                default(Node),
                MatchType.Skip,
                match.Index + match.Length);
        }
 
        return new Parsed<Node>(
            new Node(name, type),
            MatchType.Found,
            match.Index + match.Length);
    }
 
    /// <summary>
    /// Parse all the nodes (parameter or job) of a DOT file
    /// </summary>
    /// 
    /// <returns>
    /// The parsed nodes
    /// </returns>
    /// 
    /// <param name="dot">
    /// DOT file to parse
    /// </param>
    /// 
    /// <param name="startIndex">
    /// Index into the DOT file to start parsing at
    /// </param>
    /// 
    /// <param name="shape">
    /// Shape to match. Distinguishes between matching parameters and jobs.
    /// </param>
    private static List<Node> ParseNodes(
        string dot,
        int startIndex,
        string shape)
    {
        return ParseMultiple(
            dot,
            startIndex,
            (d, si) => ParseNode(d, si, shape));
    }
 
    /// <summary>
    /// Parse the next job input of a DOT file
    /// </summary>
    /// 
    /// <returns>
    /// The parsed job input
    /// </returns>
    /// 
    /// <param name="dot">
    /// DOT file to parse
    /// </param>
    /// 
    /// <param name="startIndex">
    /// Index into the DOT file to start parsing at
    /// </param>
    private static Parsed<JobInput> ParseJobInput(
        string dot,
        int startIndex)
    {
        Match match = JobInputRegex.Match(dot, startIndex);
        if (!match.Success)
        {
            return new Parsed<JobInput>(
                default(JobInput),
                MatchType.NotFound,
                dot.Length);
        }
 
        return new Parsed<JobInput>(
            new JobInput(
                match.Groups[1].ToString(),
                match.Groups[2].ToString(),
                match.Groups[3].ToString()),
            MatchType.Found,
            match.Index + match.Length);
    }
 
    /// <summary>
    /// Parse all the job inputs of a DOT file
    /// </summary>
    /// 
    /// <returns>
    /// The parsed job inputs
    /// </returns>
    /// 
    /// <param name="dot">
    /// DOT file to parse
    /// </param>
    /// 
    /// <param name="startIndex">
    /// Index into the DOT file to start parsing at
    /// </param>
    private static List<JobInput> ParseJobInputs(string dot, int startIndex)
    {
        return ParseMultiple(dot, startIndex, ParseJobInput);
    }
 
    /// <summary>
    /// Parse the next connection of a DOT file
    /// </summary>
    /// 
    /// <returns>
    /// The parsed connection
    /// </returns>
    /// 
    /// <param name="dot">
    /// DOT file to parse
    /// </param>
    /// 
    /// <param name="startIndex">
    /// Index into the DOT file to start parsing at
    /// </param>
    private static Parsed<Connection> ParseConnection(
        string dot,
        int startIndex)
    {
        Match match = ConnectionRegex.Match(dot, startIndex);
        if (!match.Success)
        {
            return new Parsed<Connection>(
                default(Connection),
                MatchType.NotFound,
                dot.Length);
        }
 
        return new Parsed<Connection>(
            new Connection(
                match.Groups[1].ToString(),
                match.Groups[2].ToString()),
            MatchType.Found,
            match.Index + match.Length);
    }
 
    /// <summary>
    /// Parse all the connections of a DOT file
    /// </summary>
    /// 
    /// <returns>
    /// The parsed connections
    /// </returns>
    /// 
    /// <param name="dot">
    /// DOT file to parse
    /// </param>
    /// 
    /// <param name="startIndex">
    /// Index into the DOT file to start parsing at
    /// </param>
    private static List<Connection> ParseConnections(string dot, int startIndex)
    {
        return ParseMultiple(dot, startIndex, ParseConnection);
    }
 
    /// <summary>
    /// Parse multiple items until none are found
    /// </summary>
    /// 
    /// <returns>
    /// The parsed items
    /// </returns>
    /// 
    /// <param name="dot">
    /// DOT file to parse
    /// </param>
    /// 
    /// <param name="startIndex">
    /// Index into the DOT file to start parsing at
    /// </param>
    /// 
    /// <param name="parseSingle">
    /// Delegate to parse the next single item
    /// </param>
    /// 
    /// <typeparam name="T">
    /// Type of items to parse
    /// </typeparam>
    private static List<T> ParseMultiple<T>(
        string dot,
        int startIndex,
        Func<string, int, Parsed<T>> parseSingle)
    {
        List<T> found = new List<T>(16);
        while (true)
        {
            Parsed<T> parsed = parseSingle(dot, startIndex);
            startIndex = parsed.EndIndex;
            switch (parsed.MatchType)
            {
                case MatchType.NotFound:
                    return found;
                case MatchType.Found:
                    found.Add(parsed.Value);
                    break;
            }
        }
    }
 
    /// <summary>
    /// Find a node by name
    /// </summary>
    /// 
    /// <returns>
    /// The found node or null if not found
    /// </returns>
    /// 
    /// <param name="nodes">
    /// Nodes to search
    /// </param>
    /// 
    /// <param name="name">
    /// Name of the node to find
    /// </param>
    private static Node FindNode(List<Node> nodes, string name)
    {
        foreach (Node node in nodes)
        {
            if (node.Name == name)
            {
                return node;
            }
        }
        return null;
    }
 
    /// <summary>
    /// Traverse the job graph breadth-first
    /// </summary>
    /// 
    /// <param name="jobs">
    /// The jobs of the graph
    /// </param>
    /// 
    /// <param name="handler">
    /// Delegate to invoke for each traversed job
    /// </param>
    private static void TraverseGraph(List<Node> jobs, Action<Node> handler)
    {
        // Add all real roots and nodes whose parent is fake
        Queue<Node> queue = new Queue<Node>();
        foreach (Node node in jobs)
        {
            if (node.Name == null)
            {
                continue;
            }
            if (node.Parents == null || node.Parents[0].Name == null)
            {
                queue.Enqueue(node);
            }
        }
 
        // Traverse
        while (queue.Count != 0)
        {
            Node cur = queue.Dequeue();
            if (!cur.Visited)
            {
                handler(cur);
                if (cur.Children != null)
                {
                    foreach (Node child in cur.Children)
                    {
                        queue.Enqueue(child);
                    }
                }
                cur.Visited = true;
            }
        }
 
        // Reset visited flags
        foreach (Node node in jobs)
        {
            node.Visited = false;
        }
    }
 
    /// <summary>
    /// Output the dependencies of a job
    /// </summary>
    /// 
    /// <param name="dependencies">
    /// Dependencies to output. The result is a C# expression.
    /// </param>
    /// 
    /// <param name="output">
    /// StringBuilder to output to
    /// </param>
    private static void OutputDependencies(
        List<Node> dependencies,
        StringBuilder output)
    {
        // No dependencies, no output
        if (dependencies == null)
        {
            return;
        }
 
        // One dependency is trivial
        if (dependencies.Count == 1)
        {
            output.Append(dependencies[0].JobHandle);
        }
        // Multiple dependencies need to be combined
        else if (dependencies.Count > 0)
        {
            output.Append("JobHandle.CombineDependencies(");
            for (int i = 0; i < dependencies.Count; ++i)
            {
                Node parent = dependencies[i];
                output.Append(parent.JobHandle);
                if (i != dependencies.Count - 1)
                {
                    output.Append(", ");
                }
            }
            output.Append(')');
        }
    }
 
    /// <summary>
    /// Output a method
    /// </summary>
    /// 
    /// <param name="header">
    /// Header of the DOT file
    /// </param>
    /// 
    /// <param name="parameters">
    /// Parameters to the method
    /// </param>
    /// 
    /// <param name="jobs">
    /// Jobs that make up the graph
    /// </param>
    /// 
    /// <param name="jobInputs">
    /// Inputs to the jobs
    /// </param>
    /// 
    /// <param name="output">
    /// StringBuilder to output C# to
    /// </param>
    private static void OutputMethod(
        Header header,
        List<Node> parameters,
        List<Node> jobs,
        List<JobInput> jobInputs,
        StringBuilder output)
    {
        // Begin method
        output.Append(Indent);
        output.Append(Indent);
        output.Append("public static JobHandle ");
        output.Append(header.Method);
        output.AppendLine("(");
        for (int i = 0; i < parameters.Count; ++i)
        {
            output.Append(Indent);
            output.Append(Indent);
            output.Append(Indent);
            Node param = parameters[i];
            output.Append(param.Type);
            output.Append(' ');
            output.Append(param.Name);
            if (i != parameters.Count - 1)
            {
                output.AppendLine(",");
            }
        }
        output.AppendLine(")");
        output.Append(Indent);
        output.Append(Indent);
        output.AppendLine("{");
 
        // Declare (real) jobs
        foreach (Node job in jobs)
        {
            if (job.Name != null)
            {
                output.Append(Indent);
                output.Append(Indent);
                output.Append(Indent);
                output.Append(job.Type);
                output.Append(' ');
                output.Append(job.Name);
                output.Append(" = default(");
                output.Append(job.Type);
                output.AppendLine(");");
            }
        }
        output.Append(Indent);
        output.Append(Indent);
        output.AppendLine();
 
        // Assign job inputs
        foreach (JobInput jobInput in jobInputs)
        {
            output.Append(Indent);
            output.Append(Indent);
            output.Append(Indent);
            output.Append(jobInput.Job);
            output.Append('.');
            output.Append(jobInput.Field);
            output.Append(" = ");
            output.Append(jobInput.Param);
            output.AppendLine(";");
        }
        output.Append(Indent);
        output.Append(Indent);
        output.AppendLine();
 
        // Schedule jobs
        TraverseGraph(
            jobs,
            job => {
                output.Append(Indent);
                output.Append(Indent);
                output.Append(Indent);
                output.Append("JobHandle ");
                output.Append(job.JobHandle);
                output.Append(" = ");
                output.Append(job.Name);
                output.Append(".Schedule(");
                OutputDependencies(job.Parents, output);
                output.AppendLine(");");
            });
 
        // Return handle for leaf nodes
        output.Append(Indent);
        output.Append(Indent);
        output.Append(Indent);
        output.Append("return ");
        List<Node> leafJobs = new List<Node>();
        foreach (Node job in jobs)
        {
            if (job.Children == null)
            {
                leafJobs.Add(job);
            }
        }
        OutputDependencies(leafJobs, output);
        output.AppendLine(";");
 
        // End of method
        output.Append(Indent);
        output.Append(Indent);
        output.AppendLine("}");
    }
 
    /// <summary>
    /// Generate a C# file for a DOT file that describes a job graph
    /// </summary>
    /// 
    /// <returns>
    /// The generated C# file
    /// </returns>
    /// 
    /// <param name="dot">
    /// DOT file that describes a job graph. It should look like this:
    ///   digraph "MyApp.Jobs::GeneratedJobGraph.Schedule"
    ///   {
    ///       i1[comment = "NativeArray&lt;float&gt;", shape = "cylinder"]
    ///       i2[comment = "NativeArray&lt;int&gt;", shape = "cylinder"]
    ///       i3[comment = "NativeArray&lt;float&gt;", shape = "cylinder"]
    ///       i4[comment = "NativeArray&lt;int&gt;", shape = "cylinder"]
    ///       i5[comment = "NativeArray&lt;float&gt;", shape = "cylinder"]
    ///   
    ///       a1[comment = "JobTypes.JobA", shape = "ellipse"]
    ///       i1 -> a1[label = "Input1"]
    ///       a2[comment = "JobTypes.JobA", shape = "ellipse"]
    ///       i2 -> a2[label = "Input2"]
    ///       b[comment = "JobTypes.JobB", shape = "ellipse"]
    ///       i3 -> b[label = "Input3"]
    ///       c[comment = "JobTypes.GenericJob&lt;int&gt;", shape = "ellipse"]
    ///       i4 -> c[label = "Input4"]
    ///       d[comment = "JobTypes.JobD", shape = "ellipse"]
    ///       i5 -> d[label = "Input5"]
    ///   
    ///       a1 -> b
    ///       a2 -> b
    ///       b -> c
    ///       b -> d
    ///   }
    /// 
    /// These are the specific parts that this function parses:
    ///   Header: digraph "Namespace1.Namespace2::Class.Method"
    ///   Inputs: ParamName [comment = "ParamType", shape = "cylinder" ]
    ///   Jobs: JobName [comment = "JobType", shape = "ellipse" ]
    ///   Job Inputs: ParamName -> JobName [ label="FieldName" ]
    ///   Dependencies: DependencyJobName -> DependentJobName
    /// 
    /// The elements of the graph can be in any order.
    /// Comments are ignored.
    /// The C# file includes 'using Unity.Collections' and 'Unity.Jobs' only.
    /// Attributes like 'color' are ignored.
    /// </param>
    public static string Generate(string dot)
    {
        // Parse
        Parsed<Header> parsedHeader = ParseHeader(dot);
        List<Node> parameters = ParseNodes(dot, parsedHeader.EndIndex, ParamShape);
        List<Node> jobs = ParseNodes(dot, parsedHeader.EndIndex, JobShape);
        List<JobInput> jobInputs = ParseJobInputs(dot, parsedHeader.EndIndex);
        List<Connection> connections = ParseConnections(dot, parsedHeader.EndIndex);
 
        // Build job graph
        foreach (Connection connection in connections)
        {
            Node from = FindNode(jobs, connection.From);
            Node to = FindNode(jobs, connection.To);
            if (from.Children == null)
            {
                from.Children = new List<Node>();
            }
            from.Children.Add(to);
            if (to.Parents == null)
            {
                to.Parents = new List<Node>();
            }
            to.Parents.Add(from);
        }
 
        // Assign job handles
        int nextJobHandle = 1;
        TraverseGraph(
            jobs,
            job => {
                job.JobHandle = "jobHandle" + nextJobHandle;
                nextJobHandle++;
            });
 
        // Begin file
        StringBuilder output = new StringBuilder(1024 * 16);
        output.AppendLine("///////////////////////////////////////////////////////////////////////");
        output.AppendLine("// Warning: This file was automatically generated by JobGraphGenerator");
        output.AppendLine("//          If you edit this by hand, the next run of JobGraphGenerator");
        output.AppendLine("//          will overwrite your edits. Instead, change the DOT source");
        output.AppendLine("//          for this file and run JobGraphGenerator again.");
        output.AppendLine("///////////////////////////////////////////////////////////////////////");
        output.AppendLine();
        output.AppendLine("using Unity.Collections;");
        output.AppendLine("using Unity.Jobs;");
        output.AppendLine();
 
        // Begin namespace
        output.Append("namespace ");
        output.AppendLine(parsedHeader.Value.Namespace);
        output.AppendLine("{");
 
        // Begin class
        output.Append(Indent);
        output.Append("public static class ");
        output.AppendLine(parsedHeader.Value.Class);
        output.Append(Indent);
        output.AppendLine("{");
 
        // Method with no dependency
        OutputMethod(
            parsedHeader.Value,
            parameters,
            jobs,
            jobInputs,
            output);
 
        output.Append(Indent);
        output.Append(Indent);
        output.AppendLine();
 
        // Add external dependency
        Node externalDependencyParam = new Node("dependsOn", "JobHandle");
        parameters.Insert(0, externalDependencyParam);
        Node externalDependencyJob = new Node(null, null);
        externalDependencyJob.JobHandle = externalDependencyParam.Name;
        externalDependencyJob.Children = new List<Node>();
        foreach (Node job in jobs)
        {
            if (job.Parents == null)
            {
                job.Parents = new List<Node>(1) { externalDependencyJob };
                externalDependencyJob.Children.Add(job);
            }
        }
        jobs.Insert(0, externalDependencyJob);
 
        // Method with a dependency
        OutputMethod(
            parsedHeader.Value,
            parameters,
            jobs,
            jobInputs,
            output);
 
        // End of class
        output.Append(Indent);
        output.AppendLine("}");
 
        // End of namespace
        output.AppendLine("}");
 
        return output.ToString();
    }
 
    /// <summary>
    /// Entry point for a command line version of the generator
    /// </summary>
    /// 
    /// <param name="args">
    /// The command-line arguments, which should begin with two file paths:
    ///   in.dot (DOT file to read)
    ///   out.cs (C# file to output)
    /// </param>
    /// 
    /// <returns>
    /// 0 upon success and non-zero upon failure
    /// </returns>
    public static int Main(string[] args)
    {
        if (args.Length < 2)
        {
            Console.Error.WriteLine(
                "Not enough parameters. Usage: generator.exe in.dot out.cs");
            return 1;
        }
 
        try
        {
            string dot = File.ReadAllText(args[0]);
            string cs = Generate(dot);
            File.WriteAllText(args[1], cs);
            return 0;
        }
        catch (Exception ex)
        {
            Console.Error.WriteLine("Error:n" + ex);
            return 1;
        }
    }
}
Usage

To use the visualizer, follow these steps:

  1. Install Graphviz if it's not already installed
  2. Write a DOT file to describe a job graph.
  3. Use Graphviz to render the DOT file visually. For example, open a terminal and run dot -Tpng jobs.dot -o jobs.png to render the DOT file to a PNG image.
  4. View the graph by opening jobs.png in an image viewer
  5. Build the generator C# either as a standalone executable or as a Unity editor script.
  6. If using a standalone executable, open a terminal and run generator.exe jobs.dot jobs.cs. Make sure jobs.cs is in a Unity project.
  7. If using a Unity editor script, call string csharp = JobGraphGenerator.Generate(dot) where dot is the DOT file in a string and csharp is the output C#. Save the C# output to a C# file in the Unity project.
Example

Running the generator with the above DOT yields a working C# file that's even pretty easy to read:

///////////////////////////////////////////////////////////////////////
// Warning: This file was automatically generated by JobGraphGenerator
//          If you edit this by hand, the next run of JobGraphGenerator
//          will overwrite your edits. Instead, change the DOT source
//          for this file and run JobGraphGenerator again.
///////////////////////////////////////////////////////////////////////
 
using Unity.Collections;
using Unity.Jobs;
 
namespace MyApp.Jobs
{
    public static class GeneratedJobGraph
    {
        public static JobHandle Schedule(
            NativeArray<float> i1,
            NativeArray<float> i2,
            NativeArray<int> i3,
            NativeArray<int> i4,
            NativeArray<float> i5)
        {
            JobTypes.JobA a1 = default(JobTypes.JobA);
            JobTypes.JobA a2 = default(JobTypes.JobA);
            JobTypes.JobB b = default(JobTypes.JobB);
            JobTypes.GenericJob<int> c = default(JobTypes.GenericJob<int>);
            JobTypes.JobD d = default(JobTypes.JobD);
 
            a1.Input1 = i1;
            a2.Input1 = i2;
            b.Input3 = i3;
            c.Input4 = i4;
            d.Input5 = i5;
 
            JobHandle jobHandle1 = a1.Schedule();
            JobHandle jobHandle2 = a2.Schedule();
            JobHandle jobHandle3 = b.Schedule(JobHandle.CombineDependencies(jobHandle1, jobHandle2));
            JobHandle jobHandle4 = c.Schedule(jobHandle3);
            JobHandle jobHandle5 = d.Schedule(jobHandle3);
            return JobHandle.CombineDependencies(jobHandle4, jobHandle5);
        }
 
        public static JobHandle Schedule(
            JobHandle dependsOn,
            NativeArray<float> i1,
            NativeArray<float> i2,
            NativeArray<int> i3,
            NativeArray<int> i4,
            NativeArray<float> i5)
        {
            JobTypes.JobA a1 = default(JobTypes.JobA);
            JobTypes.JobA a2 = default(JobTypes.JobA);
            JobTypes.JobB b = default(JobTypes.JobB);
            JobTypes.GenericJob<int> c = default(JobTypes.GenericJob<int>);
            JobTypes.JobD d = default(JobTypes.JobD);
 
            a1.Input1 = i1;
            a2.Input1 = i2;
            b.Input3 = i3;
            c.Input4 = i4;
            d.Input5 = i5;
 
            JobHandle jobHandle1 = a1.Schedule(dependsOn);
            JobHandle jobHandle2 = a2.Schedule(dependsOn);
            JobHandle jobHandle3 = b.Schedule(JobHandle.CombineDependencies(jobHandle1, jobHandle2));
            JobHandle jobHandle4 = c.Schedule(jobHandle3);
            JobHandle jobHandle5 = d.Schedule(jobHandle3);
            return JobHandle.CombineDependencies(jobHandle4, jobHandle5);
        }
    }
}
Conclusion

This tool is useful for building complex Unity job graphs quickly and visually. You can use it to inspect the flow between jobs and their data dependencies easily and visually. It can then be quickly converted to C# for execution in a Unity project. If changes are needed, simply update the DOT file and re-run the generator. You'll see the changes visually via Graphviz so you can inspect them for problems before ever running them in the engine. Hopefully you'll find this tool useful!