Zygote.jl

repository·master·Indexed 23 days ago

https://github.com/fluxml/zygote.jl

A source-to-source automatic differentiation (AD) engine for Julia, designed as the next-generation AD system for the Flux.jl differentiable programming framework. It hooks into the Julia compiler to generate high-performance backwards passes. Zygote supports Julia 1.10 onwards and handles control flow, recursion, closures, structs, and dictionaries, though mutation and exception handling are not supported. It provides high-level functions like `gradient` and low-level tools like `Zygote.pullback` and the `@adjoint` macro for custom gradients.

Tokens
4.8K
Snippets
14
Records
34
Agent score
81%

What's inside Zygote.jl

  1. Understand Zygote's differentiation mechanism

    master

    Zygote performs Automatic Differentiation (AD) by transforming functions into pullbacks. A pullback (represented by the function J in conceptual examples) takes a function and its inputs, returning the result of the forward pass and a 'back' function. The 'back' function (the adjoint) takes the gradient of the output and returns the gradient of the inputs.

    For composite functions, Zygote chains these pullbacks together: the backward pass of a composite function $f(g(x))$ is the composition of the pullbacks of $f$ and $g$, propagating gradients from the output back to the input.

  2. Understand pullbacks and the pullback function

    master

    The gradient function is a high-level wrapper around Zygote.pullback. A pullback is a fundamental operation that returns two values: the result of the original function and a pullback function (often called back).

    Mathematically, if $y = f(x)$, the pullback $\mathcal{B}_y$ implements the vector-Jacobian product (VJP). Given a gradient $\bar{y}$ (the derivative of a loss with respect to the output), the pullback computes the gradient with respect to the input: $\bar{x} = \mathcal{B}_y(\bar{y})$.

    In Zygote, pullbacks implement the adjoint of the Jacobian, performing a left-multiplication ($v'J$).

  3. Distinguish between Trace and IR

    master

    While both represent a program's execution, they differ in fidelity:

    • IR (Intermediate Representation): A lower-level version of source code (e.g., using goto instead of loops) that preserves control flow semantics, making it easier to manipulate programmatically.
    • Trace: A runtime recording of mathematical operations (often a Wengert List). Traces unroll and inline all control flow, functions, and data structures, which can lead to a loss of original program semantics.
  4. Differentiating real-valued functions of complex variables

    master

    When a function f returns a real number (e.g., abs2(c)), Zygote treats the complex input c = x + yi as a pair of reals (x, y). The resulting gradient is defined as the adjoint $\bar{c} = \frac{\partial z}{\partial x} + \frac{\partial z}{\partial y}i$. This pragmatic definition is suitable for gradient descent applications.

    Note that this definition is not equivalent to the mathematical complex derivative unless the function is holomorphic.

  5. Prefer ChainRulesCore for custom sensitivities

    master
    When defining custom sensitivities (adjoints), it is highly recommended to use ChainRulesCore.jl instead of Zygote-specific macros. Defining sensitivities via ChainRulesCore.rrule(f, args...; kwargs...) ensures your custom rules are compatible with multiple Automatic Differentiation (AD) systems, not just Zygote. Use Zygote-specific adjoints only when you need to access features or behaviors unique to Zygote's internal implementation.
  6. Handle try-catch statements in Zygote

    master

    You can use try/catch blocks in code being differentiated, provided that no exception is actually thrown during the execution. If the control flow enters a catch block, Zygote will fail because it cannot differentiate through the exception handling mechanism.

    Solution: Use more graceful error handling, such as returning nothing or a sentinel value instead of relying on exceptions.

    function safe_sqrt(x)
      try
        sqrt(x)
      catch
        0.
      end
    end
    
    # Works if no error is thrown
    gradient(safe_sqrt, 4.) 
    
    # Fails if error is thrown (control flow enters catch)
    pullback(safe_sqrt, -1.)
  7. Considerations for second derivatives

    master

    While Zygote theoretically supports derivatives of derivatives, it faces several challenges:

    • Many existing rules are not themselves differentiable (they use forbidden mutation).
    • Complexity grows rapidly as Zygote differentiates its own un-optimized output.
    • Reverse-over-reverse is often inefficient.

    Recommendation: For second-order derivatives (like the Hessian), it is often better to use a different AD system. For example, the hessian function uses ForwardDiff over Zygote.

  8. Understand Zygote's differentiation approach: Source to Source Differentiation

    master
    Zygote uses Source to Source Differentiation (also known as Source Code Transformation or SCT). Unlike tracing ADs that record operations at runtime to create a Wengert list, Zygote operates on the language's IR (Intermediate Representation). This allows it to work on in-memory IR rather than text source, providing more expressiveness than tracing-based systems which often struggle with control flow due to the lossy nature of the trace.
  9. How pullbacks and adjoints work in Zygote

    master

    In Zygote, a pullback is the core mechanism for reverse-mode differentiation. Given a function $y = f(x)$, the pullback is the function $\bar{x} = \text{back}(\bar{y})$ returned by y, back = Zygote.pullback(f, x). This operation is mathematically equivalent to a Vector-Jacobian product.

    When defining new pullbacks (for example, using the @adjoint macro), you are defining the adjoint of the Jacobian. This process is used to determine the sensitivity—how a change in a value $x$ affects a scalar loss $l$.

    y, back = Zygote.pullback(f, x)
  10. Differentiate foreign call expressions via custom rrules

    master

    Zygote cannot differentiate expressions that call external libraries (C or Fortran) via ccall because the underlying code is not in Julia.

    Solution: The only way to differentiate a foreign call is to write a custom ChainRulesCore.rrule that manually defines the gradient (pullback).

    # A function calling a C function
    jclock(x) = ccall(:clock, Int32, ()) * x
    
    # Custom rrule to enable differentiation
    function ChainRulesCore.rrule(::typeof(jclock), x)
      y = jclock(x)
      pb(ȳ) = (ChainRulesCore.NoTangent(), ȳ * y)
      return y, pb
    end
    
    # Now this works
    gradient(jclock, rand())
  11. Best practices for mutable structs

    master

    Zygote has limited and buggy support for mutating fields in mutable structs (e.g., x.a = val).

    Recommendation: Use immutable structs instead. If you need to 'modify' a value, use tools like @set from Accessors.jl, which returns a new object without side-effects.

  12. Differentiating holomorphic functions of complex variables

    master

    For holomorphic (analytic) functions—functions that can be represented by a Taylor series and do not explicitly use real, imag, or conj (e.g., exp, log)—the complex derivative is the conjugate of the gradient of the real part.

    To obtain the mathematically correct complex derivative for a holomorphic function, take the gradient of the real part and apply conj to the result.