Skip to content

perf(VTPR): cache T-only UNIFAC tables + densify group arrays (~6-7x for multicomponent flash) - #3276

Open
andr1976 wants to merge 3 commits into
CoolProp:masterfrom
andr1976:fix/vtpr-unifac-set-temperature-cache
Open

andr1976 wants to merge 3 commits into
CoolProp:masterfrom
andr1976:fix/vtpr-unifac-set-temperature-cache

Conversation

@andr1976

@andr1976 andr1976 commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the catastrophic slowdown of VTPR mixture flashes with component count (issue #3275). A 6-component natural-gas two-phase PT flash went from ~2.7 s to ~0.43 s (~6×); a single-phase flash from ~0.5 s to ~0.07 s (~7×). Results are bit-for-bit identical — this is pure memoization + a data-structure change, no math change.

Root cause

Profiling showed ~84 % of the flash in UNIFAC::UNIFACMixture::set_temperature, which rebuilt the entire temperature-dependent UNIFAC table on every call using std::map<pair,double> lookups, and was invoked O(N²) times per flash iteration through the fugacity-composition-derivative Jacobian. The temperature-cache guard was present but commented out.

Changes

  1. Cache the T-only tables (Ψ interaction table + pure-component reference ln Γ). A flash holds T fixed across its density-solve iterations and its O(N²) composition-derivative evaluations, so these are recomputed only when T changes (re-enabled the guard).

  2. Cache the mixture group residual ln Γ (m_lnGammag) on (T, composition). gE_R_RT evaluates ln_gamma_R for every component at the same (T, composition), invoking set_temperature from each, so this O(G³) block was rebuilt N× per gE evaluation. It is invalidated whenever the composition changes — set_mole_fractions is the sole writer of the group surface fractions it depends on, so the cache is invalidated exactly when its input changes. This is safe across a phase split: every liquid/vapour trial composition routes through set_mole_fractions, and SatL/SatV are separate instances with independent caches.

  3. Replace the std::map group structures with dense arrays indexed by a compact group index built once in set_pure_data. The compact index preserves the ascending-sgi ordering of the previous std::map iteration, so summation order — and therefore results — are unchanged.

Results

6-component NG single flash before after
single-phase gas ~502 ms ~72 ms
two-phase ~2665 ms ~426 ms

VTPR/HEOS ratio at N=6 drops from ~57× to ~5×, and VTPR is now faster than HEOS for N ≤ 3. Total instruction count 3.24 B → 760 M.

Correctness / tests

  • Bit-identical verified before/after on methanol-water (bubble P 24669.872 Pa exact) and a 6-component NG mixture (bubble/dew match exactly at every T) — the NG case exercises asymmetric Ψ and many group pairs, ruling out an index transposition.
  • Adds the first VTPR mixture test (CoolProp-Tests-VTPRMixture.cpp, [VTPR][cubic][mixture]). CoolProp bundles no UNIFAC parameters, so it embeds a minimal methanol-water UNIFAC dataset, writes it to a temp dir, and loads it via VTPR_UNIFAC_PATH; the asserted bubble pressure is a regression value guarding the group-activity math. Config is restored on scope exit.
  • [cubic] (3694 assertions) passes; preflight green (clang-format, build, tests, cppcheck, clang-tidy, semgrep).

Remaining (separate follow-up)

The dominant remaining cost is the finite-difference temperature derivatives in ln_gamma_R (itau > 0), which evaluate at perturbed T (tau ± dtau) and so miss the T-cache. Replacing them with analytical τ-derivatives of ln Γ would remove it and is a distinct piece of work.

Closes #3275.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Improvements

    • Enhanced UNIFAC mixture calculations with more efficient dense data structures and compact-group indexing.
    • Improved responsiveness to composition/temperature changes using updated caching and table-validity handling.
  • Testing

    • Added a new VTPR methanol–water integration test (bubble-point regression and blind PT flash), using an embedded/local UNIFAC dataset.
    • Expanded the Catch2 test runner build to include an additional UNIFAC-related test translation unit.

Update: added temperature-keyed cache for the finite-difference τ-derivative path (commit c9e5bab)

The finite-difference temperature derivatives in ln_gamma_R (itau>0, needed by the const-P fugacity Jacobian) evaluate the group tables at perturbed temperatures (tau ± dtau). Those repeat across the per-component loop and the density-solve iterations at fixed (T, composition), but the single-slot cache thrashed on every perturbation. Replaced it with a temperature-keyed cache of the fully-computed group tables — a hit restores them with a few small vector copies instead of a rebuild. Cleared on composition/group change (and, since the Jacobian is analytical, composition changes only per trial phase, so the perturbed-T tables are reused across the whole density solve). A size cap bounds memory.

Still bit-for-bit identical (methanol-water and 6-component NG bubble/dew match exactly). Adds ~1.3× on top of the previous work → ~8× cumulative vs the original (6-component NG two-phase flash ~2.7 s → ~0.33 s).

The remaining follow-up is now genuinely just the analytical τ-derivatives (an accuracy improvement over the finite differences, which would also remove the perturbed-T evaluations entirely) — a distinct, non-bit-identical piece of work.

…for multicomponent flash)

VTPR mixture flashes scaled catastrophically with component count: a 6-component
natural-gas two-phase PT flash took ~2.7 s versus ~12 ms for HEOS.  Profiling
showed 84% of the time in UNIFAC::UNIFACMixture::set_temperature, which rebuilt
the entire temperature-dependent UNIFAC table on every call using
std::map<pair,double> lookups, and was invoked O(N^2) times per flash iteration
through the fugacity-composition-derivative Jacobian.

Three changes, all producing bit-for-bit identical results (verified: methanol-
water and 6-component NG bubble/dew match exactly before/after):

1. Cache the T-only tables (Psi interaction table + pure-component reference
   ln(Gamma)).  A flash holds T fixed across its density-solve iterations and its
   O(N^2) composition-derivative evaluations, so these are recomputed only when T
   changes.  The guard that did this was present but commented out.

2. Cache the mixture group residual ln(Gamma) (m_lnGammag) on (T, composition).
   gE_R_RT evaluates ln_gamma_R for every component at the SAME (T, composition),
   invoking set_temperature from each, so the O(G^3) block was rebuilt N times per
   gE evaluation.  Invalidated whenever the composition changes (set_mole_fractions
   is the sole writer of the group surface fractions it depends on -- so the cache
   is invalidated exactly when its input changes, including every liquid/vapour
   trial composition during a phase split; SatL/SatV are separate instances with
   independent caches).

3. Replace the std::map group structures (Psi table, per-group and per-component
   group quantities, group counts) with dense arrays indexed by a compact group
   index built once in set_pure_data.  The compact index preserves the ascending-
   sgi ordering of the previous std::map iteration, so summation order -- and
   therefore results -- are unchanged.

Combined: 6-component NG single-flash ~7x faster (single-gas 502 -> 72 ms,
two-phase 2665 -> 426 ms); VTPR/HEOS ratio at N=6 drops from ~57x to ~5x, and VTPR
is now faster than HEOS for N<=3.

Adds the first VTPR *mixture* test (CoolProp-Tests-VTPRMixture.cpp): CoolProp
bundles no UNIFAC parameters, so it embeds a minimal methanol-water UNIFAC dataset,
writes it to a temp dir, and loads it via VTPR_UNIFAC_PATH.  The asserted bubble
pressure is a regression value guarding the UNIFAC group-activity math.

A remaining bottleneck (separate follow-up) is the finite-difference temperature
derivatives in ln_gamma_R (itau>0), which evaluate at perturbed T and so miss the
T-cache; analytical tau-derivatives would remove it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 30d3f14f-8849-46db-9bc2-61f3b7b12877

📥 Commits

Reviewing files that changed from the base of the PR and between c9e5bab and be67243.

📒 Files selected for processing (2)
  • src/Backends/Cubics/UNIFAC.cpp
  • src/Tests/CoolProp-Tests-VTPRMixture.cpp
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/Tests/CoolProp-Tests-VTPRMixture.cpp
  • src/Backends/Cubics/UNIFAC.cpp

📝 Walkthrough

Walkthrough

UNIFAC mixture calculations now use compact dense group storage with temperature and composition cache invalidation. A new VTPR methanol-water test embeds UNIFAC data, validates bubble pressures and two-phase flash behavior, and is included in the Catch2 test runner.

Changes

UNIFAC optimization and VTPR validation

Layer / File(s) Summary
Dense UNIFAC storage contract
src/Backends/Cubics/UNIFAC.h
Component and mixture group quantities change from map-based storage to compact-indexed dense vectors, matrices, and cache state.
Dense group calculations and caching
src/Backends/Cubics/UNIFAC.cpp
Group indexing, composition fractions, pure-component values, residual activity coefficients, and temperature tables use dense arrays with cache invalidation and guarded recomputation.
VTPR mixture regression coverage
src/Tests/CoolProp-Tests-VTPRMixture.cpp, CMakeLists.txt
A temporary embedded UNIFAC dataset supports methanol-water bubble, flash, and pure-endpoint assertions, and the test source is added to the Catch2 runner.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant VTPRFlash
  participant UNIFACMixture
  participant m_T_cache
  VTPRFlash->>UNIFACMixture: evaluate ln_gamma_R
  UNIFACMixture->>m_T_cache: look up tables for temperature
  m_T_cache-->>UNIFACMixture: restore or rebuild dense tables
  UNIFACMixture-->>VTPRFlash: return residual activity coefficient
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main performance work on UNIFAC caching and dense group arrays.
Linked Issues check ✅ Passed The PR implements T-only UNIFAC caching, dense compact-group arrays, and a regression test, matching the linked issue objectives.
Out of Scope Changes check ✅ Passed The only changes are the targeted UNIFAC, test, and CMake updates needed for the performance fix and regression coverage.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Backends/Cubics/UNIFAC.cpp`:
- Around line 296-319: Update UNIFACMixture::set_pure_data() to invalidate all
dependent interaction and activity-coefficient caches after rebuilding group
data, including _T, Psi_, and m_lnGammag_valid, and reset any related cached
values as needed. Update set_Q_k() to reapply the existing composition/mole
fractions after changing Q_k so m_Xg and m_thetag are immediately refreshed when
composition is already set.

In `@src/Tests/CoolProp-Tests-VTPRMixture.cpp`:
- Around line 63-76: The VTPRConfigGuard destructor restores configuration but
leaves the process-global UNIFAC library populated with the test dataset. Extend
VTPRConfigGuard cleanup to reset or reload the global UNIFAC library after
restoring VTPR_UNIFAC_PATH and VTPR_ALWAYS_RELOAD_LIBRARY, ensuring subsequent
tests load their expected data; alternatively isolate the test in a separate
process.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 56d73951-56d1-49f1-addf-b6ab7b6eda35

📥 Commits

Reviewing files that changed from the base of the PR and between a540d91 and f4e80c9.

📒 Files selected for processing (4)
  • CMakeLists.txt
  • src/Backends/Cubics/UNIFAC.cpp
  • src/Backends/Cubics/UNIFAC.h
  • src/Tests/CoolProp-Tests-VTPRMixture.cpp

Comment thread src/Backends/Cubics/UNIFAC.cpp
Comment thread src/Tests/CoolProp-Tests-VTPRMixture.cpp Outdated
Comment thread src/Backends/Cubics/UNIFAC.cpp Fixed
Comment thread src/Backends/Cubics/UNIFAC.cpp Fixed
andr1976 and others added 2 commits July 19, 2026 09:08
…ivative path

Follow-up to the UNIFAC caching + dense-array work.  The finite-difference
temperature derivatives in ln_gamma_R (itau>0, needed by the const-P fugacity
Jacobian) evaluate the group tables at perturbed temperatures (tau +/- dtau).
Those perturbed temperatures repeat across the per-component loop and across the
density-solve iterations at fixed (T, composition), but the previous single-slot
cache held only one temperature and so thrashed on every perturbation.

Replace the single-slot guard with a temperature-keyed cache of the fully-computed
group tables (dense Psi, per-component pure reference ln(Gamma), and the mixture
group residual ln(Gamma)).  A cache hit restores the tables with a few small vector
copies instead of a full rebuild.  All entries depend on the composition, so the
cache is cleared whenever the composition changes (set_mole_fractions) or the group
set changes (set_pure_data) -- and because the flash Jacobian is analytical (not
composition-perturbed), a composition change happens only per trial phase, so the
perturbed-temperature tables are reused across the whole density solve.  A size cap
bounds memory if a caller sweeps many temperatures at fixed composition.

Results unchanged (bit-for-bit): methanol-water and 6-component NG bubble/dew match
exactly.  Adds ~1.3x on top of the previous work (6-component NG two-phase flash
426 -> 332 ms; ~8x cumulative vs the original ~2.7 s).

Also removes the now-unused CachedElement _T member.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- set_pure_data: refresh the mixture group surface fractions (m_Xg/m_thetag) when a
  composition is already set.  set_Q_k() changes Q_k and rebuilds the pure/group data
  via set_pure_data(), but m_thetag (computed in set_mole_fractions and dependent on
  the group layout/Q) was left stale; refresh it from the stored composition so a
  subsequent evaluation is correct.  (No effect on the normal set_components ->
  set_mole_fractions flow, where the composition is not yet set here.)

- VTPRMixture test: the VTPR UNIFAC library is a process-global that the test
  overwrites with its minimal dataset.  Restoring only the config keys would leave
  that dataset resident, so the guard now FORCES reload-on-next-use on exit (rather
  than restoring the old flag), ensuring a later VTPR construction reloads from its
  own path instead of reusing this test's library.

- Document that the exact floating-point comparison in set_temperature's fast path
  (and the std::map<double,...> key lookup) is an intentional cache-key match, not a
  physical tolerance -- the caller re-passes identical T values (CodeQL false positive).

Bit-identical results unchanged; [VTPR]/[cubic] (3694 assertions) pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@andr1976

Copy link
Copy Markdown
Contributor Author

Thanks @coderabbitai — addressed in be67243:

  1. Invalidate caches on rebuild of pure/group data — as you noted, c9e5bab already cleared the temperature-keyed table cache in set_pure_data. Added the second half: set_pure_data now also refreshes the mixture group surface fractions m_Xg/m_thetag when a composition is already set, so set_Q_k after set_mole_fractions no longer leaves them stale (no effect on the normal set_components → set_mole_fractions flow, where the composition isn't set yet at that point).

  2. UNIFAC library isolation in the test — the guard now forces reload-on-next-use on exit (instead of restoring the old flag), so the process-global library populated by this test can't be reused by a later VTPR test; the next VTPR construction reloads from its own path.

On the two CodeQL floating-point-equality alerts: the exact == in set_temperature's fast path (and the std::map<double, …> key lookup) is an intentional cache-key match, not a physical tolerance — the caller re-passes the identical T value (T_r/tau at fixed tau) across a flash, and the finite-difference τ-derivatives repeat exact perturbed temperatures. A tolerance would wrongly return tables computed at a different temperature. I've added a comment documenting this; these alerts can be dismissed as intentional.

Bit-identical results are unchanged; [VTPR]/[cubic] (3694 assertions) and preflight (clang-format, build, tests, cppcheck, clang-tidy, semgrep) all pass.

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@andr1976 Thanks for the thorough follow-up. The composition-aware refresh of m_Xg/m_thetag addresses the stale-table path, and forcing a reload after the test correctly prevents its process-global UNIFAC data from leaking into later VTPR tests.

Your cache-key rationale for exact temperature equality is also sound: this is identity-based table reuse, not a physical comparison, so a tolerance would be incorrect. The added documentation should make that intent clear for CodeQL review.

🐇 ✅


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

// temperatures of the finite-difference tau-derivatives are likewise exact repeats. A tolerance
// would wrongly return tables computed at a *different* temperature. (Same rationale for the
// std::map<double, ...> lookup below.)
if (m_tables_valid && m_T == T) {

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

VTPR mixture flash scales catastrophically with component count (UNIFAC set_temperature recomputes on every call)

2 participants