Skip to content

Commit e345f97

Browse files
authored
Expression profiling (legacy mode) (#936)
* expression profiling * two reports * make simple report sortable * change default to grid * configurable expression_profile_cutoff * works with MP * documentation * expression_profile_style * performance_log can be bool * fix for pandas 2 * test expr profiling * test expression profiling on mtc and semcog examples * set encoding to utf8 because windows * add missing import * add warning about path length in error message * oops, .log not .csv * add timestamping to expression profile logs * fix file naming, update tests * all subprocesses to same timestamp * add note in docs about runtime cost
1 parent 1514a51 commit e345f97

File tree

15 files changed

+1316
-38
lines changed

15 files changed

+1316
-38
lines changed

.github/workflows/core_tests.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,39 @@ jobs:
321321
run: |
322322
uv run pytest activitysim/estimation/test/test_larch_estimation.py --durations=0
323323
324+
expression-profiling:
325+
needs: foundation
326+
env:
327+
python-version: "3.10"
328+
label: win-64
329+
defaults:
330+
run:
331+
shell: pwsh
332+
name: Expression Profiling Tests
333+
runs-on: windows-latest
334+
steps:
335+
- uses: actions/checkout@v4
336+
337+
- name: Install uv
338+
uses: astral-sh/setup-uv@v5
339+
with:
340+
version: "0.7.12"
341+
enable-cache: true
342+
cache-dependency-glob: "uv.lock"
343+
344+
- name: "Set up Python"
345+
uses: actions/setup-python@v5
346+
with:
347+
python-version-file: ".python-version"
348+
349+
- name: Install activitysim
350+
run: |
351+
uv sync --locked --only-group github-action
352+
353+
- name: Test Expression Profiling
354+
run: |
355+
uv run pytest test/test_expression_profiling.py
356+
324357
develop-docbuild:
325358
needs: foundation
326359
if: github.ref_name == 'main' || github.ref_name == 'docs-fix'

activitysim/abm/models/summarize.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44

55
import logging
66
import os
7+
from pathlib import Path
78

89
import numpy as np
910
import pandas as pd
1011

11-
from activitysim.core import expressions, workflow
12+
from activitysim.core import expressions, timing, workflow
1213
from activitysim.core.configuration.base import PreprocessorSettings, PydanticReadable
1314
from activitysim.core.los import Network_LOS
1415

@@ -353,6 +354,12 @@ def summarize(
353354
}
354355
)
355356

357+
if state.settings.expression_profile:
358+
perf_log_file = Path(trace_label + ".log")
359+
else:
360+
perf_log_file = None
361+
performance_timer = timing.EvalTiming(perf_log_file)
362+
356363
for i, row in spec.iterrows():
357364
out_file = row["Output"]
358365
expr = row["Expression"]
@@ -361,15 +368,19 @@ def summarize(
361368
if out_file.startswith("_"):
362369
logger.debug(f"Temp Variable: {expr} -> {out_file}")
363370

364-
locals_d[out_file] = eval(expr, globals(), locals_d)
371+
with performance_timer.time_expression(expr):
372+
locals_d[out_file] = eval(expr, globals(), locals_d)
365373
continue
366374

367375
logger.debug(f"Summary: {expr} -> {out_file}.csv")
368376

369-
resultset = eval(expr, globals(), locals_d)
377+
with performance_timer.time_expression(expr):
378+
resultset = eval(expr, globals(), locals_d)
370379
resultset.to_csv(
371380
state.get_output_file_path(
372381
os.path.join(output_location, f"{out_file}.csv")
373382
),
374383
index=False,
375384
)
385+
386+
performance_timer.write_log(state)

activitysim/cli/run.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
import os
99
import sys
1010
import warnings
11+
from datetime import datetime
1112

1213
import numpy as np
1314

14-
from activitysim.core import chunk, config, mem, tracing, workflow
15+
from activitysim.core import chunk, config, mem, timing, tracing, workflow
1516
from activitysim.core.configuration import FileSystem, Settings
1617

1718
from activitysim.abm.models.settings_checker import check_model_settings
@@ -27,6 +28,7 @@
2728
"cache_dir",
2829
"settings_file_name",
2930
"imported_extensions",
31+
"run_timestamp",
3032
]
3133

3234

@@ -281,6 +283,14 @@ def run(args):
281283
if state.settings.rotate_logs:
282284
state.logging.rotate_log_directory()
283285

286+
# set a run timestamp
287+
timestamp = state.get("run_timestamp", None)
288+
if timestamp is None:
289+
# if no run timestamp, use current time, and store it so
290+
# it can be used later in the same run
291+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
292+
state.set("run_timestamp", timestamp)
293+
284294
if state.settings.memory_profile and not state.settings.multiprocess:
285295
# Memory sidecar is only useful for single process runs
286296
# multiprocess runs log memory usage without blocking in the controlling process.
@@ -450,6 +460,13 @@ def run(args):
450460
if memory_sidecar_process:
451461
memory_sidecar_process.stop()
452462

463+
if state.settings.expression_profile:
464+
# generate a summary of slower expression evaluation times
465+
# across all models and write to a file
466+
analyze = timing.AnalyzeEvalTiming(state)
467+
analyze.component_report(style=state.settings.expression_profile_style)
468+
analyze.subcomponent_report(style=state.settings.expression_profile_style)
469+
453470
return 0
454471

455472

activitysim/core/assign.py

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55
import logging
66
from builtins import object, zip
77
from collections import OrderedDict
8+
from pathlib import Path
89

910
import numpy as np
1011
import pandas as pd
1112

12-
from activitysim.core import chunk, util, workflow
13+
from activitysim.core import chunk, timing, util, workflow
1314

1415
logger = logging.getLogger(__name__)
1516

@@ -275,6 +276,12 @@ def to_series(x):
275276

276277
assert assignment_expressions.shape[0] > 0
277278

279+
if state.settings.expression_profile:
280+
perf_log_file = Path(trace_label + ".log")
281+
else:
282+
perf_log_file = None
283+
performance_timer = timing.EvalTiming(perf_log_file)
284+
278285
trace_assigned_locals = trace_results = None
279286
if trace_rows is not None:
280287
# convert to numpy array so we can slice ndarrays as well as series
@@ -311,24 +318,25 @@ def to_series(x):
311318
n_randoms += 1
312319
assignment_expressions.loc[expression_idx, "expression"] = expression
313320
if n_randoms:
314-
try:
315-
random_draws = state.get_rn_generator().normal_for_df(
316-
df, broadcast=True, size=n_randoms
317-
)
318-
except RuntimeError:
319-
pass
320-
else:
321-
_locals_dict["random_draws"] = random_draws
321+
with performance_timer.time_expression("<RANDOM DRAWS>"):
322+
try:
323+
random_draws = state.get_rn_generator().normal_for_df(
324+
df, broadcast=True, size=n_randoms
325+
)
326+
except RuntimeError:
327+
pass
328+
else:
329+
_locals_dict["random_draws"] = random_draws
322330

323-
def rng_lognormal(random_draws, mu, sigma, broadcast=True, scale=False):
324-
if scale:
325-
x = 1 + ((sigma * sigma) / (mu * mu))
326-
mu = np.log(mu / (np.sqrt(x)))
327-
sigma = np.sqrt(np.log(x))
328-
assert broadcast
329-
return np.exp(random_draws * sigma + mu)
331+
def rng_lognormal(random_draws, mu, sigma, broadcast=True, scale=False):
332+
if scale:
333+
x = 1 + ((sigma * sigma) / (mu * mu))
334+
mu = np.log(mu / (np.sqrt(x)))
335+
sigma = np.sqrt(np.log(x))
336+
assert broadcast
337+
return np.exp(random_draws * sigma + mu)
330338

331-
_locals_dict["rng_lognormal"] = rng_lognormal
339+
_locals_dict["rng_lognormal"] = rng_lognormal
332340

333341
sharrow_enabled = state.settings.sharrow
334342

@@ -356,7 +364,8 @@ def rng_lognormal(random_draws, mu, sigma, broadcast=True, scale=False):
356364

357365
if is_temp_singular(target) or is_throwaway(target):
358366
try:
359-
x = eval(expression, globals(), _locals_dict)
367+
with performance_timer.time_expression(expression):
368+
x = eval(expression, globals(), _locals_dict)
360369
except Exception as err:
361370
logger.error(
362371
"assign_variables error: %s: %s", type(err).__name__, str(err)
@@ -384,7 +393,8 @@ def rng_lognormal(random_draws, mu, sigma, broadcast=True, scale=False):
384393

385394
# FIXME should whitelist globals for security?
386395
globals_dict = {}
387-
expr_values = to_series(eval(expression, globals_dict, _locals_dict))
396+
with performance_timer.time_expression(expression):
397+
expr_values = to_series(eval(expression, globals_dict, _locals_dict))
388398

389399
if sharrow_enabled:
390400
if isinstance(expr_values.dtype, pd.api.types.CategoricalDtype):
@@ -459,4 +469,5 @@ def rng_lognormal(random_draws, mu, sigma, broadcast=True, scale=False):
459469
inplace=True,
460470
)
461471

472+
performance_timer.write_log(state)
462473
return variables, trace_results, trace_assigned_locals

activitysim/core/configuration/base.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,28 @@ def pandas_option_context(self):
234234
else:
235235
yield
236236

237+
performance_log: Path | bool | None = None
238+
"""Log runtime performance to this file.
239+
240+
The runtime performance log shows the time taken to evaluate each
241+
expression in the specification files. It is useful for debugging
242+
performance issues with complex expressions.
243+
244+
Giving a filename here will override the global performance logging
245+
setting, and will log performance to the specified file, unless the
246+
global performance logging setting is set to `False`, in which case
247+
no performance logging will be done for any component.
248+
249+
If set to `True`, performance logging will be activated, and the filename
250+
will be chosen based on the trace label for this component.
251+
252+
If this path is set to None (the default), performance logging will be
253+
activated based on the global performance logging setting, and the filename
254+
will be chosen based on the trace label for this component. This is strongly
255+
recommended for most use cases, unless the trace label causes the filename
256+
to be too long or otherwise unsuitable for the filesystem.
257+
"""
258+
237259
def subcomponent_settings(self, subcomponent: str) -> ComputeSettings:
238260
"""Get the sharrow settings for a particular subcomponent."""
239261
return ComputeSettings(

activitysim/core/configuration/top.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,60 @@ def _check_store_skims_in_shm(self):
537537
own that pollutes the collected data.
538538
"""
539539

540+
expression_profile: bool | None = None
541+
"""
542+
Track the runtime of each individual expression in each spec file.
543+
544+
.. versionadded:: 1.4
545+
546+
The default value of this setting is `None`, which sets no global control
547+
of expression profiling, and allows this feature to be turned on or off
548+
for individual components. If set to `True`, all components will have
549+
expression profiling enabled, and the outputs will be written to files named
550+
based on the trace label unless explicitly set in the `compute_settings` for
551+
individual components. If set to `False`, all components will have expression
552+
profiling disabled.
553+
554+
This is generally a developer-only feature and not needed for regular usage.
555+
It will add some overhead to the model run, which is only valuable if you
556+
expect the review the expression runtimes with an eye towards improving them.
557+
Production model users should typically have this set explicitly to `False`.
558+
"""
559+
560+
expression_profile_style: Literal["simple", "grid"] = "grid"
561+
"""
562+
The style of the expression profiling report.
563+
564+
.. versionadded:: 1.4
565+
566+
This setting controls the style of the expression profiling report that is
567+
generated at the end of a model run when :py:attr:`expression_profile` is
568+
`True`. The report summarizes the runtime of each expression in each spec
569+
file, and can be used to identify slow or inefficient expressions.
570+
571+
The "simple" style generates a simple HTML table with the expression names,
572+
runtimes, and other relevant information. This may be easier to import into
573+
other tools (e.g. Excel) for further analysis if desired. The "grid" style
574+
generates a JavaScript data grid that allows for sorting and filtering of the
575+
expression runtimes, making it easier to analyze large sets of expressions
576+
directly in a web browser with no other outside analysis tools.
577+
"""
578+
579+
expression_profile_cutoff: float = 0.1
580+
"""
581+
Minimum runtime for an expression to be included in the expression profile.
582+
583+
.. versionadded:: 1.4
584+
585+
Expressions that take less than this amount of time to evaluate will not be
586+
included in the summary report of expression profiling generated at the end
587+
of a model run. For large scale models, this value can be increased to make
588+
the report file smaller, as only the largest values will typically be of
589+
interest.
590+
591+
This setting has no effect if :py:attr:`expression_profile` is not `True`.
592+
"""
593+
540594
benchmarking: bool = False
541595
"""
542596
Flag this model run as a benchmarking run.

activitysim/core/expressions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ def compute_columns(
139139
df,
140140
_locals_dict,
141141
trace_rows=state.tracing.trace_targets(df),
142+
trace_label=trace_label,
142143
)
143144

144145
if trace_results is not None:

0 commit comments

Comments
 (0)