Skip to content

Commit fd5f1ed

Browse files
committed
Major Update 1.1.0 (adjoint)
1 parent 4c011a6 commit fd5f1ed

87 files changed

Lines changed: 5784 additions & 341 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
11
__pycache__
22
build/
33
build/docs
4-
.idea
5-
.DS_Store

README.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
1-
(adjoint capabilities coming soon)
2-
31
# SymX
42

53
<p align="center">
64
<img src="docs/source/_static/symx1920.png" alt="SymX Logo" style="width:75%;">
75
</p>
86

97
<p align="center">
10-
<strong>Symbolic differentiation. C++ code generation. JIT compilation. Global assembly. Non-linear optimization.</strong><br>
8+
<strong>Symbolic differentiation. C++ code generation. Nonlinear optimization. Differentiable simulation.</strong><br>
119
<a href="https://symx.physics-simulation.org/">Docs</a> &nbsp;·&nbsp; <a href="https://animation.rwth-aachen.de/media/papers/96/2025-TOG-SymX.pdf">PDF</a> &nbsp;·&nbsp; <a href="https://doi.org/10.1145/3764928">ACM Page</a>
1210
</p>
1311

@@ -17,6 +15,7 @@ Write complex mathematical expressions concisely, differentiate them arbitrarily
1715
SymX targets **non-linear optimization pipelines** typical of **FEM solvers**, but it can be used for any application that needs JIT compiled math.
1816
It uses a **stencil-based** perspective: expressions are defined per _element_ and evaluated over a discretization.
1917
SymX is the core engine of [STARK](https://github.com/InteractiveComputerGraphics/stark), a simulation framework for FEM elasticity, shells, rigid bodies, and frictional contact.
18+
SymX implements the adjoint method for differentiating through converged nonlinear simulations, enabling efficient inverse problems such as material, control, and shape identification.
2019

2120
Here is an overview of the SymX pipeline for FEM elasticity simulation:
2221
<p align="center">
@@ -53,11 +52,13 @@ The following gallery shows some of such results:
5352
- **Parallel execution:** OpenMP-parallelized loops with optional graph coloring for concurrent evaluation.
5453
- **Incremental compilation:** Generated code is cached on disk; unchanged expressions skip differentiation and compilation.
5554
- **Zero-overhead abstraction:** Definition code (lambdas, dynamic loops, containers) runs only during expression building; emitted binaries are fully specialized. This allows for very expressive compositions that are concise and powerful (e.g. high-order FEM).
56-
- **Layered API:** Three entry points — single expression, expression + discretization loop, full second-order global solve.
55+
- **Layered API:** Four entry points — single expression, expression + discretization loop, global optimization, and differentiable simulation.
5756
- **Bring your own data:** Views into user arrays; no data format imposed on your discretization.
5857
- **FEM integrator:** Symbolic FEM integrator with Tet4, Hex8, Hex27 and other element types for 3D mechanics.
5958
- **Global assembly:** Concurrent and fast global assembly of gradients and Hessians from composable potential definitions; multiple coupled DoF sets supported.
6059
- **Newton's method:** Robust second-order solver with line search; customizable convergence and callbacks.
60+
- **First-order optimization:** Direct global potential minimization with gradient descent, Adam, VectorAdam, or L-BFGS.
61+
- **Adjoint method:** Native differentiation through converged nonlinear solves, with adjoint-driven first-order inverse optimization.
6162
- **Projection to PD:** Newton, [Projected Newton](https://dl.acm.org/doi/10.1145/1073368.1073394), [Project-on-Demand](https://arxiv.org/abs/2311.14526) and [Progressively Projected Newton](https://arxiv.org/abs/2505.21013), with clamping or [mirroring](https://dl.acm.org/doi/10.1145/3641519.3657433).
6263

6364

@@ -146,14 +147,17 @@ Full documentation: <https://symx.physics-simulation.org/>
146147

147148
- [Hello World](https://symx.physics-simulation.org/hello_world.html)
148149
- [Setup](https://symx.physics-simulation.org/setup.html)
149-
- [Architecture Overview](https://symx.physics-simulation.org/diagram.html)
150+
- [Architecture Overview](https://symx.physics-simulation.org/architecture_overview.html)
150151
- [Core Symbolics](https://symx.physics-simulation.org/symbols.html)
151152
- [Compilation](https://symx.physics-simulation.org/compilation.html)
152-
- [Symbol-Data Maps](https://symx.physics-simulation.org/mapped_workspace.html)
153-
- [Loops](https://symx.physics-simulation.org/compiled_in_loop.html)
154-
- [Second-order Optimization](https://symx.physics-simulation.org/second_order_optimization.html)
153+
- [Symbol-Data Maps](https://symx.physics-simulation.org/symbol_data_maps.html)
154+
- [Loops](https://symx.physics-simulation.org/loops.html)
155+
- [Global Optimization](https://symx.physics-simulation.org/global_optimization.html)
155156
- [FEM Integration](https://symx.physics-simulation.org/fem_integration.html)
156157
- [Newton's Method](https://symx.physics-simulation.org/newtons_method.html)
158+
- [First-order Optimization](https://symx.physics-simulation.org/first_order_optimization.html)
159+
- [The Adjoint Method](https://symx.physics-simulation.org/adjoint_method.html)
160+
- [Inverse Simulation](https://symx.physics-simulation.org/inverse_simulation.html)
157161
- [Examples](https://symx.physics-simulation.org/examples.html)
158162

159163

@@ -227,4 +231,3 @@ If you are:
227231
- interested in extending the symbolic, compilation, or solver layers
228232

229233
then feel free to reach out!
230-

changelog.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Changelog
2+
3+
## 1.1.0
4+
5+
- Added stencil-based differentiable simulation with adjoint gradients,
6+
finite-difference validation, and cantilever inverse examples.
7+
- Added adjoint first-order optimizers with GradientDescent, Adam,
8+
VectorAdam, and L-BFGS.
9+
- Improved solver callbacks, status reporting, rollback, and direct-solver
10+
pattern reuse safety.
11+
- Added optional oneMKL/PARDISO support and the `SYMX_STACK_SIZE` build option.
12+
- Added first-order and adjoint documentation, plus expanded solver and
13+
inverse-problem tests.
14+
- Updated and expanded docs.
15+
16+
## 1.0.1
17+
18+
- Defaulted `SYMX_HESS_STORAGE_FLOAT` to `double`.
19+
- Clarified branch behavior and added boundary tests.
20+
- Updated bundled Eigen to 5.0.1.
21+
- Added CI coverage.
22+
23+
## 1.0.0
24+
25+
Initial public release.

docs/source/_static/muscle.jpg

106 KB
Loading

docs/source/adjoint_method.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# The Adjoint Method
2+
3+
Many simulation tasks ask a question that runs in the opposite direction to a forward solve: which material value, load, actuator, or design parameter makes
4+
the simulated result agree with an observation?
5+
The quantity being optimized is a loss on a state obtained only after solving an equilibrium problem.
6+
This is sensitivity analysis through a simulation, and it is what the adjoint layer in SymX is for.
7+
8+
The layer deliberately keeps the forward model unchanged.
9+
You declare and solve a `NewtonsMethod` exactly as in the [global-optimization layer](newtons_method.md).
10+
Then you declare a separate loss and give both objects to `AdjointNewton`.
11+
The result is an efficient gradient with respect to all loss parameters, independent of how many of them there are.
12+
13+
SymX is perfect for this type of problems as it has access to the symbolic representation of the entire problem and can handle with the annoying task of calculating cumbersome cross derivatives.
14+
15+
## Equilibrium sensitivities
16+
17+
Let $u$ be the simulation DoFs, $p$ a set of parameters, and $P(u,p)$ the physical potential.
18+
The converged forward state $u^*(p)$ satisfies
19+
20+
$$
21+
P_u(u^*(p), p) = 0.
22+
$$
23+
24+
We want the total derivative of a loss $L(u^*(p),p)$.
25+
Differentiating the equilibrium condition would require a linear solve for every parameter:
26+
27+
$$
28+
P_{uu}\frac{d u^*}{d p} + P_{up} = 0.
29+
$$
30+
31+
Instead, SymX solves one adjoint system,
32+
33+
$$
34+
P_{uu}^{\mathsf T}\lambda = -L_u^{\mathsf T},
35+
$$
36+
37+
and evaluates the gradient as
38+
39+
$$
40+
\frac{dL}{dp} = L_p + \lambda^{\mathsf T} P_{up}.
41+
$$
42+
43+
That one additional solve is why the method is especially useful when the number of parameters is large.
44+
The forward Hessian is reused as the adjoint system matrix; choose the adjoint linear solver just as deliberately as the one used by Newton.
45+
46+
## Declare the forward problem and loss
47+
48+
The forward `GlobalPotential` owns the state DoFs.
49+
The loss is another `GlobalPotential`, whose DoFs are the quantities to optimize.
50+
Both potentials use the same mapped-stencil declarations, so a loss can naturally read the converged state and any parameter arrays.
51+
52+
```cpp
53+
spGlobalPotential physical = GlobalPotential::create();
54+
// physical->add_potential(...); // defines P(u, p)
55+
physical->add_dof(state); // u
56+
57+
auto newton = NewtonsMethod::create(physical, context);
58+
59+
spGlobalPotential loss = GlobalPotential::create();
60+
// loss->add_potential(...); // defines L(u, p)
61+
loss->add_dof(parameters, "parameters"); // p
62+
63+
LinearSolveSettings adjoint_linear;
64+
adjoint_linear.linear_solver = LinearSolver::DirectLLT;
65+
auto adjoint = AdjointNewton::create(newton, loss, adjoint_linear);
66+
```
67+
68+
The physical potential may depend on `parameters` without registering them as simulation DoFs.
69+
Conversely, the loss does not register the state as a loss parameter: it reads it from the forward model.
70+
Register every independent parameter set that should receive a gradient with `loss->add_dof`.
71+
72+
## Evaluate a value and gradient
73+
74+
`run_forward_and_evaluate_L()` solves the forward equilibrium and evaluates the loss.
75+
Once that state is current, `evaluate_dL_dp_no_solve()` computes its adjoint gradient without solving the forward problem again.
76+
77+
```cpp
78+
AdjointValueResult value = adjoint->run_forward_and_evaluate_L();
79+
if (!value.success) {
80+
// value.status explains whether the forward or loss evaluation failed.
81+
}
82+
83+
AdjointGradientResult gradient = adjoint->evaluate_dL_dp_no_solve();
84+
if (!gradient.success) {
85+
// gradient.status explains the failure.
86+
}
87+
88+
double L = value.value;
89+
const Eigen::VectorXd& dL_dp = gradient.gradient;
90+
```
91+
92+
The `run_forward_and_evaluate_dL_dp()` convenience method performs both operations.
93+
The `*_no_solve` methods are intentional: they let an outer optimizer reuse an equilibrium state, but require the caller to keep that state valid.
94+
A failed value is infinity and a failed gradient is empty, so always inspect `success` before consuming either result.
95+
96+
## Verify an implementation
97+
98+
Finite differences remain the best compact check of a new inverse model.
99+
The adjoint helper evaluates centered finite differences, restoring both the parameters and simulation state even if an evaluation fails.
100+
101+
```cpp
102+
auto analytic = adjoint->run_forward_and_evaluate_dL_dp();
103+
auto finite_difference = adjoint->evaluate_dL_dp_finite_differences(1e-3);
104+
105+
if (analytic.success && finite_difference.success) {
106+
double relative_error =
107+
(analytic.gradient - finite_difference.gradient).norm() /
108+
finite_difference.gradient.norm();
109+
}
110+
```
111+
112+
For temporarily fixed scalar components, use `force_gradient_to_zero(indices)`.
113+
Indices address the flattened vector of all loss DoF sets and are validated by the API.
114+
115+
The complete workflow, including a target deformation, finite-difference check, and L-BFGS solve, is developed in [Inverse Simulation](inverse_simulation.md).
Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ classDef expr fill:#dcfce7,stroke:#22c55e,stroke-width:1.5px,color:#064e3b
1616
classDef comp fill:#dbeafe,stroke:#3b82f6,stroke-width:1.5px,color:#1e3a8a
1717
classDef map fill:#fef9c3,stroke:#eab308,stroke-width:1.5px,color:#713f12
1818
classDef solve fill:#ffe4e6,stroke:#fb7185,stroke-width:1.5px,color:#881337
19+
classDef diff fill:#f3e8ff,stroke:#a855f7,stroke-width:1.5px,color:#581c87
1920
2021
CO["Compiled&lt;T&gt;"]:::comp
2122
@@ -31,6 +32,11 @@ DM["DataMap&lt;T&gt;"]:::map
3132
POT["Potential"]:::solve
3233
GP["GlobalPotential"]:::solve
3334
NM["NewtonsMethod"]:::solve
35+
FO["FirstOrderOptimizer"]:::solve
36+
LOSSPOT["Loss Potential"]:::diff
37+
LOSS["Loss GlobalPotential"]:::diff
38+
AN["AdjointNewton"]:::diff
39+
AFO["FirstOrderOptimizerAdjoint"]:::diff
3440
3541
WS ==>|"creates"| SC
3642
WS ==>|"creates"| VE
@@ -45,6 +51,11 @@ CIL ==>|"has one"| MW
4551
GP ==>|"has many"| POT
4652
NM ==>|"has one"| GP
4753
NM ==>|"has many"| CIL
54+
FO ==>|"has one"| GP
55+
AN ==>|"reuses"| NM
56+
AN ==>|"has one"| LOSS
57+
LOSS ==>|"has many"| LOSSPOT
58+
AFO ==>|"has one"| AN
4859
```
4960

5061
## Layer 1 (Green) · Expression Building
@@ -78,11 +89,23 @@ You process the output in a (inlined) callback function.
7889

7990
This layer is still generic and powerful, but it is made convenient by handling loop evaluation and hardware optimizations.
8091

81-
## Layer 4 (Red) · Second–Order Solver
92+
## Layer 4 (Red) · Global Optimization
8293

8394
- **Representative Classes:** `NewtonsMethod`, `GlobalPotential`
8495
- **Use case:** You want SymX to solve a second-order optimization problem end-to-end.
8596

8697
You declare energy potentials and degrees of freedom in a `GlobalPotential`.
8798
Each potential is defined by a connectivity array and symbolic expression.
8899
`NewtonsMethod` then takes care of everything else: symbolic differentiation, code generation, compilation (with caching), element-level assembly, projection to positive-definite Hessians, global linear system solve, and line search.
100+
101+
For objectives where a first-order method is preferable, `FirstOrderOptimizer` uses the same potential and DoF declaration with Gradient Descent, Adam, VectorAdam, or L-BFGS.
102+
103+
## Layer 5 (Purple) · Differentiable Simulation
104+
105+
- **Representative Classes:** `AdjointNewton`, `FirstOrderOptimizerAdjoint`
106+
- **Use case:** You want gradients of a loss that depends on a converged simulation.
107+
108+
`AdjointNewton` combines a Newton equilibrium solve with a separate loss potential and evaluates the total derivative with respect to loss parameters.
109+
`FirstOrderOptimizerAdjoint` then uses those gradients to solve inverse problems.
110+
The forward simulation remains a normal `NewtonsMethod`; the adjoint layer is the bridge that makes its outcome differentiable.
111+

docs/source/examples.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,17 @@ Showcases how to use the high-level `NewtonsMethod` to solve FEM problems compac
103103

104104
---
105105

106+
## Inverse Cantilever
107+
108+
![Muscle](_static/muscle.jpg)
109+
110+
**File:** [`cantilever_adjoint.cpp`](../../examples/cantilever_adjoint.cpp)
111+
112+
Recovers a cantilever's density from a target gravity deformation using a Newton equilibrium solve, an adjoint gradient, and outer L-BFGS optimization.
113+
The same file includes a muscle-control inverse problem (result above) where the inverse simulation optimizes the active muscle fibers along a deformable elastic muscle so that the tip is at a target position marked with a red cube.
114+
115+
---
116+
106117
## Dynamic Elasticity with Contact
107118

108119
![Dynamic elasticity with contact](_static/dynamic_with_contact.jpg)
@@ -113,4 +124,3 @@ An elastic Stanford bunny is thrown in the air, hits the ground, and rolls.
113124
The simulation uses implicit (backward Euler) time integration, stable Neo-Hookean elasticity and IPC-style frictional contact with the floor.
114125

115126
Each time step is solved with Newton's Method on a `GlobalPotential` that includes inertia, elasticity, contact, and friction terms — all defined symbolically through the same callback-based API.
116-

docs/source/fem_integration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ return
9696
};
9797
```
9898

99-
As described in [Symbol-Data Maps](mapped_workspace.md#summation), `MappedWorkspace` provides _summation_ facilities that allows for easy and efficient implementation of numerical integration.
99+
As described in [Symbol-Data Maps](symbol_data_maps.md), `MappedWorkspace` provides _summation_ facilities that allows for easy and efficient implementation of numerical integration.
100100
Note that SymX's summation is also _symbolic_ and **not** unrolled, which would explode the number of operations.
101101
Differentiation is efficiently handled as a summation.
102102

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# First-order optimization
2+
3+
`FirstOrderOptimizer` directly minimises a `GlobalPotential` without the need of second-order derivatives.
4+
This is useful in the cases where Hessian calculation is too complex or unstable.
5+
First order runs are also useful for visualization and debugging as they usually produce a smoother progression of the minimization process than Newton's method.
6+
7+
## Basic use
8+
9+
Define the potential and its DoF sets in the usual way, then construct the optimizer with the same context and optional callbacks used by Newton's method:
10+
11+
~~~cpp
12+
spGlobalPotential objective = GlobalPotential::create();
13+
// objective->add_potential(...);
14+
// objective->add_dof(variables, "variables");
15+
16+
spContext context = Context::create();
17+
auto optimizer = FirstOrderOptimizer::create(objective, context);
18+
optimizer->settings.type = FirstOrderOptType::LBFGS;
19+
SolverReturn result = optimizer->solve();
20+
~~~
21+
22+
The optimizer compiles the potential value and direct gradient once.
23+
Each iteration evaluates both, selects a descent direction, and applies the normal step cap, maximum-step callback, intermediate-state check, and optional Armijo backtracking.
24+
A rejected or failed line search restores the exact DoFs that were accepted before that trial.
25+
26+
## Algorithms and settings
27+
28+
`FirstOrderSettings` is shared by the direct and adjoint optimizers.
29+
Its default is `LBFGS`; the available values are:
30+
31+
- `GradientDescent`: `-learning_rate * gradient`.
32+
- `Adam`: component-wise adaptive moments.
33+
- `VectorAdam`: adaptive moments with one second-moment value per fixed-size vector block.
34+
- `LBFGS`: limited-memory quasi-Newton directions with normalized steepest descent for the first step.
35+
36+
`VectorAdam` keeps each DoF set separate when creating blocks.
37+
Therefore every DoF set must be divisible by `settings.vector_adam.vector_block_size`; invalid block configurations throw `std::invalid_argument`.
38+
This preserves vector rotation behavior for three-dimensional fields while allowing scalar parameter sets to use a block size of one.
39+
40+
L-BFGS uses `history_size`, `bootstrap_step_length`, and `curvature_threshold`.
41+
Curvature pairs whose `y.dot(s)` is not larger than the threshold are discarded.
42+
If a produced direction is not a descent direction, `restart_when_non_descent` resets the algorithm state and retries with the default steepest-descent direction.
43+
44+
## Callbacks, results, and diagnostics
45+
46+
The direct optimizer accepts `SolverCallbacks`. It invokes `before_step` and `after_step` as a pair for every attempted iteration, and invokes
47+
`before_energy_evaluation` before each value or gradient evaluation.
48+
The normal validity, maximum-step, Armijo-failure, convergence, and residual callbacks have the same meanings as in [Newton's method](newtons_method.md).
49+
50+
`solve()` returns `SolverReturn`.
51+
Invalid initial values or gradients return `InvalidInitialState`; failed intermediate-state or Armijo searches return the corresponding line-search status after restoring the prior DoFs.
52+
`get_last_solve_stats()` exposes iteration, line-search, and rejected L-BFGS history counts. `print_summary()` formats the accumulated logger statistics.
Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
# Second-Order Optimization
1+
# Global Optimization
22
SymX was designed to find the solution to large and complex non-linear optimization problems.
33
While it is not the only thing it can do, it is the main focus and where most of the advanced infrastructure lies.
44

5+
This layer starts from a `GlobalPotential`: a global objective assembled from symbolic element energies.
6+
Use [Newton's method](newtons_method.md) when its Hessian is useful, or [first-order optimization](first_order_optimization.md) when a gradient method is the better fit.
7+
The next layer, [differentiable simulation](adjoint_method.md), uses a converged Newton solve as the state of an inverse problem.
8+
59
Consider the problem
610

711
$$
@@ -78,7 +82,7 @@ G->add_potential("new_potential", connectivity,
7882
}
7983
);
8084
```
81-
Potential function definitions use `MappedWorkspace` and `Element`, introduced in [Symbol-Data Maps](symbol_data.md), within a lambda.
85+
Potential function definitions use `MappedWorkspace` and `Element`, introduced in [Symbol-Data Maps](symbol_data_overview.md), within a lambda.
8286
This time however, these are managed by `GlobalPotential` in order to automate differentiation and evaluation later on.
8387
Multiple potentials can be added freely, and together they form $G = \sum_p P_p$.
8488

0 commit comments

Comments
 (0)