STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_props_performance.py
Go to the documentation of this file.
1"""Phase 01's speed gate, and the thread confinement that makes it safe.
2
3Speed is a correctness property here, not a nicety. A transient on a sixty-node
4network evaluates properties millions of times over Jacobian assembly; at
5CoolProp's convenience API (184 us/call) that solve does not finish, so the
6whole layer exists to make it 0.1 us. A regression would not break a test
7anywhere else -- it would just quietly make the simulator unusable, months
8later, with no obvious culprit.
9
10Thresholds are deliberately loose against the measured numbers so shared CI
11runners do not produce flakes. They are ceilings on the *design*, not
12benchmarks: the point is to catch someone accidentally routing the hot path
13through PropsSI or rebuilding a backend per call, which costs 10-1000x, not to
14notice a 20% drift.
15"""
16
17from __future__ import annotations
18
19import os
20import threading
21import time
22
23import pytest
24
25from feedtwin.props import Fluid
26
27#: Microsecond budgets mean nothing with every core busy: a parallel run
28#: (``pytest -n``) skips this file, and CI and scripts/check.sh time it alone.
29pytestmark = pytest.mark.skipif(
30 "PYTEST_XDIST_WORKER" in os.environ, reason="timed on its own, not in parallel"
31)
32
33# Two kinds of assertion below, and the distinction matters more than either
34# number.
35#
36# *Ratios* are the sharp instrument. A shared CI runner can be two or three
37# times slower than a laptop, but it is slower at everything, so "the tables
38# beat the equation of state by 3x" and "PropsSI is 10x our worst case" hold
39# on any machine. Those are the tests that would actually catch a regression.
40#
41# *Absolute ceilings* are the blunt one, and are set to catch the catastrophic
42# case -- someone routing the hot path through PropsSI, or rebuilding a backend
43# per call, which cost 10-1000x -- without flaking when a runner is loaded.
44# They are deliberately several times the measured values rather than snug
45# around them; a snug absolute threshold on a shared runner is a test that
46# fails for reasons unrelated to the code.
47
48#: Measured on the development host, nitrogen via the tabulated backend:
49#: accessor 0.63-0.9 us, get about 1 us, state 5.0 us (twelve reads). GitHub's
50#: runners take about 3.3x as long, every time and not just when loaded: the cp
51#: accessor read 3.006 and 3.028 us in two runs, the second the fastest of
52#: five batches. So the ceilings are set several times over the *runner's*
53#: numbers, as the note above asks -- 3 us was the runner's own speed, and
54#: failed on a coin toss. They still catch what they are for: PropsSI is
55#: 184 us a call, and a backend rebuilt per call is 10-1000x.
56#: Phase 01's design target is the sub-microsecond accessor; run with
57#: FEEDTWIN_STRICT_PERF=1 on a quiet machine to hold that.
58HOT_PATH_BUDGET_US = 10.0
59CONVENIENCE_BUDGET_US = 15.0
60
61#: Set FEEDTWIN_STRICT_PERF=1 to assert the design targets instead of the
62#: CI-safe ceilings. Worth running on a quiet machine after touching the hot
63#: path -- the loose ceilings above would not notice a 3x regression, and that
64#: is exactly the size of mistake a refactor introduces.
65if os.environ.get("FEEDTWIN_STRICT_PERF") == "1": # pragma: no cover - opt-in
66 HOT_PATH_BUDGET_US = 1.0
67 CONVENIENCE_BUDGET_US = 1.5
68
69#: Enough to swamp timer noise; small enough to stay quick in CI.
70ITERATIONS = 20_000
71
72#: Timed in this many equal batches, and the fastest one counts.
73#:
74#: One mean over all twenty thousand calls takes in whatever else a shared
75#: runner is doing meanwhile. Another job stealing the core only ever adds
76#: time, so the quickest batch is the closest reading of the code itself --
77#: and a regression of the 10-1000x kind these ceilings exist for is slow in
78#: every batch.
79BATCHES = 5
80
81
82#: Temperature step between successive timed calls [K].
83#:
84#: The calls have to walk, not repeat. :meth:`Fluid.get` and the accessor share
85#: a per-thread memo, so twenty thousand reads of one state point measure the
86#: memo -- microseconds of dictionary lookup -- and say nothing at all about
87#: the backend underneath. Everything asserted in this file is about that
88#: backend: whether the tables are being used, whether somebody has routed the
89#: hot path through PropsSI. So each call moves the state.
90#:
91#: A microkelvin is small enough that every point stays in the same phase and
92#: the same table cell, and large enough to be a distinct cache key. It is also
93#: what a solve does: a Newton iteration walks pressures and never asks twice
94#: for exactly the same point.
95_STEP_K = 1.0e-6
96
97
98def _time_us(call: object, n: int = ITERATIONS) -> float:
99 """Microseconds per call in the fastest of :data:`BATCHES` batches, after a
100 warm-up that excludes table build.
101
102 ``call`` takes the iteration index and is expected to use it to move the
103 state point -- see :data:`_STEP_K`. The index runs on across batches, so no
104 batch repeats another's state points.
105 """
106 fn = call # type: ignore[assignment]
107 fn(0) # type: ignore[operator]
108 per = max(1, n // BATCHES)
109 best = float("inf")
110 for b in range(BATCHES):
111 started = time.perf_counter()
112 for i in range(b * per, (b + 1) * per):
113 fn(i) # type: ignore[operator]
114 best = min(best, (time.perf_counter() - started) / per)
115 return best * 1e6
116
117
118@pytest.mark.parametrize("prop", ["rho", "mu", "cp", "Z"])
120 """The ergonomic API stays close to a microsecond."""
121 n2 = Fluid("nitrogen")
122 elapsed = _time_us(lambda i: n2.get(prop, p=1.0e7, T=300.0 + i * _STEP_K))
123 assert elapsed < CONVENIENCE_BUDGET_US, f"Fluid.get({prop!r}) took {elapsed:.3f} us"
124
125
126@pytest.mark.parametrize("prop", ["rho", "mu", "cp", "Z"])
128 """The accessor -- what a solve loop uses -- stays fast.
129
130 Phase 01's design target is under a microsecond, met at 0.63 us on the
131 development host. What is asserted here is the looser ceiling above, so the
132 test means the same thing on a loaded runner; run with
133 FEEDTWIN_STRICT_PERF=1 to hold the real target.
134
135 Z is included deliberately: it is the gas-side workhorse (COPV, ullage) and
136 is derived from several reads rather than being one, so it is the property
137 most likely to drift over.
138 """
139 read = Fluid("nitrogen").accessor(prop, "p", "T")
140 elapsed = _time_us(lambda i: read(1.0e7, 300.0 + i * _STEP_K))
141 assert elapsed < HOT_PATH_BUDGET_US, f"accessor({prop!r}) took {elapsed:.3f} us"
142
143
144@pytest.mark.parametrize("prop", ["rho", "mu"])
145def test_accessor_is_faster_than_get(prop: str) -> None:
146 """The bound accessor is the loop API, and must earn that claim.
147
148 It hoists everything invariant -- state-pair resolution, which backends
149 support the property, keyword unpacking -- out of the call. If it ever
150 stopped being faster, the extra API would be complexity for nothing.
151 """
152 n2 = Fluid("nitrogen")
153 read = n2.accessor(prop, "p", "T")
154
155 bound = _time_us(lambda i: read(1.0e7, 300.0 + i * _STEP_K))
156 convenient = _time_us(lambda i: n2.get(prop, p=1.0e7, T=300.0 + i * _STEP_K))
157
158 assert bound < HOT_PATH_BUDGET_US
159 assert bound < convenient
160
161
163 """The reason the chain is ordered the way it is.
164
165 A loose 3x floor against a measured 10-30x: enough to prove the tabulated
166 path is actually being taken, without failing on a noisy runner.
167 """
168 fast = Fluid("nitrogen", chain=["bicubic"]).accessor("rho", "p", "T")
169 exact = Fluid("nitrogen", chain=["heos"]).accessor("rho", "p", "T")
170
171 speedup = _time_us(lambda i: exact(1.0e7, 300.0 + i * _STEP_K)) / _time_us(
172 lambda i: fast(1.0e7, 300.0 + i * _STEP_K)
173 )
174 assert speedup > 3.0, f"tables only {speedup:.1f}x faster than the EOS"
175
176
178 """The measurement behind the ban in test_property_call_discipline.
179
180 That test forbids ``PropsSI`` in library code on the strength of a number.
181 This is the number, re-measured, so the rule keeps a live justification
182 rather than a comment citing a benchmark nobody runs. Tests are exempt from
183 the ban precisely so this comparison can exist.
184 """
185 import CoolProp.CoolProp as CP
186
187 n2 = Fluid("nitrogen").accessor("rho", "p", "T")
188
189 convenient = _time_us(
190 lambda i: CP.PropsSI("D", "T", 300.0 + i * _STEP_K, "P", 1.0e7, "Nitrogen"),
191 n=2_000,
192 )
193 ours = _time_us(lambda i: n2(1.0e7, 300.0 + i * _STEP_K))
194
195 assert convenient / ours > 10.0, (
196 f"PropsSI {convenient:.1f} us vs Fluid {ours:.3f} us -- the gap that "
197 "justifies the ban has narrowed; re-check the rule."
198 )
199
200
202 """Threads must not share a backend cursor.
203
204 Backends wrap a mutable cursor: ``update`` moves it, ``value`` reads it.
205 Two threads sharing one would interleave -- each reading the other's state
206 point -- and produce plausible wrong numbers with no exception anywhere.
207
208 Each thread here queries a *different* state point in a tight loop and
209 checks its own answer every time. Interleaving would show up as a wrong
210 density, not as a crash, which is exactly why it needs asserting.
211 """
212 n2 = Fluid("nitrogen")
213 expected = {
214 T: Fluid("nitrogen").get("rho", p=1.0e7, T=T) for T in (250.0, 300.0, 350.0)
215 }
216 failures: list[str] = []
217 barrier = threading.Barrier(len(expected))
218
219 def hammer(T: float) -> None:
220 barrier.wait()
221 for _ in range(2_000):
222 got = n2.get("rho", p=1.0e7, T=T)
223 if got != pytest.approx(expected[T], rel=1e-12):
224 failures.append(f"T={T}: got {got}, expected {expected[T]}")
225 return
226
227 threads = [threading.Thread(target=hammer, args=(T,)) for T in expected]
228 for t in threads:
229 t.start()
230 for t in threads:
231 t.join()
232
233 assert not failures, failures[:5]
234
235
237 """One measured table, many threads.
238
239 Unlike the CoolProp backends, a tabulated one cannot be rebuilt per thread
240 -- it is parameterised by its data, not by a species name -- so the single
241 instance is shared and only its cursor is thread-local. This asserts that
242 actually holds.
243 """
244 import numpy as np
245
246 from feedtwin.props import TabulatedProperties
247
248 p_grid = np.array([1.0e6, 2.0e6, 3.0e6])
249 T_grid = np.array([280.0, 300.0, 320.0])
250 values = np.array([[p * T for T in T_grid] for p in p_grid])
251 tab = TabulatedProperties(p_grid, T_grid, {"rho": values}, source="unit-test")
252
253 fluid = Fluid("nitrogen", chain=[tab])
254 failures: list[str] = []
255 barrier = threading.Barrier(3)
256
257 def hammer(p: float, T: float) -> None:
258 barrier.wait()
259 for _ in range(2_000):
260 if fluid.get("rho", p=p, T=T) != pytest.approx(p * T, rel=1e-9):
261 failures.append(f"p={p} T={T}")
262 return
263
264 threads = [
265 threading.Thread(target=hammer, args=args)
266 for args in [(1.0e6, 280.0), (2.0e6, 300.0), (3.0e6, 320.0)]
267 ]
268 for t in threads:
269 t.start()
270 for t in threads:
271 t.join()
272
273 assert not failures, failures[:5]
274
275
277 """The property memo is an *exact* cache, and has to stay one.
278
279 No rounding, no tolerance, no quantised state -- a hit returns the number
280 the backend would have returned, and the only thing saved is asking again.
281 That is what makes it safe to put in front of a solve: it cannot move an
282 answer, only shorten the path to it.
283
284 Checked against a fluid with no memo of its own, so the comparison is
285 against the backend rather than against another cache.
286 """
287 warm = Fluid("nitrogen")
288 for prop in ("rho", "mu", "cp", "Z"):
289 first = warm.get(prop, p=1.0e7, T=300.0)
290 again = warm.get(prop, p=1.0e7, T=300.0)
291 cold = Fluid("nitrogen").get(prop, p=1.0e7, T=300.0)
292 assert again == first, f"{prop}: memo returned a different number"
293 assert first == cold, f"{prop}: memo diverged from the backend"
294
295 # And it must not merge neighbours. Rounding the key would be a tempting
296 # way to raise the hit rate -- it was measured, and buys 3 points -- but it
297 # turns the cache into an approximation, which is a different thing to have
298 # in front of a solve. Two states a millikelvin apart have genuinely
299 # different densities, and the memo has to keep them apart.
300 #
301 # Checked by walking, not by one pair: a key rounded coarsely enough to
302 # collide somewhere will collide across a sweep, and a single hand-picked
303 # point can miss it entirely.
304 walked = Fluid("nitrogen")
305 values = [walked.get("rho", p=1.0e7, T=300.0 + i * 1.0e-3) for i in range(64)]
306 assert len(set(values)) == len(
307 values
308 ), "the memo merged distinct state points; the key is being rounded"
309 fresh = Fluid("nitrogen")
310 assert values == [
311 fresh.get("rho", p=1.0e7, T=300.0 + i * 1.0e-3) for i in range(64)
312 ], "a walked sweep disagreed with a cold one"
313
314
316 """Otherwise the accessor -- the API this module tells people to use in a
317 loop -- would be the slower of the two for a repeated point, and its own
318 advice would be wrong."""
319 n2 = Fluid("nitrogen")
320 read = n2.accessor("rho", "p", "T")
321 n2._local.memo = {}
322
323 from_get = n2.get("rho", p=1.0e7, T=300.0)
324 assert len(n2._local.memo) == 1
325 from_accessor = read(1.0e7, 300.0)
326
327 assert from_accessor == from_get
328 assert len(n2._local.memo) == 1, "the accessor filled a second entry"
329
330 # ...and the caller's argument order is still honoured, not the table's.
331 flipped = Fluid("nitrogen").accessor("rho", "T", "p")
332 assert flipped(300.0, 1.0e7) == from_get
333
334
336 """It is cleared wholesale rather than evicted one at a time, so the only
337 thing that must hold is that it cannot grow without limit."""
338 from feedtwin.props.fluid import _MEMO_ENTRIES
339
340 n2 = Fluid("nitrogen")
341 n2._local.memo = {}
342 for i in range(_MEMO_ENTRIES * 3):
343 n2.get("rho", p=1.0e7, T=300.0 + i * 1.0e-3)
344 assert len(n2._local.memo) <= _MEMO_ENTRIES
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
None test_accessor_is_faster_than_get(str prop)
None test_the_memo_returns_exactly_what_the_backend_would()
None test_a_shared_tabulated_backend_is_also_thread_safe()
None test_get_is_within_the_convenience_budget(str prop)
float _time_us(object call, int n=ITERATIONS)
None test_tables_are_dramatically_faster_than_the_equation_of_state()
None test_the_hot_path_meets_the_phase_budget(str prop)
static double read(int32_t code)