Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow user to supply a c++ header file #464

Merged
merged 5 commits into from
Oct 11, 2021
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 49 additions & 11 deletions cmdstanpy/compiler_opts.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,27 +48,30 @@ class CompilerOptions:
Attributes:
stanc_options - stanc compiler flags, options
cpp_options - makefile options (NAME=value)
user_header - path to a user .hpp file to include during compilation
"""

def __init__(
self,
*,
stanc_options: Optional[Dict[str, Any]] = None,
cpp_options: Optional[Dict[str, Any]] = None,
user_header: Optional[str] = None,
logger: Optional[logging.Logger] = None,
) -> None:
"""Initialize object."""
self._stanc_options = stanc_options if stanc_options is not None else {}
self._cpp_options = cpp_options if cpp_options is not None else {}
self._user_header = user_header if user_header is not None else ''
if logger is not None:
get_logger().warning(
"Parameter 'logger' is deprecated."
" Control logging behavior via logging.getLogger('cmdstanpy')"
)

def __repr__(self) -> str:
return 'stanc_options={}, cpp_options={}'.format(
self._stanc_options, self._cpp_options
return 'stanc_options={}, cpp_options={}, user_header={}'.format(
self._stanc_options, self._cpp_options, self._user_header
)

@property
Expand All @@ -81,13 +84,19 @@ def cpp_options(self) -> Dict[str, Union[bool, int]]:
"""C++ compiler options."""
return self._cpp_options

@property
def user_header(self) -> str:
"""The user header file if it exists, otherwise empty"""
return self._user_header

def validate(self) -> None:
"""
Check compiler args.
Raise ValueError if invalid options are found.
"""
self.validate_stanc_opts()
self.validate_cpp_opts()
self.validate_user_header()

def validate_stanc_opts(self) -> None:
"""
Expand All @@ -104,17 +113,15 @@ def validate_stanc_opts(self) -> None:
get_logger().info('ignoring compiler option: %s', key)
ignore.append(key)
elif key not in STANC_OPTS:
raise ValueError(
'unknown stanc compiler option: {}'.format(key)
)
raise ValueError(f'unknown stanc compiler option: {key}')
elif key == 'include_paths':
paths = val
if isinstance(val, str):
paths = val.split(',')
elif not isinstance(val, list):
raise ValueError(
'Invalid include_paths, expecting list or '
'string, found type: {}.'.format(type(val))
f'string, found type: {type(val)}.'
)
elif key == 'use-opencl':
if self._cpp_options is None:
Expand Down Expand Up @@ -149,10 +156,37 @@ def validate_cpp_opts(self) -> None:
val = self._cpp_options[key]
if not isinstance(val, int) or val < 0:
raise ValueError(
'{} must be a non-negative integer value,'
' found {}.'.format(key, val)
f'{key} must be a non-negative integer value,'
f' found {val}.'
)

def validate_user_header(self) -> None:
"""
User header exists.
Raise ValueError if bad config is found.
"""
if self._user_header != "":
if not (
os.path.exists(self._user_header)
and os.path.isfile(self._user_header)
):
raise ValueError(
f"User header file {self._user_header} cannot be found"
)
if self._user_header[-4:] != '.hpp':
raise ValueError(
f"Header file must end in .hpp, got {self._user_header}"
)
if "allow_undefined" not in self._stanc_options:
self._stanc_options["allow_undefined"] = True
# set full path
self._user_header = os.path.abspath(self._user_header)

if ' ' in self._user_header:
mitzimorris marked this conversation as resolved.
Show resolved Hide resolved
raise ValueError(
"User header must be in a folder with no spaces in path!"
)

def add(self, new_opts: "CompilerOptions") -> None: # noqa: disable=Q000
"""Adds options to existing set of compiler options."""
if new_opts.stanc_options is not None:
Expand All @@ -167,6 +201,8 @@ def add(self, new_opts: "CompilerOptions") -> None: # noqa: disable=Q000
if new_opts.cpp_options is not None:
for key, val in new_opts.cpp_options.items():
self._cpp_options[key] = val
if new_opts.user_header != '' and self._user_header == '':
self._user_header = new_opts.user_header

def add_include_path(self, path: str) -> None:
"""Adds include path to existing set of compiler options."""
Expand All @@ -191,10 +227,12 @@ def compose(self) -> List[str]:
)
)
elif key == 'name':
opts.append('STANCFLAGS+=--{}={}'.format(key, val))
opts.append(f'STANCFLAGS+=--name={val}')
else:
opts.append('STANCFLAGS+=--{}'.format(key))
opts.append(f'STANCFLAGS+=--{key}')
if self._cpp_options is not None and len(self._cpp_options) > 0:
for key, val in self._cpp_options.items():
opts.append('{}={}'.format(key, val))
opts.append(f'{key}={val}')
if self._user_header:
opts.append(f'USER_HEADER={self._user_header}')
return opts
29 changes: 26 additions & 3 deletions cmdstanpy/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ class CmdStanModel:
:param cpp_options: Options for C++ compiler, specified as a Python
dictionary containing C++ compiler option name, value pairs.
Optional.

:param user_header: A path to a header file to include during C++
compilation.
Optional.
"""

def __init__(
Expand All @@ -86,6 +90,7 @@ def __init__(
compile: bool = True,
stanc_options: Optional[Dict[str, Any]] = None,
cpp_options: Optional[Dict[str, Any]] = None,
user_header: Optional[str] = None,
logger: Optional[logging.Logger] = None,
) -> None:
"""
Expand All @@ -97,12 +102,16 @@ def __init__(
:param compile: Whether or not to compile the model.
:param stanc_options: Options for stanc compiler.
:param cpp_options: Options for C++ compiler.
:param user_header: A path to a header file to include during C++
compilation.
"""
self._name = ''
self._stan_file = None
self._exe_file = None
self._compiler_options = CompilerOptions(
stanc_options=stanc_options, cpp_options=cpp_options
stanc_options=stanc_options,
cpp_options=cpp_options,
user_header=user_header,
)
if logger is not None:
get_logger().warning(
Expand Down Expand Up @@ -235,6 +244,11 @@ def cpp_options(self) -> Dict[str, Union[bool, int]]:
"""Options to C++ compilers."""
return self._compiler_options._cpp_options

@property
def user_header(self) -> str:
"""The user header file if it exists, otherwise empty"""
return self._compiler_options._user_header

def code(self) -> Optional[str]:
"""Return Stan program as a string."""
if not self._stan_file:
Expand All @@ -255,6 +269,7 @@ def compile(
force: bool = False,
stanc_options: Optional[Dict[str, Any]] = None,
cpp_options: Optional[Dict[str, Any]] = None,
user_header: Optional[str] = None,
override_options: bool = False,
) -> None:
"""
Expand All @@ -272,6 +287,8 @@ def compile(

:param stanc_options: Options for stanc compiler.
:param cpp_options: Options for C++ compiler.
:param user_header: A path to a header file to include during C++
compilation.

:param override_options: When ``True``, override existing option.
When ``False``, add/replace existing options. Default is ``False``.
Expand All @@ -280,9 +297,15 @@ def compile(
raise RuntimeError('Please specify source file')

compiler_options = None
if not (stanc_options is None and cpp_options is None):
if not (
stanc_options is None
and cpp_options is None
and user_header is None
):
compiler_options = CompilerOptions(
stanc_options=stanc_options, cpp_options=cpp_options
stanc_options=stanc_options,
cpp_options=cpp_options,
user_header=user_header,
)
compiler_options.validate()
if self._compiler_options is None:
Expand Down
1 change: 1 addition & 0 deletions docsrc/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ __________________
examples/Maximum Likelihood Estimation.ipynb
examples/Variational Inference.ipynb
examples/Run Generated Quantities.ipynb
examples/Using External C++.ipynb
3 changes: 2 additions & 1 deletion docsrc/examples/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@
*.hpp
*.exe
*.csv
.ipynb_checkpoints
.ipynb_checkpoints
!make_odds.hpp
145 changes: 145 additions & 0 deletions docsrc/examples/Using External C++.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
{
"cells": [
{
"cell_type": "markdown",
"source": [
"# Advanced Topic: Using External C++ Functions\n",
"\n",
"This is based on the relevant portion of the CmdStan documentation [here](https://mc-stan.org/docs/cmdstan-guide/using-external-cpp-code.html)"
],
"metadata": {}
},
{
"cell_type": "markdown",
"source": [
"Consider the following Stan model, based on the bernoulli example."
],
"metadata": {}
},
{
"cell_type": "code",
"execution_count": null,
"source": [
"import os\n",
"from cmdstanpy import cmdstan_path, CmdStanModel\n",
"model_external = CmdStanModel(stan_file='bernoulli_external.stan', compile=False)\n",
"print(model_external.code())"
],
"outputs": [],
"metadata": {}
},
{
"cell_type": "markdown",
"source": [
"As you can see, it features a function declaration for `make_odds`, but no definition. If we try to compile this, we will get an error. "
],
"metadata": {}
},
{
"cell_type": "code",
"execution_count": null,
"source": [
"model_external.compile()"
],
"outputs": [],
"metadata": {}
},
{
"cell_type": "markdown",
"source": [
"Even enabling the `--allow_undefined` flag to stanc3 will not allow this model to be compiled quite yet."
],
"metadata": {}
},
{
"cell_type": "code",
"execution_count": null,
"source": [
"model_external.compile(stanc_options={'allow_undefined':True})"
],
"outputs": [],
"metadata": {}
},
{
"cell_type": "markdown",
"source": [
"To resolve this, we need to both tell the Stan compiler an undefined function is okay **and** let C++ know what it should be. \n",
"\n",
"We can provide a definition in a C++ header file by using the `user_header` argument to either the CmdStanModel constructor or the `compile` method. \n",
"\n",
"This will enables the `allow_undefined` flag automatically."
],
"metadata": {}
},
{
"cell_type": "code",
"execution_count": null,
"source": [
"model_external.compile(user_header='make_odds.hpp')"
],
"outputs": [],
"metadata": {}
},
{
"cell_type": "markdown",
"source": [
"We can then run this model and inspect the output"
],
"metadata": {}
},
{
"cell_type": "code",
"execution_count": null,
"source": [
"fit = model_external.sample(data={'N':10, 'y':[0,1,0,0,0,0,0,0,0,1]})\n",
"fit.stan_variable('odds')"
],
"outputs": [],
"metadata": {}
},
{
"cell_type": "markdown",
"source": [
"The contents of this header file are a bit complicated unless you are familiar with the C++ internals of Stan, so they are presented without comment:\n",
"\n",
"```c++\n",
"#include <boost/math/tools/promotion.hpp>\n",
"#include <ostream>\n",
"\n",
"namespace bernoulli_model_namespace {\n",
" template <typename T0__> inline typename\n",
" boost::math::tools::promote_args<T0__>::type \n",
" make_odds(const T0__& theta, std::ostream* pstream__) {\n",
" return theta / (1 - theta); \n",
" }\n",
"}\n",
"```"
],
"metadata": {}
}
],
"metadata": {
"orig_nbformat": 4,
"language_info": {
"name": "python",
"version": "3.9.5",
"mimetype": "text/x-python",
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"pygments_lexer": "ipython3",
"nbconvert_exporter": "python",
"file_extension": ".py"
},
"kernelspec": {
"name": "python3",
"display_name": "Python 3.9.5 64-bit ('stan': conda)"
},
"interpreter": {
"hash": "d31ce8e45781476cfd394e192e0962028add96ff436d4fd4e560a347d206b9cb"
}
},
"nbformat": 4,
"nbformat_minor": 2
}
Loading