CoolProp 8.0.0
An open-source fluid property and humid air property database
CoolProp-Tests-TermCacheProfile.cpp
Go to the documentation of this file.
1// Standalone profiling harness for residual-Helmholtz term-table cache feasibility
2// (optimization "E" reconnaissance for ResidualHelmholtzGeneralizedExponential::all).
3//
4// QUESTION:
5// For real CoolProp fluids, how many UNIQUE l_double / m_double exponents appear
6// in the residual term table vs the total term count N? If the sharing ratio is
7// high (say 5 unique across 50 terms), caching pow(delta, l) / pow(tau, m) at the
8// top of the loop replaces N exp/log evals with just (unique + N lookups). If the
9// ratio is low (45 unique across 50 terms), the cache buys nothing.
10//
11// PART 1 — Term-table structure
12// For each representative fluid: walk components[0].EOS().alphar.GenExp.elements
13// and count distinct l_double / m_double values, filtered by the gates the live
14// all() implementation actually uses (delta_li_in_u + c!=0 + l>0 for l;
15// tau_mi_in_u + |m|>0 for m). Print a per-fluid table with the savings ratio.
16//
17// PART 2 — Microbench of the two evaluation paths
18// For Water, R134a, n-Propane: at a representative single-phase state, run a tight
19// loop that mimics the per-term pow/exp pattern. Path A repeats exp(l*log_delta)
20// and exp(m*log_tau) per term. Path B precomputes a small vector of distinct
21// values and looks them up via linear search. std::chrono steady_clock, 1e6 reps,
22// 5 trials, report mean +/- stddev for each path and the A/B ratio.
23//
24// This file is a PROFILING TOOL — Catch2 hidden ([.]). It does NOT modify
25// production code; it only INSPECTS public state. Run via:
26// ./build_catch_rel/CatchTestRunner "[term_cache_profile]"
27
31#include "CoolProp/CoolProp.h"
33
34#include <sstream>
35
36#if defined(ENABLE_CATCH)
37# include <catch2/catch_all.hpp>
38
39# include <algorithm>
40# include <cfloat>
41# include <chrono>
42# include <cmath>
43# include <cstddef>
44# include <cstdio>
45# include <memory>
46# include <string>
47# include <vector>
48
49namespace {
50
51// Count distinct values in a vector under a relative tolerance. Order-preserving:
52// emits the first occurrence each time. Used by Part 1 and Part 2 (to build the
53// "small linear-search table" the cached path uses).
54std::vector<double> distinct_values(const std::vector<double>& xs, double rtol = 1e-12) {
55 std::vector<double> out;
56 out.reserve(xs.size());
57 for (double v : xs) {
58 bool seen = false;
59 for (double u : out) {
60 const double scale = std::max(std::abs(u), 1.0);
61 if (std::abs(u - v) <= rtol * scale) {
62 seen = true;
63 break;
64 }
65 }
66 if (!seen) out.push_back(v);
67 }
68 return out;
69}
70
71// What the term table looks like from the public API. Mirrors the gates inside
72// ResidualHelmholtzGeneralizedExponential::all so the unique-count we report
73// corresponds to the pow/exp calls actually executed at run time.
74struct TermStats
75{
76 std::size_t N = 0; // total residual term count (GenExp.elements.size())
77 std::size_t N_l_used = 0; // terms that take the exp(l*log_delta) branch
78 std::size_t N_m_used = 0; // terms that take the exp(m*log_tau) branch
79 std::size_t unique_l = 0; // distinct l_double values among the N_l_used terms
80 std::size_t unique_m = 0; // distinct m_double values among the N_m_used terms
81 std::vector<double> ls; // the l_double values per-term (for Part 2 to reuse)
82 std::vector<double> ms; // the m_double values per-term (for Part 2 to reuse)
83 bool delta_li_in_u = false;
84 bool tau_mi_in_u = false;
85 bool ok = false; // false => introspection failed / not a HEOS pure
86};
87
88// Walk the residual term table of fluid `name` and produce stats. Returns ok=false
89// if the fluid is unavailable, not a HEOS pure backend, or otherwise unscrutable —
90// caller will report it as "skipped".
91TermStats inspect_fluid(const std::string& name) {
92 TermStats s;
93 std::shared_ptr<CoolProp::AbstractState> AS;
94 try {
95 AS.reset(CoolProp::AbstractState::factory("HEOS", name));
96 } catch (...) {
97 return s;
98 }
99 auto* be = dynamic_cast<CoolProp::HelmholtzEOSMixtureBackend*>(AS.get());
100 if (be == nullptr) return s;
101 auto& comps = be->get_components();
102 if (comps.empty()) return s;
103 auto& gen = comps[0].EOS().alphar.GenExp;
104 s.delta_li_in_u = gen.delta_li_in_u;
105 s.tau_mi_in_u = gen.tau_mi_in_u;
106 s.N = gen.elements.size();
107
108 std::vector<double> active_l, active_m;
109 active_l.reserve(s.N);
110 active_m.reserve(s.N);
111 for (std::size_t i = 0; i < s.N; ++i) {
112 const auto& el = gen.elements[i];
113 // Gate for the delta^l branch — mirrors the live code in
114 // src/Helmholtz.cpp around line 162 of ResidualHelmholtzGeneralizedExponential::all.
115 const bool l_active = s.delta_li_in_u && std::isfinite(static_cast<double>(el.l_double)) && static_cast<double>(el.l_double) > 0.0
116 && std::abs(static_cast<double>(el.c)) > DBL_EPSILON;
117 // Gate for the tau^m branch — mirrors the live code around line 177.
118 const bool m_active = s.tau_mi_in_u && std::abs(static_cast<double>(el.m_double)) > 0.0;
119 if (l_active) active_l.push_back(static_cast<double>(el.l_double));
120 if (m_active) active_m.push_back(static_cast<double>(el.m_double));
121 }
122 s.N_l_used = active_l.size();
123 s.N_m_used = active_m.size();
124 s.ls = active_l;
125 s.ms = active_m;
126 s.unique_l = distinct_values(active_l).size();
127 s.unique_m = distinct_values(active_m).size();
128 s.ok = true;
129 return s;
130}
131
132// ----- Part 2 helpers -----------------------------------------------------
133
134// Path A: independently evaluate exp(l_i * log_delta) and exp(m_i * log_tau) for
135// every term, the way the live all() does today. Returns a sink to defeat DCE.
136double path_A(const std::vector<double>& ls, const std::vector<double>& ms, double log_delta, double log_tau) {
137 double acc = 0.0;
138 const std::size_t Nl = ls.size(), Nm = ms.size();
139 for (std::size_t i = 0; i < Nl; ++i)
140 acc += std::exp(ls[i] * log_delta);
141 for (std::size_t i = 0; i < Nm; ++i)
142 acc += std::exp(ms[i] * log_tau);
143 return acc;
144}
145
146// Path B: precompute one exp() per unique exponent, then do a small linear-search
147// lookup per term to fetch the cached value. Realistic for the typical case where
148// unique <= 16 — linear search beats a hash map at that size.
149double path_B(const std::vector<double>& ls, const std::vector<double>& ms, const std::vector<double>& uniq_l, const std::vector<double>& uniq_m,
150 double log_delta, double log_tau) {
151 // Precompute exp(u * log_x) once per unique exponent.
152 double cache_l[64], cache_m[64];
153 const std::size_t Ul = std::min<std::size_t>(uniq_l.size(), 64), Um = std::min<std::size_t>(uniq_m.size(), 64);
154 for (std::size_t k = 0; k < Ul; ++k)
155 cache_l[k] = std::exp(uniq_l[k] * log_delta);
156 for (std::size_t k = 0; k < Um; ++k)
157 cache_m[k] = std::exp(uniq_m[k] * log_tau);
158
159 double acc = 0.0;
160 const std::size_t Nl = ls.size(), Nm = ms.size();
161 for (std::size_t i = 0; i < Nl; ++i) {
162 const double li = ls[i];
163 for (std::size_t k = 0; k < Ul; ++k) {
164 if (uniq_l[k] == li) { // exponents loaded from JSON are bit-exact within the table
165 acc += cache_l[k];
166 break;
167 }
168 }
169 }
170 for (std::size_t i = 0; i < Nm; ++i) {
171 const double mi = ms[i];
172 for (std::size_t k = 0; k < Um; ++k) {
173 if (uniq_m[k] == mi) {
174 acc += cache_m[k];
175 break;
176 }
177 }
178 }
179 return acc;
180}
181
182struct TrialStats
183{
184 double mean_ns = 0.0;
185 double stddev_ns = 0.0;
186};
187
188// Run `n_trials` of `reps` repetitions of `fn`, return mean and stddev of ns/iter.
189template <typename F>
190TrialStats bench(F&& fn, std::size_t reps, std::size_t n_trials) {
191 std::vector<double> ns_per_iter;
192 ns_per_iter.reserve(n_trials);
193 volatile double sink = 0.0;
194 for (std::size_t t = 0; t < n_trials; ++t) {
195 const auto t0 = std::chrono::steady_clock::now();
196 for (std::size_t r = 0; r < reps; ++r) {
197 sink = sink + fn();
198 }
199 const auto t1 = std::chrono::steady_clock::now();
200 const double dt_ns = std::chrono::duration<double, std::nano>(t1 - t0).count();
201 ns_per_iter.push_back(dt_ns / static_cast<double>(reps));
202 }
203 (void)sink;
204 double sum = 0.0;
205 for (double x : ns_per_iter)
206 sum += x;
207 const double mean = sum / static_cast<double>(n_trials);
208 double sq = 0.0;
209 for (double x : ns_per_iter)
210 sq += (x - mean) * (x - mean);
211 const double stddev = (n_trials > 1) ? std::sqrt(sq / static_cast<double>(n_trials - 1)) : 0.0;
212 return TrialStats{mean, stddev};
213}
214
215// Pick a representative single-phase (T, p) state for each benchmark fluid, run the
216// AbstractState through update so tau/delta are well-defined, then return log(tau)
217// and log(delta). Returns false if the state can't be reached.
218bool eval_state(const std::string& name, double T, double p, double& log_tau, double& log_delta) {
219 std::shared_ptr<CoolProp::AbstractState> AS;
220 try {
221 AS.reset(CoolProp::AbstractState::factory("HEOS", name));
222 AS->update(CoolProp::PT_INPUTS, p, T);
223 } catch (...) {
224 return false;
225 }
226 const double tau = AS->tau(), delta = AS->delta();
227 if (!(tau > 0) || !(delta > 0)) return false;
228 log_tau = std::log(tau);
229 log_delta = std::log(delta);
230 return true;
231}
232
233} // anonymous namespace
234
235TEST_CASE("Tau-mi sweep: which fluids actually populate tau_mi_in_u with non-zero m_double", "[term_cache_tau_sweep][.]") {
236 // Scan the whole fluid library for fluids where N_m_used > 0.
237 const std::string fluids_csv = CoolProp::get_global_param_string("FluidsList");
238 std::vector<std::string> all_fluids;
239 std::stringstream ss(fluids_csv);
240 std::string item;
241 while (std::getline(ss, item, ','))
242 all_fluids.push_back(item);
243 std::printf("\n=== tau_mi sweep: %zu fluids ===\n", all_fluids.size());
244 std::printf("%-30s %5s %8s %10s %8s %10s\n", "fluid", "N", "N_l_used", "unique_l", "N_m_used", "unique_m");
245 std::size_t with_m = 0;
246 for (const auto& name : all_fluids) {
247 const TermStats s = inspect_fluid(name);
248 if (!s.ok) continue;
249 if (s.N_m_used == 0) continue;
250 ++with_m;
251 std::printf("%-30s %5zu %8zu %10zu %8zu %10zu\n", name.c_str(), s.N, s.N_l_used, s.unique_l, s.N_m_used, s.unique_m);
252 }
253 std::printf("--- %zu / %zu fluids have N_m_used > 0 ---\n", with_m, all_fluids.size());
254}
255
256TEST_CASE("Term-cache feasibility profile for ResidualHelmholtzGeneralizedExponential::all", "[term_cache_profile][.]") {
257 // -------------------- PART 1 --------------------
258 const std::vector<std::string> fluids = {"Water", "CarbonDioxide", "n-Propane", "R134a", "Nitrogen", "Methane",
259 "MM", "Ammonia", "Hydrogen", "R32", "R143a"};
260
261 std::printf("\n=== Part 1: residual-term-table structure (per fluid) ===\n");
262 std::printf("%-15s %5s %8s %10s %8s %10s %12s\n", "fluid", "N", "N_l_used", "unique_l", "N_m_used", "unique_m", "savings_pow");
263 std::printf("%-15s %5s %8s %10s %8s %10s %12s\n", "-----", "-", "--------", "--------", "--------", "--------", "-----------");
264 std::vector<std::string> skipped;
265 for (const auto& name : fluids) {
266 const TermStats s = inspect_fluid(name);
267 if (!s.ok) {
268 skipped.push_back(name);
269 continue;
270 }
271 const std::size_t denom = s.N_l_used + s.N_m_used;
272 const double savings = (denom > 0) ? static_cast<double>(denom - s.unique_l - s.unique_m) / static_cast<double>(denom) : 0.0;
273 std::printf("%-15s %5zu %8zu %10zu %8zu %10zu %12.3f\n", name.c_str(), s.N, s.N_l_used, s.unique_l, s.N_m_used, s.unique_m, savings);
274 // Sanity asserts (validation): the unique count can't exceed the participating count,
275 // and savings is in [0, 1].
276 REQUIRE(s.unique_l <= s.N_l_used);
277 REQUIRE(s.unique_m <= s.N_m_used);
278 REQUIRE(savings >= 0.0);
279 REQUIRE(savings <= 1.0);
280 }
281 if (!skipped.empty()) {
282 std::printf("(skipped: ");
283 for (std::size_t i = 0; i < skipped.size(); ++i)
284 std::printf("%s%s", skipped[i].c_str(), i + 1 == skipped.size() ? "" : ", ");
285 std::printf(")\n");
286 }
287
288 // -------------------- PART 2 --------------------
289 struct BenchFluid
290 {
291 std::string name;
292 double T;
293 double p;
294 };
295 // Representative single-phase states (well away from saturation / criticality).
296 const std::vector<BenchFluid> benches = {
297 {"Water", 400.0, 1.0e7}, // compressed liquid water, comfortably subcritical
298 {"R134a", 350.0, 5.0e5}, // superheated R134a vapor
299 {"n-Propane", 300.0, 2.0e5} // gas-phase n-propane
300 };
301 const std::size_t reps = 1'000'000;
302 const std::size_t n_trials = 5;
303
304 std::printf("\n=== Part 2: microbench Path A (per-term exp) vs Path B (precompute+lookup) ===\n");
305 std::printf("(%zu reps x %zu trials, ns per loop iteration)\n", reps, n_trials);
306 std::printf("%-12s %5s %10s %10s %14s %14s %8s %14s\n", "fluid", "N", "uniq_l", "uniq_m", "A mean (ns)", "B mean (ns)", "A/B", "Delta x 25 ns");
307 std::printf("%-12s %5s %10s %10s %14s %14s %8s %14s\n", "-----", "-", "------", "------", "-----------", "-----------", "---", "-------------");
308
309 for (const auto& b : benches) {
310 TermStats s = inspect_fluid(b.name);
311 if (!s.ok || (s.N_l_used == 0 && s.N_m_used == 0)) {
312 std::printf("%-12s [skipped — introspection failed or no l/m terms]\n", b.name.c_str());
313 continue;
314 }
315 double log_tau = 0.0, log_delta = 0.0;
316 if (!eval_state(b.name, b.T, b.p, log_tau, log_delta)) {
317 std::printf("%-12s [skipped — state (%g K, %g Pa) unreachable]\n", b.name.c_str(), b.T, b.p);
318 continue;
319 }
320 const std::vector<double> uniq_l = distinct_values(s.ls);
321 const std::vector<double> uniq_m = distinct_values(s.ms);
322
323 // Warmup once to populate caches and let the branch predictor settle.
324 (void)path_A(s.ls, s.ms, log_delta, log_tau);
325 (void)path_B(s.ls, s.ms, uniq_l, uniq_m, log_delta, log_tau);
326
327 const TrialStats A = bench([&] { return path_A(s.ls, s.ms, log_delta, log_tau); }, reps, n_trials);
328 const TrialStats B = bench([&] { return path_B(s.ls, s.ms, uniq_l, uniq_m, log_delta, log_tau); }, reps, n_trials);
329
330 const double ratio = (B.mean_ns > 0) ? A.mean_ns / B.mean_ns : 0.0;
331 // Rough projected solve-time saving: PT_INPUTS does on the order of ~25 EOS
332 // evals per solve (residual + derivatives in the Newton iteration); each
333 // residual eval calls all() once. So saving (A - B) ns per all() call
334 // translates to ~25 * (A - B) ns per flash solve.
335 const double per_solve_ns = (A.mean_ns - B.mean_ns) * 25.0;
336
337 std::printf("%-12s %5zu %10zu %10zu %8.1f +-%3.1f %8.1f +-%3.1f %8.2f %14.1f\n", b.name.c_str(), s.N, uniq_l.size(), uniq_m.size(), A.mean_ns,
338 A.stddev_ns, B.mean_ns, B.stddev_ns, ratio, per_solve_ns);
339 }
340
341 std::printf("\nNote: Path A mimics 'exp(l*log_delta) + exp(m*log_tau) per term'.\n");
342 std::printf(" Path B precomputes one exp per UNIQUE exponent + linear-search lookup per term.\n");
343 std::printf(" If A/B >> 1 with low unique counts, optimization (E) is worth implementing.\n");
344}
345
346#endif // ENABLE_CATCH