"""
Implement numba JIT decorators used to speed-up the execution
of Tax-Calculator functions in the calcfunctions.py module.
"""
# CODING-STYLE CHECKS:
# pycodestyle decorators.py
# pylint --disable=locally-disabled decorators.py
import os
import sys
import io
import ast
import time
import shutil
import inspect
import hashlib
import tempfile
import functools
import numpy as np
import numba
from numba.misc.appdirs import user_cache_dir
from taxcalc.policy import Policy
DO_JIT = True
# One way to use the Python debugger is to do these two things:
# (a) change the line immediately above this comment from
# "DO_JIT = True" to "DO_JIT = False", and
# (b) import pdb package and call pdb.set_trace() in either the
# calculator.py or calcfunctions.py file.
[docs]
def id_wrapper(*dec_args, **dec_kwargs): # pylint: disable=unused-argument
"""
Function wrapper when numba package is not being used during debugging.
"""
def wrap(fnc):
"""
wrap function nested in id_wrapper function.
"""
def wrapped_f(*args, **kwargs):
"""
wrapped_f function nested in wrap function.
"""
return fnc(*args, **kwargs)
return wrapped_f
return wrap
# Functions defined in the CACHED_MODULE, and the apply-style functions
# made from them, have their JIT-compiled code cached on disk in a folder
# whose name is a hash of the source code the compiled code depends on.
CACHED_MODULE = "taxcalc.calcfunctions"
# Cache folders for other source hashes that have not been used for more
# than JIT_CACHE_MAX_AGE_DAYS days are deleted when taxcalc is imported.
JIT_CACHE_MAX_AGE_DAYS = 90
def jit_cache_root():
"""
Return path of folder that holds the on-disk cache of JIT-compiled
functions, which is the TAXCALC_JIT_CACHE_DIR environment variable
value if set, otherwise the taxcalc subfolder of the NUMBA_CACHE_DIR
folder if set, otherwise the taxcalc folder in the user's cache folder.
"""
root = os.environ.get("TAXCALC_JIT_CACHE_DIR")
if root:
return root
numba_root = os.environ.get("NUMBA_CACHE_DIR")
if numba_root:
return os.path.join(numba_root, "taxcalc")
return user_cache_dir(appname="taxcalc", appauthor=False, opinion=False)
def source_hash():
"""
Return hash of the calcfunctions.py and decorators.py source code
and of the Python, NumPy, and Numba versions, all of which affect the
JIT-compiled code. Any change in these produces a new hash value.
"""
hsh = hashlib.sha256()
here = os.path.dirname(os.path.abspath(__file__))
for fname in ("calcfunctions.py", "decorators.py"):
with open(os.path.join(here, fname), "rb") as sfile:
hsh.update(sfile.read())
for version in (sys.version, np.__version__, numba.__version__):
hsh.update(version.encode())
return hsh.hexdigest()[:16]
def prune_jit_cache(root, current):
"""
Mark the current cache folder in the root folder as used now, and
delete the other cache folders in the root folder that have not
been used for more than JIT_CACHE_MAX_AGE_DAYS days. Only folders
whose names look like a source_hash() value are ever deleted.
"""
os.utime(os.path.join(root, current))
oldest = time.time() - JIT_CACHE_MAX_AGE_DAYS * 24 * 60 * 60
hexdigits = set("0123456789abcdef")
for entry in os.scandir(root):
if (
entry.name == current or
len(entry.name) != len(current) or
not set(entry.name) <= hexdigits or
not entry.is_dir(follow_symlinks=False)
):
continue
if entry.stat(follow_symlinks=False).st_mtime < oldest:
shutil.rmtree(entry.path, ignore_errors=True)
@functools.cache
def jit_cache_class():
"""
Return the Numba cache class that stores the compiled code in the
jit_cache_root()/source_hash() folder, or None if that folder is not
writable or if Numba does not provide the internal classes needed to
construct the cache class.
"""
# pylint: disable=import-outside-toplevel
try:
from numba.core.caching import (
_CacheLocator, CompileResultCacheImpl, FunctionCache
)
except ImportError:
return None
root = jit_cache_root()
current = source_hash()
cache_path = os.path.join(root, current)
try:
os.makedirs(cache_path, exist_ok=True)
tempfile.TemporaryFile(dir=cache_path).close()
except OSError:
return None
try:
prune_jit_cache(root, current)
except OSError:
pass
class Locator(_CacheLocator):
"""
Numba cache locator that puts every cached function in the
cache_path folder, which changes whenever the source code the
compiled code depends on changes.
"""
def __init__(self, py_func):
self._tag = py_func.taxcalc_cache_tag
def get_cache_path(self):
return cache_path
def get_source_stamp(self):
return os.path.basename(cache_path)
def get_disambiguator(self):
return self._tag
@classmethod
def from_function(cls, py_func, py_file):
if not hasattr(py_func, "taxcalc_cache_tag"):
return None
return cls(py_func)
class CacheImpl(CompileResultCacheImpl):
"""
Numba cache implementation that uses only the Locator class.
"""
_locator_classes = [Locator]
class Cache(FunctionCache):
"""
Numba function cache that uses the CacheImpl class.
"""
_impl_class = CacheImpl
return Cache
def enable_jit_cache(dispatcher, tag):
"""
Turn on on-disk caching of the specified Numba dispatcher's compiled
code, where tag uniquely identifies the dispatcher's function. If
the cache folder is not writable, the dispatcher is left uncached.
"""
cache_class = jit_cache_class()
if cache_class is None:
return
dispatcher.py_func.taxcalc_cache_tag = tag
try:
# pylint: disable=protected-access
dispatcher._cache = cache_class(dispatcher.py_func)
except (OSError, RuntimeError):
pass
def cached_jit(cache_tag=None, **kwargs):
"""
Return a decorator that JIT-compiles a function using numba.jit with
the specified kwargs and caches the compiled code on disk if the
function is defined in the CACHED_MODULE or if cache_tag is not None.
"""
def wrap(func):
"""
wrap function nested in cached_jit function.
"""
dispatcher = numba.jit(**kwargs)(func)
tag = cache_tag
if tag is None and func.__module__ == CACHED_MODULE:
tag = func.__qualname__
if tag is not None:
enable_jit_cache(dispatcher, tag)
return dispatcher
return wrap
if DO_JIT is False or "NOTAXCALCJIT" in os.environ:
JIT = id_wrapper
else:
JIT = cached_jit
[docs]
class GetReturnNode(ast.NodeVisitor):
"""
A NodeVisitor to get the return tuple names from a calc-style function.
"""
[docs]
def visit_Return(self, node): # pylint: disable=invalid-name
"""
visit_Return is used by NodeVisitor.visit method.
"""
if isinstance(node.value, ast.Tuple):
return [e.id for e in node.value.elts]
return [node.value.id]
[docs]
def create_apply_function_string(sigout, sigin, parameters):
"""
Create a string for a function of the form::
def ap_fuc(x_0, x_1, x_2, ...):
for i in range(len(x_0)):
x_0[i], ... = jitted_f(x_j[i], ...)
return x_0[i], ...
where the specific args to jitted_f and the number of
values to return is determined by sigout and sigin.
Parameters
----------
sigout: iterable of the out arguments
sigin: iterable of the in arguments
parameters: iterable of which of the args (from in_args) are parameter
variables (as opposed to column records). This influences
how we construct the apply-style function
Returns
-------
a String representing the function
"""
fstr = io.StringIO()
total_len = len(sigout) + len(sigin)
out_args = ["x_" + str(i) for i in range(0, len(sigout))]
in_args = ["x_" + str(i) for i in range(len(sigout), total_len)]
all_args = ",".join(out_args + in_args)
fstr.write(f"def ap_func({all_args}):\n")
fstr.write(" for i in range(len(x_0)):\n")
out_index = [x + "[i]" for x in out_args]
in_index = []
for arg, _var in zip(in_args, sigin):
in_index.append(arg + "[i]" if _var not in parameters else arg)
fstr.write(" " + ",".join(out_index) + " = ")
fstr.write("jitted_f(" + ",".join(in_index) + ")\n")
fstr.write(" return " + ",".join(out_args) + "\n")
return fstr.getvalue()
[docs]
def create_toplevel_function_string(args_out, args_in, pm_or_pf):
"""
Create a string for a function of the form:
def hl_func(x_0, x_1, x_2, ...):
outputs = (...) = calc_func(...)
header = [...]
return DataFrame(data, columns=header)
Parameters
----------
args_out: iterable of the out arguments
args_in: iterable of the in arguments
pm_or_pf: iterable of strings for object that holds each arg
Returns
-------
a String representing the function
"""
fstr = io.StringIO()
fstr.write("def hl_func(pm, pf")
fstr.write("):\n")
fstr.write(" from pandas import DataFrame\n")
fstr.write(" import numpy as np\n")
fstr.write(" import pandas as pd\n")
fstr.write(" def get_values(x):\n")
fstr.write(" if isinstance(x, pd.Series):\n")
fstr.write(" return x.values\n")
fstr.write(" else:\n")
fstr.write(" return x\n")
fstr.write(" outputs = \\\n")
outs = []
for ppp, attr in zip(pm_or_pf, args_out + args_in):
outs.append(ppp + "." + attr + ", ")
outs = [m_or_f + "." + arg for m_or_f, arg in zip(pm_or_pf, args_out)]
fstr.write(" (" + ", ".join(outs) + ") = \\\n")
fstr.write(" " + "applied_f(")
for ppp, attr in zip(pm_or_pf, args_out + args_in):
# Bring Policy parameter values down a dimension.
if ppp == "pm":
attr += "[0]"
fstr.write("get_values(" + ppp + "." + attr + ")" + ", ")
fstr.write(")\n")
fstr.write(" header = [")
col_headers = ["'" + out + "'" for out in args_out]
fstr.write(", ".join(col_headers))
fstr.write("]\n")
if len(args_out) == 1:
fstr.write(" return DataFrame(data=outputs,"
"columns=header)")
else:
fstr.write(" return DataFrame(data=np.column_stack("
"outputs),columns=header)")
return fstr.getvalue()
[docs]
def make_apply_function(func, out_args, in_args, parameters,
do_jit=DO_JIT, **kwargs):
"""
Takes a calc-style function and creates the necessary Python code for
an apply-style function. Will also jit the function if desired.
Parameters
----------
func: the calc-style function
out_args: list of out arguments for the apply-style function
in_args: list of in arguments for the apply-style function
parameters: iterable of which of the args (from in_args) are parameter
variables (as opposed to column records). This influences
how we construct the apply-style function.
do_jit: Bool, if True, jit the resulting apply-style function
Returns
-------
apply-style function
"""
if do_jit:
jitted_f = JIT(**kwargs)(func)
else:
jitted_f = func
apfunc = create_apply_function_string(out_args, in_args, parameters)
func_code = compile(apfunc, "<string>", "exec")
fakeglobals = {}
# __name__ must be an importable module for Numba to load cached code
eval(func_code, # pylint: disable=eval-used
{"jitted_f": jitted_f, "__name__": __name__}, fakeglobals)
if do_jit:
cache_tag = None
if func.__module__ == CACHED_MODULE:
aphash = hashlib.sha256(apfunc.encode()).hexdigest()[:12]
cache_tag = f"ap_{func.__name__}_{aphash}"
return JIT(cache_tag=cache_tag, **kwargs)(fakeglobals["ap_func"])
return fakeglobals["ap_func"]
[docs]
def apply_jit(dtype_sig_out, dtype_sig_in, parameters=None, **kwargs):
"""
Make a decorator that takes in a calc-style function, handle apply step.
"""
if not parameters:
parameters = []
def make_wrapper(func):
"""
make_wrapper function nested in apply_jit function.
"""
theargs = inspect.getfullargspec(func).args
jitted_apply = make_apply_function(func, dtype_sig_out,
dtype_sig_in, parameters, **kwargs)
def wrapper(*args):
"""
wrapper function nested in make_wrapper function.
"""
in_arrays = []
out_arrays = []
for farg in theargs:
if hasattr(args[0], farg):
in_arrays.append(getattr(args[0], farg))
else:
in_arrays.append(getattr(args[1], farg))
for farg in dtype_sig_out:
if hasattr(args[0], farg):
out_arrays.append(getattr(args[0], farg))
else:
out_arrays.append(getattr(args[1], farg))
final_array = out_arrays + in_arrays
ans = jitted_apply(*final_array)
return ans
return wrapper
return make_wrapper
[docs]
def iterate_jit(parameters=None, **kwargs):
"""
Public decorator for a calc-style function (see calcfunctions.py) that
transforms the calc-style function into an apply-style function that
can be called by Calculator class methods (see calculator.py).
"""
if not parameters:
parameters = []
def make_wrapper(func):
"""
make_wrapper function nested in iterate_jit decorator
wraps specified func using apply_jit.
"""
# pylint: disable=too-many-locals
# Get the input arguments from the function
in_args = inspect.getfullargspec(func).args
# Get the numba.jit arguments
jit_args_list = inspect.getfullargspec(numba.jit).args + ["nopython"]
kwargs_for_jit = {}
for key, val in kwargs.items():
if key in jit_args_list:
kwargs_for_jit[key] = val
# Any name that is a parameter
# Boolean flag is given special treatment.
# Identify those names here
param_list = Policy.parameter_list()
allowed_parameters = param_list
allowed_parameters += list(arg[1:] for arg in param_list)
additional_parameters = [arg for arg in in_args if
arg in allowed_parameters]
additional_parameters += parameters
# Remote duplicates
all_parameters = list(set(additional_parameters))
src = inspect.getsourcelines(func)[0]
# Discover the return arguments by walking
# the AST of the function
grn = GetReturnNode()
all_out_args = None
for node in ast.walk(ast.parse("".join(src))):
all_out_args = grn.visit(node)
if all_out_args:
break
if not all_out_args:
raise ValueError("Can't find return statement in function!")
# Now create the apply-style possibly-jitted function
applied_jitted_f = make_apply_function(func,
list(reversed(all_out_args)),
in_args,
parameters=all_parameters,
do_jit=DO_JIT,
**kwargs_for_jit)
def wrapper(*args, **kwargs):
"""
wrapper function nested in make_wrapper function nested
in iterate_jit decorator.
"""
# os TESTING environment only accepts string arguments
if os.getenv("TESTING") == "True":
return func(*args, **kwargs)
in_arrays = []
pm_or_pf = []
for farg in all_out_args + in_args:
if hasattr(args[0], farg):
in_arrays.append(getattr(args[0], farg))
pm_or_pf.append("pm")
elif hasattr(args[1], farg):
in_arrays.append(getattr(args[1], farg))
pm_or_pf.append("pf")
# Create the high level function
high_level_func = create_toplevel_function_string(all_out_args,
list(in_args),
pm_or_pf)
func_code = compile(high_level_func, "<string>", "exec")
fakeglobals = {}
eval(func_code, # pylint: disable=eval-used
{"applied_f": applied_jitted_f}, fakeglobals)
high_level_fn = fakeglobals["hl_func"]
ans = high_level_fn(*args, **kwargs)
return ans
return wrapper
return make_wrapper