Skip to content

Commit 673b1f3

Browse files
authored
Merge pull request #1128 from SeaCelo/perf/cache-e-long
3.3x speedup: cache e_long, skip redundant tax-parameter conversions
2 parents 03c30c7 + 5c1a2fa commit 673b1f3

8 files changed

Lines changed: 100 additions & 54 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.15.12] - 2026-05-14 12:00:00
9+
10+
### Added
11+
12+
- Caches the `e_long` array in `household.FOC_savings` and `household.FOC_labor` rather than rebuilding it on every call. `e_long` is a pure function of `p.e`, `p.S`, and `p.J`, none of which change during a solve, so a `_get_e_long` helper now builds it once per worker and reuses it. Profiling identified this rebuild as the single most expensive operation in a TPI run. The change is a pure cache — model output is bit-for-bit identical to master — and gives roughly a 3x speedup on a single-reform TPI run. See PR [#1128](https://github.com/PSLmodels/OG-Core/pull/1128).
13+
- Builds the per-period tax-parameter slices in `TPI.inner_loop` as numpy arrays via a new `_params_to_array` helper, and switches `txfunc.get_tax_rates` to `np.asarray`, so the repeated per-call list-to-array conversion is skipped on the hot TPI path. `mono` and `mono2D` tax functions store callables rather than numbers, so their nested-list form is passed through unchanged. Profiling identified this conversion as the next hot spot after the `e_long` rebuild. Model output is bit-for-bit identical to master, and the change gives roughly a further 10% speedup on a single-reform TPI run (about 3.3x cumulative versus master). See PR [#1128](https://github.com/PSLmodels/OG-Core/pull/1128).
14+
- Changes the minimum of the allowable range for `tau_c` in `default_parameters.py` to allow for government consumption subsidies.
15+
- Fixes a bug in the `parameter_plots.plot_fert_rates` function. See PR [#1127](https://github.com/PSLmodels/OG-Core/pull/1127).
16+
817
## [0.15.11] - 2026-05-08 12:00:00
918

1019
### Added

ogcore/TPI.py

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,19 @@ def twist_doughnut(
363363
return list(error1.flatten()) + list(error2.flatten())
364364

365365

366+
def _params_to_array(nested, tax_func_type):
367+
"""Convert a nested tax-parameter slice to a numpy array.
368+
369+
For numeric tax functions this lets ``get_tax_rates`` skip a costly
370+
per-call list-to-array conversion. ``mono`` and ``mono2D`` store
371+
callables rather than numbers, so their nested-list form is returned
372+
unchanged.
373+
"""
374+
if tax_func_type in ("mono", "mono2D"):
375+
return nested
376+
return np.array(nested)
377+
378+
366379
def inner_loop(guesses, outer_loop_vars, initial_values, ubi, j, ind, p):
367380
"""
368381
Given path of economic aggregates and factor prices, solves
@@ -456,27 +469,25 @@ def inner_loop(guesses, outer_loop_vars, initial_values, ubi, j, ind, p):
456469
ubi_to_use = np.diag(ubi[: p.S, :, j], p.S - (s + 2))
457470

458471
num_params = len(p.etr_params[0][0])
472+
# Convert the per-age tax-parameter slices to arrays here, once,
473+
# rather than letting get_tax_rates re-convert them from nested
474+
# lists on each of its many calls. mono/mono2D store callables,
475+
# not numbers, so _params_to_array leaves them as nested lists.
459476
temp_etr = [
460477
[p.etr_params[t][p.S - s - 2 + t][i] for i in range(num_params)]
461478
for t in range(s + 2)
462479
]
463-
etr_params_to_use = [
464-
[temp_etr[i][j] for j in range(num_params)] for i in range(s + 2)
465-
]
480+
etr_params_to_use = _params_to_array(temp_etr, p.tax_func_type)
466481
temp_mtrx = [
467482
[p.mtrx_params[t][p.S - s - 2 + t][i] for i in range(num_params)]
468483
for t in range(s + 2)
469484
]
470-
mtrx_params_to_use = [
471-
[temp_mtrx[i][j] for j in range(num_params)] for i in range(s + 2)
472-
]
485+
mtrx_params_to_use = _params_to_array(temp_mtrx, p.tax_func_type)
473486
temp_mtry = [
474487
[p.mtry_params[t][p.S - s - 2 + t][i] for i in range(num_params)]
475488
for t in range(s + 2)
476489
]
477-
mtry_params_to_use = [
478-
[temp_mtry[i][j] for j in range(num_params)] for i in range(s + 2)
479-
]
490+
mtry_params_to_use = _params_to_array(temp_mtry, p.tax_func_type)
480491

481492
solutions = opt.root(
482493
twist_doughnut,
@@ -521,18 +532,27 @@ def inner_loop(guesses, outer_loop_vars, initial_values, ubi, j, ind, p):
521532

522533
# initialize array of diagonal elements
523534
num_params = len(p.etr_params[t][0])
524-
etr_params_to_use = [
525-
[p.etr_params[t + s][s][i] for i in range(num_params)]
526-
for s in range(p.S)
527-
]
528-
mtrx_params_to_use = [
529-
[p.mtrx_params[t + s][s][i] for i in range(num_params)]
530-
for s in range(p.S)
531-
]
532-
mtry_params_to_use = [
533-
[p.mtry_params[t + s][s][i] for i in range(num_params)]
534-
for s in range(p.S)
535-
]
535+
etr_params_to_use = _params_to_array(
536+
[
537+
[p.etr_params[t + s][s][i] for i in range(num_params)]
538+
for s in range(p.S)
539+
],
540+
p.tax_func_type,
541+
)
542+
mtrx_params_to_use = _params_to_array(
543+
[
544+
[p.mtrx_params[t + s][s][i] for i in range(num_params)]
545+
for s in range(p.S)
546+
],
547+
p.tax_func_type,
548+
)
549+
mtry_params_to_use = _params_to_array(
550+
[
551+
[p.mtry_params[t + s][s][i] for i in range(num_params)]
552+
for s in range(p.S)
553+
],
554+
p.tax_func_type,
555+
)
536556

537557
solutions = opt.root(
538558
twist_doughnut,

ogcore/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,4 @@
2121
from ogcore.txfunc import * # noqa: F403
2222
from ogcore.utils import * # noqa: F403
2323

24-
__version__ = "0.15.11"
24+
__version__ = "0.15.12"

ogcore/household.py

Lines changed: 23 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,25 @@
1818
"""
1919

2020

21+
def _get_e_long(p):
22+
"""Return ``p.e`` extended for the TPI transition window.
23+
24+
``e_long`` is a pure function of ``p.e`` / ``p.S`` / ``p.J`` -- none of
25+
which change during a solve -- so it is built once and cached on the
26+
parameters object. ``FOC_savings`` and ``FOC_labor`` previously rebuilt
27+
this array on every call, which profiling identified as the single most
28+
expensive operation in a TPI run.
29+
"""
30+
e_long = getattr(p, "_e_long_cache", None)
31+
if e_long is None:
32+
e_long = np.concatenate(
33+
(p.e, np.tile(p.e[-1, :, :].reshape(1, p.S, p.J), (p.S, 1, 1))),
34+
axis=0,
35+
)
36+
p._e_long_cache = e_long
37+
return e_long
38+
39+
2140
def marg_ut_cons(c, sigma):
2241
r"""
2342
Compute the marginal utility of consumption.
@@ -493,13 +512,7 @@ def FOC_savings(
493512
]
494513
income_tax_filer = p.income_tax_filer[t : t + length, j]
495514
wealth_tax_filer = p.wealth_tax_filer[t : t + length, j]
496-
e_long = np.concatenate(
497-
(
498-
p.e,
499-
np.tile(p.e[-1, :, :].reshape(1, p.S, p.J), (p.S, 1, 1)),
500-
),
501-
axis=0,
502-
)
515+
e_long = _get_e_long(p)
503516
e = np.diag(e_long[t : t + p.S, :, j], max(p.S - length, 0))
504517
else:
505518
chi_b = p.chi_b
@@ -521,13 +534,7 @@ def FOC_savings(
521534
]
522535
income_tax_filer = p.income_tax_filer[t : t + length, :]
523536
wealth_tax_filer = p.wealth_tax_filer[t : t + length, :]
524-
e_long = np.concatenate(
525-
(
526-
p.e,
527-
np.tile(p.e[-1, :, :].reshape(1, p.S, p.J), (p.S, 1, 1)),
528-
),
529-
axis=0,
530-
)
537+
e_long = _get_e_long(p)
531538
e = np.diag(e_long[t : t + p.S, :, :], max(p.S - length, 0))
532539
e = np.squeeze(e)
533540
if method == "SS":
@@ -707,13 +714,7 @@ def FOC_labor(
707714
t : t + length, j
708715
]
709716
income_tax_filer = p.income_tax_filer[t : t + length, j]
710-
e_long = np.concatenate(
711-
(
712-
p.e,
713-
np.tile(p.e[-1, :, :].reshape(1, p.S, p.J), (p.S, 1, 1)),
714-
),
715-
axis=0,
716-
)
717+
e_long = _get_e_long(p)
717718
e = np.diag(e_long[t : t + p.S, :, j], max(p.S - length, 0))
718719
else:
719720
if method == "SS":
@@ -729,13 +730,7 @@ def FOC_labor(
729730
t : t + length, :
730731
]
731732
income_tax_filer = p.income_tax_filer[t : t + length, :]
732-
e_long = np.concatenate(
733-
(
734-
p.e,
735-
np.tile(p.e[-1, :, :].reshape(1, p.S, p.J), (p.S, 1, 1)),
736-
),
737-
axis=0,
738-
)
733+
e_long = _get_e_long(p)
739734
e = np.diag(e_long[t : t + p.S, :, j], max(p.S - length, 0))
740735
if method == "TPI":
741736
if b.ndim == 2:

ogcore/txfunc.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,9 @@ def get_tax_rates(
8383
income = X + Y
8484
if tax_func_type != "mono":
8585
# easier to use arrays for calculations below, except when
86-
# can't (bc lists of functions)
87-
params = np.array(params)
86+
# can't (bc lists of functions). asarray avoids a copy when the
87+
# caller already passes an array (the hot TPI path does).
88+
params = np.asarray(params)
8889
if tax_func_type == "GS":
8990
phi0, phi1, phi2 = (
9091
np.squeeze(params[..., 0]),

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "ogcore"
7-
version = "0.15.11"
7+
version = "0.15.12"
88
authors = [
99
{name = "Jason DeBacker and Richard W. Evans"},
1010
]

tests/test_TPI.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
- test_get_initial_SS_values(), 3 parameterizations
55
- test_firstdoughnutring(), 1 parameterization
66
- test_twist_doughnut(), 2 parameterizations
7+
- test_params_to_array(), 7 parameterizations
78
- test_inner_loop(), 1 parameterization
89
- test_run_TPI_full_run(), 11 parameterizations, local only
910
- test_run_TPI(), 2 parameterizations, local only
@@ -364,6 +365,26 @@ def test_twist_doughnut(file_inputs, file_outputs):
364365
assert np.allclose(np.array(test_list), np.array(expected_list), atol=1e-5)
365366

366367

368+
@pytest.mark.parametrize(
369+
"tax_func_type",
370+
["DEP", "DEP_totalinc", "GS", "HSV", "linear", "mono", "mono2D"],
371+
ids=["DEP", "DEP_totalinc", "GS", "HSV", "linear", "mono", "mono2D"],
372+
)
373+
def test_params_to_array(tax_func_type):
374+
# Test TPI._params_to_array helper. For numeric tax functions the
375+
# nested list is converted to a numpy array; for mono/mono2D (which
376+
# store callables, not numbers) the nested list is returned unchanged.
377+
nested = [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]
378+
result = TPI._params_to_array(nested, tax_func_type)
379+
if tax_func_type in ("mono", "mono2D"):
380+
assert result is nested
381+
else:
382+
expected = np.array(nested)
383+
assert isinstance(result, np.ndarray)
384+
assert np.array_equal(result, expected)
385+
assert result.dtype == expected.dtype
386+
387+
367388
def test_inner_loop():
368389
# Test TPI.inner_loop function. Provide inputs to function and
369390
# ensure that output returned matches what it has been before.

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)