Code generation guide¶
Once a first-order method has been verified with a
Verifier, FOMTools can generate a fast, low-level
implementation of it — in the spirit of
OSQP codegen — together with a tiny
Python wrapper. The translation is performed by an LLM coding agent that writes
the target code and iterates against a Python reference oracle until the output
is numerically verified.
This page is the reference for that pipeline: how to record a method, when a run rolls back up into a loop, how to declare an iteration-varying coefficient, and what the emitted project contains. The user guide introduces the same pipeline in brief, the tutorials work through five complete examples, and the API page documents each symbol.
Defining the FOM¶
The Python snippets below reuse the imports and problem data in the first block.
Calls to codegen, codegen_rolled, and generate_code require the codegen
extra and an authenticated coding-agent driver (FOMTOOLS_CODEGEN_AGENT=1),
or an explicit driver=. Recording the method requires no coding agent.
Write the algorithm as a loop, exactly as you would to verify it, and keep the
iterate sequence. An
iterate_trajectory is
the tidiest way to hold it (see accumulating methods
for why), but any list works:
import numpy as np
import fomtools as ft
n, m = 2, 4
rng = np.random.default_rng(0)
A = rng.normal(size=(m, n))
b = rng.normal(size=(m,))
t = 1.0 / np.max(np.real(np.linalg.eigvals(A.T @ A)))
At = np.eye(n) - t * A.T @ A
Bt = t * A.T
K = 200
vp = ft.Verifier(method="simulator", obbt_solver=None)
b_param = vp.parameter(m, lb=b - 1.0, ub=b + 1.0)
z = vp.iterate_trajectory(K + 1)
z[0] = vp.iterate(n, lb=2, ub=2)
for k in range(1, K + 1):
z[k] = vp.soft_threshold(At @ z[k - 1] + Bt @ b_param, lambd=t)
Verify it however you normally would, then hand the same object to codegen:
result = vp.codegen("out/ista", iterates=z, name="ista")
There is nothing to restructure: the program you generate from is the program you
verified. Each iteration carries its own coefficients, so the recording may do
whatever it likes with the iteration index — a decaying step size, momentum, a
step whose type changes with k, or a reference to an earlier iterate held
across the whole run, as in a Halpern anchor.
Rolling back up¶
Generating the run literally would emit K blocks, which for a method that does
the same thing every iteration is wasteful. So before unrolling anything,
extraction pulls out iteration one on its own and asks whether repeating it
reproduces the recording. When it does, that compact IR is what gets emitted —
one block, whatever K was:
result = vp.codegen("out/ista", iterates=z)
result.ir.unrolled # False: the run rolled back up
len(result.ir.body) # 1
Rolling changes the artifact and never the computation. The rolled solver keeps
the recorded horizon rather than any horizon you configure, and the check that
decides to roll is the proof it was safe: it replays the recorded tape and
compares it against the rolled loop, iteration by iteration, over several
parameter draws. Pass roll=False to emit the unrolled body regardless.
result.ir.unrolled tells you which you got.
Generating from a single iteration instead¶
If you would rather supply one stationary iteration and choose the loop count
yourself, codegen_rolled does that:
next_state = z[1]
result = vp.codegen_rolled(
"out/ista", next_state,
iterates=[z[k] for k in range(1, K + 1)], # optional, but see below
param_values={b_param: b}, # any value in the parameter set
)
Here the iteration must be stationary, because extraction reads it once and
emits a loop that repeats it. A coefficient that changes with the iteration index
would be frozen at whatever value the recorded iteration happened to use, and the
generated solver would run a different algorithm than the one you verified.
Passing iterates and param_values has that checked rather than assumed; omit
them and you get a warning, because nothing else in the pipeline can catch the
mistake — the reference oracle is itself a rolled loop, so it agrees with a
wrongly rolled iteration by construction.
codegen needs no such argument: it generates the recording itself, and gates the
result against the tape using parameter values it draws from the declared box.
Accumulating methods¶
Douglas–Rachford and ADMM carry their iterate through an expression rather than a step output:
dr = ft.Verifier(method="simulator", obbt_solver=None)
q_dr = dr.parameter(n, lb=-1, ub=1)
kkt = np.eye(n) + A.T @ A
z_dr = dr.iterate_trajectory(K + 1)
z_dr[0] = dr.iterate(n, lb=2, ub=2)
for k in range(1, K + 1):
u = dr.implicit_linear(z_dr[k - 1] - q_dr, output_mat=kkt)
ut = dr.relu(2 * u - z_dr[k - 1])
z_dr[k] = z_dr[k - 1] + ut - u # not a step output
Written that way, each iterate references every leaf the previous one did plus the new step outputs, so the expression grows down the trajectory — leaf support runs 3, 5, 7, 9, … — and generating the run would need a number of matrix-vector products that grows with the square of the iteration count.
iterate_trajectory handles this for you. Its default collapse_expr="auto"
notices the growth after two consecutive increases and keeps each subsequent
iterate as its own variable, which restores linear growth. A method that merely
refers to an earlier iterate — a Halpern anchor — holds its support flat instead,
so it is left alone. Nothing to configure either way.
Forcing collapse_expr=True opts out of that, and generating the resulting
recording raises UnrolledBlowupError with the measured counts rather than
emitting a solver nobody wants. (A stationary accumulating method is unaffected
either way: it rolls back up before size matters.)
Generating the solver¶
result = vp.codegen("out/ista", iterates=z, name="ista")
assert result.success
Stopping early on a tolerance¶
A solver that exits once the iterate stops moving is a property of the rolled
form, so it comes from codegen_rolled:
from fomtools.codegen import CodegenConfig, TerminationKind, TerminationSpec
config = CodegenConfig(
termination=TerminationSpec(kind=TerminationKind.INF_NORM_CHANGE, max_iters=2000, tol=1e-6),
)
result = vp.codegen_rolled("out/ista", next_state=next_state, config=config, name="ista")
assert result.success
Generating from a recorded run runs that run, so there is no horizon to stop short
of and codegen rejects the infinity-norm criterion rather than quietly ignoring
it. Reach for codegen_rolled when you want that stopping rule, or codegen when
you want the algorithm you recorded. The verification objective (next section) is
the exception: codegen accepts it.
TerminationSpec also takes check_termination (named after OSQP's setting):
the residual is computed and tested only every check_termination iterations,
and 0 disables early exit entirely. The default is 1 — the generated check is
an infinity norm fused into the state copy, measured at 1–2.5% of iteration
time for direct-method FOMs, and checking less often overshoots the stopping
point by up to check_termination - 1 full iterations. Raise it only for
matrix-free methods on large problems, where a single iteration is cheap
relative to a pass over the state.
Stopping on the verification objective¶
TerminationKind.INF_NORM_CHANGE stops on ||x_k - x_{k-1}||_inf over the whole
state, which is not the quantity you verified when your objective reads a scaled
or partial iterate, or is a KKT residual. TerminationKind.OBJECTIVE stops on the
objective itself, the one set with maximize, in the norm it was set
with (ft.sum_squares is the squared two-norm, as in verification):
H = np.diag(np.linspace(1.0, 2.0, n)) # e.g. a diagonal scaling of the residual
vp.maximize(ft.norm(H @ (z[K] - z[K - 1]), np.inf))
config = CodegenConfig(
termination=TerminationSpec(kind=TerminationKind.OBJECTIVE, max_iters=K, tol=1e-6),
)
result = vp.codegen("out/ista", iterates=z, config=config)
The solver tests r^k <= tol every check_termination iterations (default 25)
and always at the last one, and reports r^k in solution.residual. The test is
then exactly the certified quantity: if verification shows r^K <= tol over the
whole parameter set, the generated solver is guaranteed to stop by iteration K
with FOM_SOLVED. It also means experiments can time every solver at the same
tolerance without searching over iteration counts.
The objective is written at iteration K in terms of w^K and w^{K-1}, and is
mapped exactly onto what the loop holds at the end of an iteration: the new and
current state, the parameters, the step outputs of the iteration (when the
objective is written on the iteration the solver was extracted from), and ReLU
steps recorded after the loop for one-sided terms such as max(Ax - u, 0). A row
holding a ReLU must hold only that term. An objective written after the loop, as
above, needs the recorded iterates to locate iteration K; codegen has them,
and codegen_rolled takes them through iterates=. Anything the loop does not
carry (an iterate older than w^{K-1}, an iterate accumulated as a non-leaf
expression, a parameter the iteration never reads) raises
ObjectiveExtractionError saying what to change: carry the older iterate in the
state, or record the iterates with iterate_trajectory.
The metric is generated as fom_objective(...) in its own source,
src/fom_objective.c (beside the kernels in src/fom_ops.cu on CUDA), which the
coding agent may never edit, even where it may rewrite the matrix-vector
products; the agent's loop only decides when to call it. make verify checks the stopping iteration, the
objective value and the status against the reference oracle, at the default
settings and at other runtime check_termination and max_iter. An unrolled run
(one that does not roll back up) evaluates the objective once, after its single
pass.
This requires a coding agent. Enable the real Claude Agent SDK driver with
FOMTOOLS_CODEGEN_AGENT=1 (requires authentication), or pass an explicit driver
(for example a StubAgentDriver wrapping a known-good source) for fully
deterministic, offline runs.
The emitted directory (out/ista) contains:
include/ fom_solver.h, fom_workspace.h, fom_data.h (ABI + dims + data decls)
src/ fom_solver.c (the agent's solver), fom_data.c (all numeric data)
test/ SPEC.md, spec.json, data.npz, verify_main.c, verify_data.h
python/ fom_solver.py (ctypes wrapper)
Makefile (make verify / make lib)
Using the generated solver¶
From C, via the OSQP-style ABI:
#include "fom_solver.h"
int main(void) {
FomWorkspace work; FomSettings s;
FomParams params = {0}; /* example parameter p0 = 0 */
fom_set_default_settings(&s);
fom_setup(&work, &s);
fom_update_params(&work, ¶ms); /* set params.p0[...] */
fom_solve(&work); /* work.solution.x holds the solution */
fom_cleanup(&work);
return 0;
}
Or from Python:
import subprocess, sys
subprocess.run(["make", "lib"], cwd="out/ista", check=True)
sys.path.insert(0, "out/ista/python")
from fom_solver import Solver
solver = Solver()
out = solver.solve(p0=b) # {"x", "iter", "residual", "status"}
solver.update_settings(tol=1e-8, check_termination=10, max_iter=500) # runtime
Performance: timing, profiling, and the code optimization phase¶
Pass code_optimization=True, profile=True (the default for the fomtools-codegen
CLI with --driver agent) to (a) measure the speed of the generated solver
against the Python reference, and (b) enter the code optimization phase, an
iterative, profile-guided rewrite of the solver:
result = vp.codegen(
"out/ista", iterates=z, name="ista",
code_optimization=True, profile=True,
)
print(result.timing.summary())
After the translation phase verifies, the orchestrator records a baseline (Python
reference time, the translated generated-C time, and a profiling breakdown). Then the agent
runs a loop: profile → optimize the hottest region → make verify (correctness
is re-checked every round) → make bench → repeat until gains level off. A
change that breaks verification is never kept, and if the whole phase leaves the
solver regressed, the verified translated solver is restored automatically. The
files the agent may rewrite, and how each is checked, are described
below.
result.timing (a TimingReport) holds python_us, c_initial_us,
c_final_us, the speedup factors (speedup_codegen, speedup_optimization,
speedup_total), and the final profile breakdown, and .summary() formats
them, e.g.:
Timing summary (per solve):
Python reference (before codegen): 1328.250 us
Generated C (translated): 1.753 us
Generated C (optimized): 1.640 us
Speedup from codegen (Py -> C): 757.7x
Speedup from code optimization: 1.07x
Total speedup (Py -> optimized C): 809.9x
Profiling breakdown (hottest first):
soft_threshold 20.246 us 51.21% (1000000 calls)
state_update 19.288 us 48.79% (1000000 calls)
The generated directory also exposes make bench and make profile directly.
The portable region profiler (include/fom_profile.h) is compiled in only for
make profile (-DFOM_PROFILE); its FOM_PROF_BEGIN/END macros are no-ops in
the shipped make verify / make lib builds, so profiling never affects the
production solver.
Time and turn budgets¶
Each agent phase runs under a budget, set per run on CodegenConfig:
| setting | default | bounds |
|---|---|---|
agent_timeout_s |
600 s (10 min) | wall-clock time of the translation phase |
agent_optimize_timeout_s |
7200 s (2 h) | wall-clock time of one code optimization round |
agent_optimize_max_turns |
200 | agent turns of one code optimization round |
agent_optimize_rounds |
5 | maximum number of code optimization rounds |
agent_optimize_min_gain |
0.02 | a round gaining less than this (relative) ends the phase |
The code optimization phase runs in rounds. Each round is a fresh agent run on
the fastest verified solver so far, told its current time and profile. After each
round the pipeline re-verifies the rewrite (including the kernel rules) and
benchmarks it head to head against that solver. A round that fails, is not
faster, or gains less than agent_optimize_min_gain ends the phase; a failing or
slower one is reverted first. OPTIMIZATIONS.md lists the rounds.
Each round's summary also lists, under Blocked by rules:, the optimizations
the agent considered but did not make because a rule forbade them, with the rule
and an expected gain. They appear as a table in OPTIMIZATIONS.md and as
rule_friction on the result. Collected across problems, they show which rules
cost speed, and so which ones are worth replacing by an exact check that admits
the optimization.
fomtools.codegen.generate.run_code_optimization runs the phase on any directory
that holds a verified translated solver, for example a copy, when you want to
keep the translated solver too.
Translation is a bounded task and rarely needs more than a few minutes. The code
optimization phase is open-ended, and on a GPU every round (an nvcc build,
make verify, make bench) is slow, so it gets two hours and enough turns that
time, not turns, ends a run. Raise them for larger problems:
config = CodegenConfig(agent_optimize_timeout_s=4 * 3600, agent_optimize_max_turns=400)
result = generate_code(..., config=config, code_optimization=True)
or, on the command line, --translate-timeout, --optimize-timeout (seconds),
--optimize-max-turns, --optimize-rounds and --optimize-min-gain. The
budgets apply to whichever agent driver runs, a driver you build yourself included: one built without a config takes the run's.
When a budget runs out the agent is stopped, whatever it wrote is re-verified and
benchmarked as usual, and the report says so (in that case the agent may not have
written its own summary). For reference, a CUDA CG solve stopped at 30 minutes
had already gone from 2.05 s to 0.114 s per solve and was still improving.
What changed, and a marker-free production build¶
When the code optimization phase runs, two extra artifacts are written:
OPTIMIZATIONS.md— a durable record of the run: the timing summary, the agent's own notes, a unified diff of every rewritten source from the translated version to the optimized one, so you can see exactly what was changed, and a kernel-check table (see below). Also available programmatically asresult.optimization_diffandresult.kernel_checks.release/fom_solver.c(withrelease/fom_linsolve.candrelease/fom_ops.c) — a production copy with the profiling markers and thefom_profile.hinclude stripped. The markers are already no-ops outsidemake profile, so this is behaviour- and performance-identical; it is just a clean, dependency-free source for embedding. Build it withmake release(which links the shared library fromrelease/), or drop it in for thesrc/files.
What the agent may rewrite, and how it is checked¶
The translation phase writes only src/fom_solver.c. The code optimization
phase may also rewrite the two linear parts of the solver, where most of the time
goes. The package restores every other file before each check, so the numeric
data cannot change:
| file | in the code optimization phase | checked by make verify |
|---|---|---|
src/fom_solver.c (the iteration) |
rewritten by the agent | end to end, against the Python reference on sampled parameters |
src/fom_linsolve.c (linear solves) |
rewritten by the agent | each fom_lin_solve_<name> against the reference solve of its factorization, on random right-hand sides |
src/fom_ops.c (matrix-vector products) |
rewritten by the agent | each fom_apply_<name> exactly against its matrix, each fom_fused_<block> exactly against its members (below); each fom_step_<block>, __rest routine and fom_iterate exactly against its members and a reference activation |
src/fom_data.c, src/fom_objective.c, include/, test/, Makefile |
package only | — |
A matrix-vector routine is a linear map, so it can be checked for every
input, not just sampled ones. make verify runs each fom_apply_<name> next to a
package-written reference (test/ops_ref.c) that reads the same fom_data.c
arrays, starting both from the same nonzero out (so the routine must still
accumulate, out += M @ x). The tolerance scales with M @ x, not with that
starting value, so each entry is held to tol_kernel relative to itself. A rewrite
may only reorder arithmetic, so tol_kernel (1e-10 in double precision) is far
tighter than the end-to-end tol_verify, and a slightly perturbed coefficient fails
here even where the sampled solves would absorb it:
- on every unit vector when the matrix is small enough
(
cols × (nnz + rows) ≤ 1e8): this recovers the routine's matrix column by column, and a wrong coefficient is reported by its entry; - on two random vectors always, which also catches a routine that branches on its input; above the budget these are the whole check.
The check stores no matrix-sized data: the expected values are computed in C from
fom_data.c, and the only stored data are the random probe vectors.
Fused products. Some speedups come from relations between matrices, which
no single fom_apply_<name> can see. In an accelerated method the slope of the
momentum term is often a signed rearrangement of the base block
(D = [-A1, A1] for A = [0, A1]), so A x + gamma D x is one smaller product,
A1 (x1 + gamma (x1 - x0)). So fom_ops.c also holds one
fom_fused_<block>(sources..., schedule values..., out) per output block with two
or more products by the state or a temporary. It adds them all,
out += sum_k s_k * (M_k @ src_k). Parameter terms stay out, so the solver can
still compute them once per solve. A fused routine may read the data of its
members, and only theirs. make verify checks it against the sum of its members'
references:
- on every unit vector of each source (the other sources zero), first with every schedule value at 0 and then with each alone at 1. At each setting the routine is linear in the probed source, so this recovers every member exactly, entry by entry;
- on two random choices of all sources and schedule values, which catches a routine that is right only at 0 and 1, or not linear in its inputs.
fom_ops.c may not read the schedule tables or accessors (fom_sched_*,
FOM_SCHED_*). A routine gets its schedule values as arguments, so it cannot
special-case the values the solver will pass it; with the no-new-literals rule,
it has nothing to compare them against.
Selections. A matrix with at most one nonzero per row, every nonzero exactly
1, is a copy: an identity, a shift, or a gather. SPEC.md lists these, and
fom_data.h carries each index map as FOM_SEL_<name>_SRC (-1 for an empty
row). The solver may apply them itself (or write a result straight to its
destination) instead of calling fom_apply_<name>. Such a copy is checked end to
end only; a wrong index moves whole entries of the state, which the sampled
comparison sees. CUDA has neither fused routines nor selection maps: its products
run on the device from fom_ops.cu's own arrays.
Step routines. A fused routine still leaves the activation, the constant
and the parameter terms to the solver, and it accumulates, so the solver zeroes,
copies and makes a second pass over every block. fom_ops.c therefore also holds
one fom_step_<block>(sources..., schedule values..., bias, act, out) per output
block (each body step without a linear solve, and the state update su). It
computes the whole block, out = act(bias + sum_k s_k * (M_k @ src_k)), and
overwrites out. bias is what the caller adds (the constant and the
parameter terms, which it can still hoist), and act points at the activation's
parameters: a FomAct_<block> with the soft-threshold lambd, or the lo (and
hi) bound vectors, whose package values are FOM_ACT_<block>. A step routine
must apply the parameters it is passed, so make verify can choose them:
- with the activation switched off (
lambd = 0, bounds at ±1e300), on every unit vector of each source at every schedule setting and with a random bias. The routine is then linear, so this recovers every member and the bias pass-through exactly.outstarts from garbage, so a routine that accumulates fails; - on random sources, schedule values and biases, with the activation off, at the package parameters, and at random parameters drawn around the pre-activation values, which make it active on about half the rows. A routine that hardcodes a bound or threshold, or is wrong only where the activation is active, fails here, even one that recognizes the switched-off probes by their huge bounds.
fom_ops.c may not read FOM_ACT_*, the constant vectors, or the activation
bounds: they come in only through bias and act.
The whole iteration. fom_iterate(state, schedule values..., bias_<block>...,
act_<block>..., t_<name>..., new_state) chains the step routines of one
iteration (when no body step has a linear solve): it writes every temporary,
which the solver and fom_objective still need, and the new state. A rewrite can
keep values in registers from block to block and share intermediates between
them. It may read the data of every matrix of the iteration. make verify
compares every temporary and the new state with the chained reference steps: on
every unit vector of the state, with the activations off and zero biases, at
every schedule value alone at 1, all at 0, and one random setting (which catches
products of schedule values across blocks); then on random states, biases and
activation parameters as for a step routine.
Selection rows. A block whose rows are partly plain copies (exactly one
unscaled product with exactly one entry equal to 1 in the row, the others none)
lists those rows in fom_data.h as FOM_SELROWS_<block>_ROW/SRC/COL, described
in SPEC.md. Its fom_fused_<block>__rest and fom_step_<block>__rest compute
every other row. make verify holds them to the full reference on their rows
and checks that they leave every selection row bit for bit untouched. The solver
may then write the selection rows itself, a step block's as
out[row] = act(bias[row] + src[col]). Like whole selections, those copies are
checked end to end only.
Only code inside fom_apply_<name> goes through this check, so these rules keep
every coefficient there. A rewrite that breaks one is discarded:
- the solver may not read the
FOM_MAT_<name>data of a matrix it multiplies by; it callsfom_apply_<name>. This rule already holds at translation (make verifyapplies it there too), so the code optimization phase starts from a solver that multiplies only through the checked routines. Infom_ops.c, a routine isfom_apply_<name>with every helper it calls (on CUDA, its__global__kernel); it may read only its own matrix's data, no other code (a macro, static data, an unreached function) may read matrix data, and every function but thefom_apply_<name>andfom_fused_<block>entry points must bestatic. A fused routine may read its members' data, and nothing infom_ops.cmay read the schedule; - the solver and
fom_ops.cmay not add floating-point constants other than0,0.5,1and2: decimal or hexadecimal float literals, or integer literals cast to a floating type (coefficients come fromfom_data.c). The exception is afom_apply_<name>,fom_fused_<block>, step,__restor iterate routine thatmake verifyprobes on every unit vector: those probes recover its matrix exactly, so it may hold its coefficients as literals (an unrolled product over the fixed sparsity, with the values inlined). A routine over the probe budget, checked on random vectors only, and the solver keep the rule; fom_setupandfom_update_params, and any function of the solver they reach, may not add uses offom_apply_*,fom_fused_*,fom_step_*orfom_iterate, which is how a matrix would be rebuilt by probing it. This also rules out hoisting a product that depends only on the parameters out of the iteration and intofom_update_params: a syntactic rule cannot tell a parameter vector from a unit vector, and the guarantee is worth more than that speedup;- every function the rules inspect must be a single plain C definition they can
find; one they cannot find, or one defined twice (in
#ifbranches, say), is a violation, not a skipped rule.
Literals and setup calls the translated file already had are exempt; in
fom_ops.c, per routine (and outside the routines, only against what the
translated file had outside them). The literal rule is syntactic: an integer coefficient
(3 * x[j]) or one computed from allowed constants gets past it, and the
numerical checks (exact for fom_apply_<name>, sampled for the solver) are what
catch those. make verify reports these rules while the agent works; the
pipeline applies them again when it collects the rewrite, and that check
decides.
OPTIMIZATIONS.md records the outcome for the shipped fom_ops.c:
| matrix | shape | nnz | rewritten | check | probes | max error |
|---|---|---|---|---|---|---|
| `t0_state` | 140x228 | 228 | no | basis | 230 | 0.000e+00 |
| `su_state` | 228x228 | 228 | yes | basis | 230 | 0.000e+00 |
A routine counts as rewritten when its body, a helper it reaches, or the file
scope (macros, static data) changed. The table is followed by any rule
violations found in the collected rewrite. result.kernel_checks holds the same
rows.
The large-scale CUDA target follows the same policy where make verify runs on
a GPU (detected with nvidia-smi, or forced with FOMTOOLS_CUDA_RUN=1):
fom_ops.cu and fom_linsolve.cu are editable, and the direct checks stage
each probe through device buffers and compare the result with the same host
reference. The device copies of the matrices live in the package-owned
include/fom_ops_data.cuh, and the objective in src/fom_objective.cu, so a
rewrite cannot change either. Without a GPU, verify only compiles, so only
fom_solver.cu is editable there; its rules still hold from translation on,
and make compile applies them.
Linear solves: factorized, never inverted¶
Affine operators (e.g. the KKT solve inside ADMM/OSQP) are never turned into
a matrix inverse. At codegen time each system M w = rhs is factorized once —
sparse LDLᵀ for symmetric systems (the ADMM/OSQP KKT is symmetric
quasidefinite), LU otherwise — and the generated solver applies
division-free triangular solves (the factor diagonals are stored as
reciprocals). The factors live in src/fom_linsolve.c and are called as
fom_lin_solve_<name>(rhs, out); the iteration code assembles the right-hand
side and calls them. CodegenConfig(solve_method=...) can force LDLT/LU;
CG (an indirect method for large/GPU problems) is reserved for a future
backend. The choice can depend on problem type and the CPU/GPU target — but a
matrix inverse is never formed or stored. The LDLᵀ factorization (by QDLDL)
uses a fill-reducing approximate-minimum-degree ordering so the factor stays
sparse (on the MPC KKT, 552 vs 19600 dense entries).
Likewise, every matrix @ vector product in the iteration is emitted as a
sparse CSR operation (fom_apply_<name> in fom_ops.c, over CSR arrays in
fom_data.c) when the matrix is
structured — the OSQP/MPC selection, dynamics, and state-update blocks are highly
sparse — and dense only when the matrix genuinely is. Together these make the
initial generated solver fast natively (no reliance on the code optimization phase
to recover structure): on the MPC example the un-optimized C solver already runs
~29× faster than the NumPy reference.
Iteration-varying coefficients¶
Accelerated methods use a coefficient that changes every iteration: FISTA's
momentum γ_k = (β_{k-1} - 1)/β_k, Nesterov's (k-1)/(k+2), a Halpern anchor
weight. Verification handles these for free — the tape is unrolled, so each
iteration just gets its own number.
Generating the recorded run keeps that property, so nothing special is needed. Write the coefficient in Python, where the rest of your algorithm already lives:
vp = ft.Verifier(method="simulator", obbt_solver=None)
b_param = vp.parameter(m, lb=b - 1.0, ub=b + 1.0)
y0 = vp.iterate(n, lb=2, ub=2)
y_prev, y_curr, beta = y0, y0, 1.0
iterates = []
for _ in range(K):
beta_next = (1.0 + np.sqrt(1.0 + 4.0 * beta * beta)) / 2.0
gamma = (beta - 1.0) / beta_next # ordinary Python, evaluated while recording
extrap = y_curr + gamma * (y_curr - y_prev)
y_next = vp.soft_threshold(At @ extrap + Bt @ b_param, lambd=t)
y_prev, y_curr, beta = y_curr, y_next, beta_next
iterates.append(y_next)
vp.codegen("out/fista", iterates=iterates, name="fista")
Because the momentum genuinely varies, this run will not roll back up: each iteration keeps its own coefficient blocks. The cost is one emitted block per iteration, which is fine at a horizon of tens and impractical at a horizon of hundreds.
A schedule buys back the compact loop. You declare which scalars vary,
FOMTools tabulates them at generation time, and the emitted loop reads the table
at its counter — one block total whatever the horizon, still division-free and
with no libm call. Two things differ: you supply the recording as a function
of the schedule scalars rather than a finished tape, and the state carries both
iterates the method needs:
import numpy as np
from fomtools.codegen import CodegenConfig, Recording, TerminationSpec, generate_code
import fomtools as ft
def momentum(k):
"""FISTA's γ in the 0-based index of the emitted loop.
Note the `k + 1`: γ_0 is the coefficient the *first* iteration applies, and it
is 0. Stopping one step short is the easy mistake, and it silently shifts the
whole schedule.
"""
beta = 1.0
for _ in range(k + 1):
beta_next = (1.0 + np.sqrt(1.0 + 4.0 * beta * beta)) / 2.0
gamma, beta = (beta - 1.0) / beta_next, beta_next
return gamma
def build(gamma):
vp = ft.Verifier(method="simulator", obbt_solver=None)
b_param = vp.parameter(m, lb=b - 1.0, ub=b + 1.0)
y_prev = vp.iterate(n, lb=2, ub=2)
y_curr = vp.iterate(n, lb=2, ub=2)
extrap = y_curr + gamma * (y_curr - y_prev)
y_next = vp.soft_threshold(At @ extrap + Bt @ b_param, lambd=t)
# the state shifts: (y_prev, y_curr) <- (y_curr, y_next)
return Recording(vp, next_state=[y_curr, y_next], state=[y_prev, y_curr])
generate_code(
build, None, "out/fista",
schedules={"gamma": momentum},
config=CodegenConfig(termination=TerminationSpec(max_iters=200)),
)
A schedule is a callable of the 0-based loop index, a sequence, or a
Schedule. Textbook formulas are written in 1-based k, so shifting them is your
job: what a coefficient is belongs to the method, not to the code generator, and
nothing in the package knows any particular one. as_schedule calls a callable
with increasing indices, so a coefficient defined by a recurrence may cache it.
Why a build function¶
A schedule scalar enters the recorded expression linearly, so every extracted
coefficient block is affine in it: M(γ) = M₀ + γ M₁. FOMTools evaluates
build at a few values of γ, recovers M₀ and M₁ by finite differences, and
holds one probe back to check the fit. The generated code then computes
M₀ @ state + γ_k * (M₁ @ state), one extra matrix-vector product per varying
term.
That validation is what makes this safe rather than merely convenient. If a
coefficient does not depend affinely on the scalar — a product of two
schedules, or a scalar used inside a nonlinearity — the held-out probe fails and
you get a ScheduleFitError instead of a wrong solver.
Declaring the state¶
Written over a single iterate, the dependency cone of iteration k reaches back
through every earlier step, so extraction returns the unrolled prefix rather than
a repeatable body. On the codegen path that is the intended answer. On the
scheduled and codegen_rolled paths it is not what you asked for, and a longer
body is still a valid body, so declaring state=[y_prev, y_curr] closes the cone
after one step. Worth remembering for any method that looks back more than one
iterate.
What is not supported¶
The varying quantity has to be a linear-operator coefficient. These each raise a clear error rather than lowering something wrong:
- a factorized system matrix that varies (an ADMM
ρthat changes withk), since the factorization is computed once at generation time; - a varying activation scalar — a soft-threshold level or a saturated-linear
bound that changes with
k; - a varying constant offset;
- a scalar the build function ignores, which is nearly always a typo in its keyword name.
The table is sized to max_iters. Raising settings.max_iter past that at
runtime holds the last value rather than reading off the end; the generated
accessor fom_sched_<name>(it) clamps, and the static checker rejects any solver
body that indexes FOM_SCHED_* directly.
Targets¶
CodegenConfig selects what to emit:
- Embedded (
version="embedded",backend="c"): statically sized, no dynamic allocation, division-free (matrix inverses are folded into the coefficients offline). The default. - Large-scale (
version="large_scale"): multithreaded CPU (backend="openmp") or GPU (backend="cuda"). These are generated and compile-checked; runtime verification of the CUDA backend requires a GPU.
CLI¶
fomtools-codegen --example ista --out out/ista --driver agent
fomtools-codegen --example dr --out out/dr --scaffold-only
fomtools-codegen --example mpc --out out/mpc --driver agent # OSQP MPC, larger
fomtools-codegen --example fista --out out/fista --scaffold-only # a recorded run
fomtools-codegen --example scheduled --out out/sched --scaffold-only # a declared coefficient
fomtools-codegen --module my_fom.py --out out/mine --tol 1e-6 --objective # stop on the objective
The built-in mpc example is a model-predictive-control QP solved with OSQP's
ADMM — a constant KKT solve (the inverse is folded into the coefficients at
codegen time) followed by a box projection (saturated-linear) and the ADMM
recombinations. It is the largest built-in example (state dimension in the
hundreds), so it is a good showcase for the Python-vs-C and the optimization-loop
speedups.
fista and scheduled are the same accelerated lasso reached the two ways.
fista records it as an ordinary loop, so scaffolding it shows an unrolled
SPEC.md: one block per recorded iteration, FOM_UNROLLED 1, and no table.
scheduled declares the momentum instead, so it shows a single block, the
FOM_SCHED_gamma table in fom_data.c, and the clamping accessor. Scaffolding
both side by side is the quickest way to see what declaring a coefficient buys.
--scaffold-only emits everything except the solver body (useful to inspect the
spec and data before invoking the agent). --no-roll keeps a recorded run
unrolled even when it could be rolled back up. --translate-timeout,
--optimize-timeout (seconds), --optimize-max-turns, --optimize-rounds and
--optimize-min-gain set the agent's budgets for this run (above). --tol alone stops on the
infinity-norm change; with --objective it stops on the verification objective
the module set on its recording, and --check-termination N sets the interval.
A custom FOM can be supplied with --module mymodule.py, where the module defines
build_fom() returning a dict. Which key you provide selects the path:
| keys | generates |
|---|---|
verifier, iterates |
the whole recorded run, rolled back up when that is verified to be equivalent |
verifier, next_state |
a rolled solver from one stationary iteration |
build, schedules |
a rolled solver with tabulated iteration-varying coefficients |
name, state and params are optional in all three.