Gyredocs
Depth Practice
API

Gyre/Mathematics & Numerical Methods

Adjoints and sensitivities

In one minuteGyre computes exact derivatives of outputs with respect to flow parameters and surface shape with one transposed linear solve per output. All partial derivatives come from automatic differentiation of the discrete operators, so gradients are consistent with the solver to solver tolerance.

Written at L2 Practice goes to L4 Theory 1192 words Markdown

An optimizer needs dJ/dx\mathrm dJ/\mathrm d\mathbf x for outputs JJ (lift, drag, moment, loads) and inputs x\mathbf x (angle of attack, Mach number, Reynolds number, the position of every surface node). The state U\mathbf U is tied to x\mathbf x implicitly by Rw(U,x)=0\mathbf R_w(\mathbf U, \mathbf x) = 0.

dJdx=∂J∂x−ψT∂Rw∂x,(∂Rw∂U) ⁣Tψ=(∂J∂U) ⁣T(adjoint)\frac{\mathrm dJ}{\mathrm d\mathbf x} = \frac{\partial J}{\partial \mathbf x} - \boldsymbol\psi^{\mathsf T}\frac{\partial \mathbf R_w}{\partial \mathbf x}, \qquad \left(\frac{\partial \mathbf R_w}{\partial \mathbf U}\right)^{\!\mathsf T}\boldsymbol\psi = \left(\frac{\partial J}{\partial \mathbf U}\right)^{\!\mathsf T} \qquad\text{(adjoint)} dJdxk=∂J∂xk+∂J∂UdUdxk,∂Rw∂UdUdxk=−∂Rw∂xk(direct)\frac{\mathrm dJ}{\mathrm dx_k} = \frac{\partial J}{\partial x_k} + \frac{\partial J}{\partial \mathbf U}\frac{\mathrm d\mathbf U}{\mathrm dx_k}, \qquad \frac{\partial \mathbf R_w}{\partial \mathbf U}\frac{\mathrm d\mathbf U}{\mathrm dx_k} = -\frac{\partial \mathbf R_w}{\partial x_k} \qquad\text{(direct)}

The adjoint costs one linear solve per output and is independent of the number of inputs — the only practical choice for shape design, where inputs number in the hundreds to millions. The direct method costs one solve per input. totals(mode="auto") picks the cheaper and both are verified against each other and against finite differences.

Adjoint, finite-difference and direct derivativesAdjoint gradients versus finite differences
casederivativeadjointfinite differencerel. diff. (adjoint, FD)rel. diff. (adjoint, direct)
Euler NACA 0012dcl/dalpha7.596267.596269.8180e-112.7336e-09
Euler NACA 0012dcd/dalpha0.02223550.02223552.0356e-082.8444e-08
Euler NACA 0012dcm/dalpha0.01237020.01237021.1288e-084.6115e-07
Euler NACA 0012dcl/dmach0.2182620.2182621.9822e-092.6393e-06
Euler NACA 0012dcd/dmach3.8734e-043.8734e-042.1349e-081.1047e-05
Euler NACA 0012dcm/dmach0.001285190.001285197.8285e-096.9061e-05
Euler NACA 0012dcl/dshape (directional)0.01304910.01304916.7282e-09—
Euler NACA 0012dcd/dshape (directional)-2.4501e-05-2.4501e-051.6480e-08—
Euler NACA 0012dcm/dshape (directional)3.9401e-043.9401e-043.8413e-08—
Cylinder Re = 40dcd/dre-0.0125001-0.01250014.7607e-10—
Cylinder Re = 40dcd/dmach0.2704110.2704111.3254e-09—

gyre 0.4.0 · 39979fcf90 · · cpu · quick suite · record (JSON)Same code as this build

Nothing is hand-differentiated. With d\mathbf d the surface displacement field and θ\theta the flow parameters, the code defines two pure functions,

python
R(U, d, th) = residual_weak(U, geometry(deform(d)), th)
J(U, d, th) = functional(U, geometry(deform(d)), th)

and obtains

  • ∂J/∂U\partial J/\partial\mathbf U by jax.grad;
  • ψ\boldsymbol\psi by FGMRES on the transposed operator from jax.vjp(R, U), preconditioned by the transposed p-multigrid hierarchy;
  • all remaining terms in a single reverse sweep, the gradient of the Lagrangian L(d,θ)=J(U,d,θ)−ψTR(U,d,θ)\mathcal L(\mathbf d, \theta) = J(\mathbf U, \mathbf d, \theta) - \boldsymbol\psi^{\mathsf T}\mathbf R(\mathbf U, \mathbf d, \theta) with U\mathbf U and ψ\boldsymbol\psi held fixed.

The last step delivers the derivative with respect to every surface node and every flow parameter at the cost of roughly one residual evaluation. Shape sensitivities pass through mesh deformation, metric terms, normals, boundary conditions and the functional’s own geometric dependence automatically.

The adjoint derivative differs from the true derivative of the discrete output only through the tolerances of the nonlinear and linear solves. Finite differences, by contrast, have a truncation error that falls like ε2\varepsilon^2 and a round-off error that grows like 1/ε1/\varepsilon; they agree with the adjoint only in a narrow window of step sizes:

Finite-difference error of dcl/dα versus step sizeAdjoint gradients versus finite differences

Smallest difference in this run: 1.2e-10 at a step of 1e-05. The reference line is the ε² truncation error of a central difference; round-off of the re-converged flows grows as 1/ε.

Data table
step sizecentral difference
0.011.0052e-04
0.0019.7679e-07
1.0000e-049.6958e-09
1.0000e-051.1864e-10
1.0000e-061.8974e-10
1.0000e-072.3724e-10
1.0000e-082.7589e-09
1.0000e-093.9518e-07

gyre 0.4.0 · 39979fcf90 · · cpu · quick suite · record (JSON)Same code as this build

This matters in optimization: gradient noise from badly chosen steps or loosely converged solves is a classical cause of stalled optimizers.

The shape input x_surface is the displacement of the high-order geometry nodes on the wall (shape (n_surface_nodes, dim)); surface_coordinates() returns their baseline positions. A geometry engine or parameterisation outside Gyre maps design variables to these displacements and applies the chain rule to the returned sensitivity field.

Volume nodes follow by inverse-distance weighting over all boundary nodes: moving-wall nodes carry their displacement, all other boundary nodes carry zero. The map is linear, X=X0+Wd\mathbf X = \mathbf X_0 + \mathbf W\mathbf d, with a weight matrix that depends on the baseline mesh only. W\mathbf W is dense, volume nodes by surface nodes: small meshes store it, and above 2×1072\times10^7 entries it is applied matrix-free, recomputing the weights of a chunk of nodes each time. The transposed map WT\mathbf W\T, which every shape derivative needs, is written out the same way, because reverse-mode differentiation through the chunked loop would keep the weights of every chunk, that is, the dense matrix again. Both representations are the same map to round-off, in value, tangent and transpose. On periodic meshes the weighting is periodic: distances are taken to the nearest periodic image, twin nodes on the two periodic planes are one source and share one row of W\mathbf W, so the two sides of a periodic interface stay matched for any surface displacement. Twin surface nodes keep their own rows of d\mathbf d but move by the mean of the two prescribed values, and the derivative with respect to d\mathbf d is shared equally between them. Only translational periodicity is supported.

The matching output for structural coupling is f_surface: consistent nodal loads obtained by integrating the wall traction against the geometry shape functions. They sum exactly to the total force and are work-conjugate to x_surface.

For a coupled aero-structural system d=C f(d,α)\mathbf d = \mathbf C\,\mathbf f(\mathbf d, \alpha) with objective J(d,α)J(\mathbf d, \alpha) the total derivative is

dJdα=∂J∂α+λTC ∂f∂α,λ=∂J∂dT+(∂f∂d) ⁣TCTλ,\frac{\mathrm dJ}{\mathrm d\alpha} = \frac{\partial J}{\partial\alpha} + \boldsymbol\lambda^{\mathsf T}\mathbf C\,\frac{\partial\mathbf f}{\partial\alpha}, \qquad \boldsymbol\lambda = \frac{\partial J}{\partial\mathbf d}^{\mathsf T} + \left(\frac{\partial\mathbf f}{\partial\mathbf d}\right)^{\!\mathsf T}\mathbf C^{\mathsf T}\boldsymbol\lambda ,

where all flow derivatives are total derivatives at converged flow (the flow state is eliminated). The flow discipline has to supply exactly two operations:

  • totals(of=J, wrt=[alpha, x_surface]) — the reduced gradient of a scalar;
  • adjoint_product({"f_surface": seed}, wrt=[...]) — the reverse product (df/dx)Tseed(\mathrm d\mathbf f/\mathrm d\mathbf x)^{\mathsf T}\mathbf{seed}, one flow-adjoint solve whatever the number of load components.

The second is what FlowDiscipline.adjoint_product provides (and its forward counterpart tangent_product). The aero-structural example iterates the λ\boldsymbol\lambda equation with these calls and matches finite differences of the fully re-converged coupled system to about 10−610^{-6}. Frameworks that prefer to own the linear solve instead use the partial products jvp/vjp and solve_linear.

Differentiating the discrete equations (rather than discretizing the continuous adjoint equations) guarantees that the gradient is the gradient of the function the optimizer actually evaluates, at any resolution. It does not by itself guarantee that ψ\boldsymbol\psi approximates the continuous adjoint: that additionally requires an adjoint-consistent discretization. Gyre has both properties, which is why the same ψ\boldsymbol\psi can be reused as a weight in output error estimation.

    ↑ ↓ moveEnter open[ ] change depthResults show section and depth level