Process performance and reusable derivatives¶
Fugacio separates physical acceptance from performance observations. The structured solvers solve the same residual equations as their dense references. A short runtime doesn't establish convergence, derivative accuracy, or measured qualification. Failed runs and resource limits remain visible in saved evidence.
Compile-once kernels¶
JAX compiles a program the first time it sees a new structure, and that compilation, not the arithmetic, dominates the first call to a process calculation. Fugacio arranges its calculations so a structure compiles once:
- Every unit operation in
fugacio.sim.unitsis one compiled kernel. The property package and every operating value (temperatures, pressures, duties, efficiencies, split fractions) are dynamic arguments, so a unit compiles once per package structure and specification kind. A new operating point, or new package parameters such as fitted NRTL coefficients, reuses the executable. Changing the specification kind (a heater ont_outinstead ofduty) or the package structure (another component count or method) compiles a new kernel. - Property-package solve methods (
flash_pt,stability, the bubble and dew calls,flash_ph,mixture_enthalpy, and so on) run through kernels cached per method and static options, with the package as a dynamic argument. A package whose pytree holds a non-array leaf, such as an unregistered activity-model object, runs eagerly instead. Flowsheetcaches each recycle block's map. Concrete iterations reuse the compiled unit kernels; an explicitly staged iteration also retains its function identity across operating points. Registering a unit or a tear clears the structural cache.- Saved-case unit templates are shared by every
CaseRunnerin a process, keyed by the template's fixed settings, so another case with the same unit structure reuses them.
A new process compiles again. To keep compiled programs on disk and reuse them across processes (a CLI run, a sweep worker, a notebook restart), enable JAX's persistent compilation cache before the first calculation:
from fugacio.sim import enable_compilation_cache
enable_compilation_cache(".jax_cache") # created if missing; returns the resolved path
By default it stores every program, however quickly it compiled, because
process kernels are small individually but numerous; pass
min_compile_time_s to store only slower ones. A later call also works: the
cache is reinitialized so the next compilation uses it. The cache is keyed by
the JAX version, the backend, and the exact program, so a stale entry is never
reused for a different computation. It grows with every distinct program, so
it's opt-in. The CLI enables it with --jax-cache DIR on run, sweep,
optimize, sensitivities, profile, and replay, or with the
FUGACIO_JAX_CACHE environment variable:
uv run fugacio run examples/process-cases/depropanizer.json --jax-cache .jax_cache
export FUGACIO_JAX_CACHE="$PWD/.jax_cache" # or set it once for every command
A warm cache reduces compilation time, not the peak memory each compilation needs.
Numerical choices¶
from fugacio.sim.cases import CaseRunner, SolverOptions
from fugacio.sim.cases.examples import example_case
runner = CaseRunner(
example_case("depropanizer"),
options=SolverOptions(
recycle_method="broyden",
column_solver="block",
plant_solver="sparse",
),
)
structure = runner.diagnose_structure()
run = runner.run(check=True)
column_solver="block" is the default for saved cases. The direct
rigorous_column API calls this option linear_solver. Set either option to
"dense" to use the reference assembly and solve. plant_solver="sparse"
uses local unit assembly and sparse CPU factorization. "dense" selects a
small reference matrix. These choices are recorded in runs independently of
case identity. Old numerical-option replay defaults have been removed.
Rigorous columns¶
For N stages and C components, each stage has b = 2C + 1 unknowns. The condenser and reboiler add a border of size k, at most two. A stage's MESH equations depend on its own and adjacent stages. Specifications form the unrestricted border. Total-condenser and reboiler duties exchange coordinates with endpoint flow variables so the local blocks can be eliminated. This permutation doesn't change any physical equation or specification.
Three stage colors assemble the local Jacobian in at most 3b + k forward directions, plus k reverse directions for the specification rows. A three-component, 16-stage column has 114 unknowns but needs 23 forward and two reverse directions. Increasing it to 64 stages gives 450 unknowns with the same direction count. Storage for the normal block representation grows linearly in stage count, with fixed component count.
Block elimination pivots within each stage. Every solve checks its linear
residual against the original structured matrix. It records the infinity-norm
backward error, norm(Ax - b) / (norm(A) * norm(x) + norm(b)), and also requires
norm(Ax - b) / norm(b) to pass. Each right-hand side has its own scale and an
underflow guard. The latter check catches inaccurate Newton directions whose
backward error looks small because the solution has very large components.
Both checks use complete vectors, so tiny individual equations and exactly
zero solution components don't impose spurious componentwise relative accuracy.
They keep adjoint acceptance independent of whether a metric is expressed in
watts or megawatts. If the block result is nonfinite or inaccurate, a pivoted
dense solve is checked against the same equations. An unresolved result returns
nonfinite sensitivities. The fallback
can still require quadratic storage; this isn't a guarantee of bounded memory
for every ill-conditioned system.
Forward and transposed solves use implicit differentiation. Pivot decisions, iteration histories, and warm-start selection don't carry derivatives. The converged matrix and factors can be retained by a local linearization for later directions. Tests compare dense and block assembly, cross-block pivot fallback, material and energy balances, finite differences, and higher derivatives.
result.solver_info() describes the selected strategy and direction count. It
isn't a count of actual fallback events. A direct
BorderedBlockJacobian.solve_with_info call returns its actual fallback flag,
backward error, and relative residual. Full stage component flows are retained
for exact warm starts, including columns with side products.
Flowsheet assembly and diagnostics¶
The shared process runtime preserves local unit compilation during concrete process iterations and differentiation. Both sequential and simultaneous saved-case execution use one process graph. Native EO built-ins call the same physical kernels; custom residual blocks use local sparse assembly with declared dependencies.
The process Jacobian stores outlet identities and local unit input blocks. Forward and transposed solves use checked sparse CPU LU with bounded factor reuse. There is no automatic dense process fallback. Design specifications add a small border to this system, so specification trials evaluate unit and metric equations without repeatedly solving the complete plant.
CaseRunner.diagnose_structure() reports process_graph, including stored
coefficients, scalar coordinates, matching, and specification variables.
EOFlowsheet.diagnose_structure() reports custom equation incidence, and
diagnose() adds a state-dependent dense rank diagnostic. Matching alone
doesn't establish a solvable or physically accepted state.
Column internals retain structured MESH solves. The connection matrix doesn't expand their stages. Parameter and feed derivatives pass through the same local kernels, and numerical seeds never carry derivatives.
Case runners in one process share compiled unit templates when their numerical structure and fixed settings match. Each instance binds its own parameter values; renaming units, streams, or parameters doesn't require another executable. Scalar inputs keep their precision but use consistent JAX scalar types across cold starts, recycles, and derivative replays. Structural diagnostics report the instance and template counts.
Derivative studies¶
Sensitivities and optimization build one local plant linearization per operating
point. They reuse it across derivative directions and retain the compiled unit
boundaries. derivative_mode="auto" chooses forward mode when there are no more
inputs than outputs, and reverse mode otherwise. The choice minimizes direction
count; it isn't a hardware-specific timing prediction. Explicit "forward" and
"reverse" modes support comparisons and unusual models.
from fugacio.sim.cases import sensitivities
from fugacio.sim.cases.examples import heater_bank_case
bank = CaseRunner(heater_bank_case(24))
study = sensitivities(
bank,
list(bank.parameters),
["duty"],
derivative_mode="auto",
derivative_batch_size=1,
)
assert study.accepted
assert study.artifact["derivatives"]["directions"] == 1
The default batch size of one limits tangent storage. Larger batches may trade memory for throughput. Each new operating point gets a new linearization; there's no global point cache that could return a stale derivative. Study artifacts record the selected orientation, dimensions, and direction count. Finite differences still use independently audited perturbations, and an optimization's final candidate still starts cold and receives its physical audit.
For array-valued numerical kernels, fugacio.thermo.sensitivity.linearize
exposes the reusable map directly:
import jax.numpy as jnp
from fugacio.thermo.sensitivity import linearize
local = linearize(lambda x: jnp.sum(jnp.sin(x)), jnp.array([0.2, 0.5]))
value = local.value
direction = local.jvp(jnp.array([1.0, 0.0]))
adjoint = local.vjp(jnp.array(1.0))
jacobian = local.jacobian(mode="reverse")
The map belongs to that point and the model captured by the function. Release
it when those inputs change to release its retained residuals. has_aux=True
keeps numerical reports separate from differentiated outputs.
Recorded performance observations¶
profile saves timings in a separate content-addressed artifact. Ordinary run
identities don't acquire timestamps or timing fields.
from fugacio.sim.cases import CaseWorkspace, profile
observed = profile(
runner,
parameters=["pressure", "approach"],
metrics=["annual_cost"],
warm_repeats=2,
workspace=CaseWorkspace(".fugacio-cases"),
)
The artifact records the first and repeated audited runs, first and repeated
linearizations, Jacobian applications, environment, solver configuration, and
process peak RSS. Operations synchronize device results before stopping their
timers. RSS is the process-lifetime high-water mark, including compiled
executables; it isn't a per-phase allocation count. A profile's finite
derivatives and repeatability don't replace the independent finite-difference
checks in sensitivities.
The Python sensitivities and optimize APIs also accept a
PerformanceRecorder through recorder. It measures each linearization and
Jacobian application while keeping timings outside ordinary study identities.
The standalone benchmark includes these phases in its progress checkpoints.
The CLI accepts fugacio profile CASE REQUEST.json, and the design copilot's
study_case accepts kind="profile". Its diagnose_case tool inspects
structure without creating a trusted run. Solver options and study derivative
options are available through the same CLI and copilot routes as ordinary runs.
Measured behavior¶
Shared runtime observations¶
The September 23, 2026, observations in
benchmarks/shared-process-runtime.json retain source digests, solver settings,
phase timings, study results, and independently audited runs. Each study uses a
fresh process and retains JAX kernels across baseline, derivative, and audit
phases. The Linux container has four CPUs, an 8,000,000,000-byte hard memory
limit, no additional swap allowance, and MALLOC_ARENA_MAX=2.
GB means 1,000,000,000 bytes.
| Workload | Environment | Disk cache | Total, seconds | Peak RSS, GB |
|---|---|---|---|---|
| 12-stage NRTL train sensitivities | Linux, JAX 0.10.1 | Empty | 1,096.2 | 7.920 |
| 16-stage depropanizer optimization | Linux, JAX 0.10.1 | Empty | 367.8 | 4.795 |
The NRTL train passes all seven physical audits and all six finite-difference comparisons. Its largest derivative relative error is 0.00000908. The process matrix has 30 connection unknowns and 210 stored coefficients; the column retains its own structured stage solve. The audited baseline peaks at 3.308 GB, and the first complete linearization peaks at 7.920 GB. Its compilation remains the dominant cost: 712.0 seconds, followed by 66.1 seconds to apply the Jacobian. This observation leaves little headroom below 8 GB; it doesn't establish that every host, allocator, or larger process will fit that budget.
On September 29, a GitHub runner exceeded the same 8 GB budget during the
first NRTL linearization, reaching 8.067 GB. The workflow now fixes glibc's
mmap and trim thresholds as described below. A fresh Ubuntu 24.04 container
with Python 3.12.3, JAX 0.10.1, four assigned CPUs, and the same hard memory
limit completed the full study in 1,762.6 seconds at 7.883 GB. All seven
physical audits and six finite-difference comparisons passed, with the same
maximum relative error of 0.00000908. benchmarks/ci-memory-regression.json
preserves both the failed CI observation and this accepted local run. The
remaining headroom is small, and different hosts aren't a controlled comparison
of allocator effects or elapsed time.
The allocator-only update still exceeded the CI limit at 8.030 GB. Exchanger energy-balance reports now detach their inputs before evaluating diagnostic enthalpies. Previously, eager differentiation could linearize those property calls before discarding their derivatives in the finished report. Both energy balances and their acceptance tolerances are unchanged; physical exchanger outputs retain their derivatives. The second CI failure is also retained in the regression record.
The depropanizer optimization completes six SLSQP iterations and passes its final cold-start physical audit. The first linearization takes 229.4 seconds; later linearizations settle near 3.1 seconds, with Jacobian applications near 0.54 seconds. Peak RSS remains near 4.795 GB across the later optimizer points. Separate compile-event regression tests verify kernel reuse across changed operating points on JAX 0.4.38 and 0.10.1.
Peak RSS uses the largest retained process observation, including samples after the study's final timer. The runs shared their physical host with other validation work, so elapsed times include that contention. Stage counts, cache states, and platforms are recorded separately for each observation.
Earlier observations¶
The September 9, 2026, observations are recorded in
benchmarks/process-performance.json, including source digests, environments,
phase timings, acceptance checks, and failures. The following measurements
used Linux, JAX 0.10.1, float64, four CPUs, and MALLOC_ARENA_MAX=2. Each row
used a fresh process and empty persistent cache. A macOS NRTL study shared
the physical host during these runs, so elapsed times aren't isolated hardware
comparisons. GB means 1,000,000,000 bytes.
| Workload | Cold total, seconds | Peak RSS, GB | Acceptance evidence |
|---|---|---|---|
| 32-stage binary column, block | 114.8 | 1.392 | Balances and finite differences |
| 32-stage binary column, dense | 94.5 | 1.109 | Balances and finite differences |
| 64-stage binary column, block | 110.7 | 1.408 | Balances and finite differences |
| 24 independent heater variables | 43.5 | 0.861 | Audited runs and repeatable reverse derivatives |
The 32-stage column outputs agree between algorithms. The block assembler uses 17 forward and two border reverse directions for 162 unknowns, and keeps that direction count at 64 stages and 322 unknowns. Its warmed derivative executions took 14–15 ms at 32 stages, compared with 25–26 ms for dense, and 105–106 ms at 64 stages. Dense compilation was faster for the 32-stage case. The 64-stage block solve also agrees with its dense reference and converges in seven Newton iterations. All column finite-difference relative errors were below 0.00003. These observations don't establish a universal crossover or a 3x cold-gradient speedup against v0.6.0. Dense fallback can change the cost of a structured solve, so direction counts alone don't predict runtime.
The heater bank uses one compiled template for 24 independently bound temperatures and one reverse direction for its total-duty derivative. The profile checks finite, repeatable derivatives; separate regression tests check the template bindings against independently audited finite differences.
The full depropanizer optimization passed in a Linux container with a hard
7,000,000,000-byte memory limit and no swap allowance, using JAX 0.10.1,
four CPUs, and MALLOC_ARENA_MAX=2 at process startup. Its cold total was
832.9 seconds and peak RSS was 6.590 GB. SLSQP
completed six iterations; every point had accepted numerical derivatives,
and the final candidate passed its independent cold-start physical audit.
Modeled annual cost fell 7.9% and reboiler duty fell 10.6%, with propane purity
at 95% and recovery at 98%. These are screening-economics results for the
saved case, not measured operating savings.
The first plant linearization took 506.0 seconds and its Jacobian application took 43.3 seconds. Subsequent linearizations took 3.1–3.8 seconds and Jacobian applications took 0.8–0.9 seconds. Neither other hosts nor additional study outputs inherit a 7 GB guarantee. The default-allocator cold run reached 7.006 GB at its fifth optimizer point; warm-cache runs also exceeded 7 GB. Failed resource observations remain in the benchmark record. A warm persistent cache can reduce compilation time without reducing peak resident memory.
Measurements before the shared graph¶
Checked solves, retained reports, and per-unit kernels enlarge the compiled process graph, so the same studies need more compile memory and time than they did before the engine reported every failure. Measured on the same CI runner, same case, and same JAX 0.10.1: the depropanizer optimization's audited baseline run went from 2.533 GB and 88 seconds to 3.148 GB and 139 seconds, and its first plant linearization from 6.104 GB to more than 7 GB, which is why the earlier job used a 9 GB limit. The 12-stage ethanol/water train's audited run went from 5.984 GB and 379 seconds to 8.041 GB and 654 seconds, and its first linearization no longer fits a 16 GB runner at all (it passed 12 GB before completing), so the earlier workflow skipped that benchmark on pull requests. Shrinking the column didn't recover it: at eight stages the same train had to work harder for the same distillate rate, and its audited run peaked higher still, at 10.494 GB. That implementation completed its depropanizer optimization in 639 seconds at 8.043 GB, within its 9 GB limit. A local comparison on one macOS host attributed the increase to the process graph rather than to the retained kernels: the same depropanizer solve moved from 3.794 GB and 122 seconds to 4.758 GB and 158 seconds, while its per-unit kernels reduced peak memory (4.755 GB with them, 4.987 GB without), and the heat exchanger's compiled program is about three times smaller yet still costs 24% more memory to build.
The Linux memory-budget workflow limits glibc's allocation arenas and fixes
its mmap and trim thresholds to reduce retained allocation memory. Set
MALLOC_ARENA_MAX=2, MALLOC_MMAP_THRESHOLD_=131072, and
MALLOC_TRIM_THRESHOLD_=131072 before starting Python to reproduce that
runtime configuration. Fixing the thresholds disables glibc's adaptive
increases, allowing freed compiler buffers to return to the operating system.
It doesn't clear JAX caches or change the process equations or their acceptance
tolerances. Profiles record all three settings alongside thread and XLA
settings. See the
glibc allocation tunables.
The separate two-parameter, two-output depropanizer sensitivity study passed all four independent finite-difference comparisons. With an existing cache, it took 270.8 seconds and peaked at 7.654 GB. Its container allowed 7.8 GB of RAM and 9 GB of RAM plus swap, within a local VM with about 8.2 GB of RAM and 1.1 GB of swap. That observation uses a larger allowance than the optimization target; it isn't evidence that every study fits within 7 GB.
The 12-stage ethanol/water NRTL train passed all seven physical audits and all six finite-difference comparisons across approach temperature, pressure, and reflux. The final numerical implementation took 2,184.5 seconds with an existing persistent cache on macOS with JAX 0.4.38 and peaked at 9.246 GB. Its largest derivative relative error was 0.000055. An earlier cold run passed in 2,820.2 seconds at 8.234 GB, before the linear acceptance check changed from rowwise to global normwise scaling. The benchmark record labels those implementations separately; these aren't a controlled cold/warm comparison. Both observations use the train's separate 12 GB budget and existing curated thermodynamic data.
Reproducible benchmarks¶
The benchmark runner starts a fresh subprocess and uses an empty persistent cache in cold mode. It samples peak RSS every 0.2 seconds and stops a worker above its declared budget. This sampled limit can overshoot briefly. The parent also checks the completed worker's high-water mark, so an over-budget final allocation can't pass between samples. The parent enforces elapsed time and records termination, including the last completed phase. On Linux, it also records available cgroup memory-event counters, which can identify an OOM kill before the worker's watchdog records its own peak. A successful benchmark requires accepted numerical/physical evidence and completion within the resource limits.
# Linux/glibc settings used by the memory-budget workflow.
export MALLOC_ARENA_MAX=2
export MALLOC_MMAP_THRESHOLD_=131072
export MALLOC_TRIM_THRESHOLD_=131072
uv run python scripts/benchmark_process.py \
--scenario column --stages 32 --column-solver block \
--output artifacts/performance/column32-block
uv run python scripts/benchmark_process.py \
--scenario column --stages 32 --column-solver dense \
--output artifacts/performance/column32-dense
uv run python scripts/benchmark_process.py \
--scenario depropanizer --study optimization \
--max-rss-gb 8 --timeout-seconds 3000 \
--output artifacts/performance/depropanizer-optimization
uv run python scripts/benchmark_process.py \
--scenario ethanol-train --stages 12 --study sensitivities \
--max-rss-gb 8 --timeout-seconds 3000 \
--output artifacts/performance/ethanol-train
uv run python scripts/benchmark_process.py \
--scenario heater-bank --variables 24 \
--output artifacts/performance/many-variable
Output directories must be empty. They retain the report, incremental progress,
worker log, cache, and any audited case artifacts. To measure persistent-cache
reuse, select --cache warm --cache-dir PATH_TO_PRIOR_CACHE and a new output
directory. Reports include the revision, dirty-tree status, and a source digest
covering code, bundled model data, and dependency declarations, captured before
starting the worker. Benchmark a stable checkout and avoid
competing workloads when comparing timings.
--release-caches applies the study API's explicit cache-release option
between baseline, derivative, and final-audit phases. Optimization retains its
compiled kernels across design points and collects unreachable Python objects
after each completed point. The host value/Jacobian pair remains cached for
repeated optimizer callbacks at that same point. Clearing compiler caches
between individual points increased both compilation time and peak memory in
the Linux depropanizer experiment. The option retains the persistent cache and
recorded runs. The shared-runtime CI studies leave it disabled to check
retained-kernel memory across the complete study.
The standalone column benchmark separates tracing and lowering, compilation, first execution, and repeated execution for both the primal and derivative kernels. Its derivative is compared with centered finite differences. Modular plant profiles report their actual phase boundaries; the first plant linearization can compile individual units, so its time mustn't be labeled pure execution time.
The process-performance CI workflow runs 32- and 64-stage columns, the saved depropanizer optimization, the NRTL train, and a 24-variable study in separate jobs on pull requests and manual dispatch. The columns and heater bank have a 7 GB process limit; both complete plant studies have an 8 GB target. These are enforced budgets, not guarantees for other processes, hosts, or JAX versions. All Linux jobs use the three glibc settings shown above at process startup; earlier observations retain their original runtime settings. Select a host with additional memory for the runner and operating system; private forks may have different runner resources. See the GitHub-hosted runner specifications. The workflow preserves failed observations. Timing observations are hardware dependent; the CI gates completion and correctness, not a universal speedup ratio. The NRTL train uses existing curated parameters and doesn't expand the measured qualification matrix.