Skip to content

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.units is 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 on t_out instead of duty) 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.
  • Flowsheet caches 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 CaseRunner in 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.

uv run fugacio diagnose examples/process-cases/depropanizer.json

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.