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

Improve Handling of cpp_options #1022

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
6 changes: 0 additions & 6 deletions R/args.R
Original file line number Diff line number Diff line change
Expand Up @@ -715,12 +715,6 @@ validate_cmdstan_args <- function(self) {
}
validate_init(self$init, num_inits)
validate_seed(self$seed, num_procs)
if (!is.null(self$opencl_ids)) {
if (cmdstan_version() < "2.26") {
stop("Runtime selection of OpenCL devices is only supported with CmdStan version 2.26 or newer.", call. = FALSE)
}
checkmate::assert_vector(self$opencl_ids, len = 2)
}
invisible(TRUE)
}

Expand Down
353 changes: 277 additions & 76 deletions R/model.R

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion R/path.R
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,16 @@ unset_cmdstan_path <- function() {
}

# fake a cmdstan version (only used in tests)
fake_cmdstan_version <- function(version) {
fake_cmdstan_version <- function(version, mod=NULL) {
.cmdstanr$VERSION <- version
if(!is.null(mod)) {
if (!is.null(mod$.__enclos_env__$private$exe_info_)) {
mod$.__enclos_env__$private$exe_info_$stan_version <- version
}
if (!is.null(mod$.__enclos_env__$private$cmdstan_version_)) {
mod$.__enclos_env__$private$cmdstan_version_ <- version
}
}
}
reset_cmdstan_version <- function() {
.cmdstanr$VERSION <- read_cmdstan_version(cmdstan_path())
Expand Down
7 changes: 4 additions & 3 deletions man/model-method-compile.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions tests/testthat/helper-custom-expectations.R
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,11 @@ expect_noninteractive_silent <- function(object) {
rlang::with_interactive(value = FALSE,
expect_silent(object))
}

expect_equal_ignore_order <- function(object, expected, ...){
object <- expected[sort(names(object))]
expected <- expected[sort(names(expected))]
expect_equal(object, expected, ...)
}

expect_not_true <- function(...) expect_false(isTRUE(...))
22 changes: 22 additions & 0 deletions tests/testthat/helper-mock-cli.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
real_wcr <- wsl_compatible_run

with_mocked_cli <- function(code, compile_ret, info_ret){
jgabry marked this conversation as resolved.
Show resolved Hide resolved
with_mocked_bindings(
code,
wsl_compatible_run = function(command, args, ...) {
if (
!is.null(command)
&& command == 'make'
&& !is.null(args)
&& startsWith(basename(args[1]), 'model-')
) {
message("mock-compile-was-called")
compile_ret
} else if (!is.null(args) && args[1] == "info") info_ret
else real_wcr(command = command, args = args, ...)
}
)
}

expect_mock_compile <- function(object, ...) expect_message(object, regexp = 'mock-compile-was-called', ...)
expect_no_mock_compile <- function(object, ...) expect_no_message(object, message = 'mock-compile-was-called' , ...)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to make sure I understand, this expects this to not say, "mock-compile-was-called"?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall what I'm trying accomplish here is basically mimic python's assert_called and assert_not_called. (https://docs.python.org/3/library/unittest.mock.html#unittest.mock.Mock.assert_called).

Logic is: expect_mock_compile will pass if mock_compile is called (at all, doesn't matter how many times) and fail if mock_compile is never called. expect_no_mock_compile is the inverse. It passes if mock_compile is not called at all and fails if mock_compile is called (even once).

Implementation is: mock_compile emits a message with the contents mock-compile-was-called. expect_mock_compile checks for this message: passes if it detects such a message, fails if it does not. expect_no_mock_compile fails if a message with exactly this text is detected and passes otherwise. A message with any other text does not impact expect_no_mock_compile.

There is probably a more elegant way to achieve this. Open to suggestions.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is probably a more elegant way to achieve this. Open to suggestions.

There may be, but I can't think of one at the moment. I would go ahead with this implementation, unless @SteveBronder has a better idea.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fine thanks for the clarification. Can you add a little comment (the above) just so people in the future can know what to expect?

5 changes: 5 additions & 0 deletions tests/testthat/helper-models.R
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ cmdstan_example_file <- function() {
file.path(cmdstan_path(), "examples", "bernoulli", "bernoulli.stan")
}

cmdstan_example_exe_file <- function() {
# stan program in different directory from the others
file.path(cmdstan_path(), "examples", "bernoulli", "bernoulli.stan")
}

testing_model <- function(name) {
cmdstan_model(stan_file = testing_stan_file(name))
}
Expand Down
2 changes: 1 addition & 1 deletion tests/testthat/test-example.R
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
context("cmdstanr_example")

test_that("cmdstanr_example works", {
fit_mcmc <- cmdstanr_example("logistic", chains = 2)
fit_mcmc <- cmdstanr_example("logistic", chains = 2, force_recompile = TRUE)
checkmate::expect_r6(fit_mcmc, "CmdStanMCMC")
expect_equal(fit_mcmc$num_chains(), 2)

Expand Down
104 changes: 104 additions & 0 deletions tests/testthat/test-model-compile-user_header.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@

file_that_exists <- 'placeholder_exists'
file_that_doesnt_exist <- 'placeholder_doesnt_exist'
file.create(file_that_exists)
on.exit(if(file.exists(file_that_exists)) file.remove(file_that_exists), add=TRUE, after=FALSE)

make_local_orig <- cmdstan_make_local()
cmdstan_make_local(cpp_options = list("PRECOMPILED_HEADERS"="false"))
on.exit(cmdstan_make_local(cpp_options = make_local_orig, append = FALSE), add = TRUE, after = FALSE)

test_that("cmdstan_model works with user_header with mock", {
skip_if(os_is_macos())
tmpfile <- tempfile(fileext = ".hpp")
hpp <-
"
#include <stan/math.hpp>
#include <boost/math/tools/promotion.hpp>
#include <ostream>

namespace bernoulli_external_model_namespace
{
template <typename T0__,
stan::require_all_t<stan::is_stan_scalar<T0__>>* = nullptr>
inline typename boost::math::tools::promote_args<T0__>::type make_odds(const T0__ &
theta,
std::ostream *pstream__)
{
return theta / (1 - theta);
}
}"
cat(hpp, file = tmpfile, sep = "\n")

with_mocked_cli(compile_ret = list(status = 0), info_ret = list(), code = expect_mock_compile(
expect_warning(
expect_no_warning({
mod <- cmdstan_model(
stan_file = testing_stan_file("bernoulli_external"),
exe_file = file_that_exists,
user_header = tmpfile
)
}, message = 'Recompiling is recommended'), # this warning should not occur because recompile happens automatically
'Retrieving exe_file info failed' # this warning should occur
)
))

with_mocked_cli(compile_ret = list(status = 0), info_ret = list(), code = expect_mock_compile({
mod_2 <- cmdstan_model(
stan_file = testing_stan_file("bernoulli_external"),
exe_file = file_that_doesnt_exist,
cpp_options=list(USER_HEADER=tmpfile),
stanc_options = list("allow-undefined")
)
}))

# Check recompilation upon changing header
file.create(file_that_exists)
with_mocked_cli(compile_ret = list(status = 0), info_ret = list(), code = expect_no_mock_compile({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we keep the line numbers to less than 85 or so? Runs over my screen here. @jgabry does cmdstanr have a linter or styler setup?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We haven't been running any linters on the code, although we could start doing that. Either way, I agree about keeping the lines short. There are likely some other places in the code where that needs to be fixed, although I've tried to keep them under 80 in the code I've written.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, since it doesn't seem like a linter was run in the past, I didn't run one on my code either, but that makes it surely inconsistent. I think there is a way to run linter just on the diff so at least we don't make the problem worse. I can look into it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is a way to run linter just on the diff so at least we don't make the problem worse. I can look into it.

If that's simple to do then that sounds good, otherwise don't worry about it. We can (and probably should) start running a linter on the whole package, maybe after merging this PR.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH - I wouldn't advise that. It basically creates a huge diff so your subsequent blames become less meaningful. I think there is a way to exclude certain commits from blame...but....

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm yeah that’s a good point

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We did this years ago in the stan math library. We essentially have a huge file that tells the compiler to ignore any auto changes.

For cmdstanr we could set up a hook so that the linter runs before any future commits. So we would put the one global change in the .git-blame-ignore-revs then have a git hook that runs precommit to run the linter

https://github.com/stan-dev/math/blob/develop/.git-blame-ignore-revs

But again, seperate issue from the PR

@katrinabrock if you can try to be mindful of not having too long of lines that would be great, but no need to go through counting column

mod$compile(quiet = TRUE, user_header = tmpfile)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I'm just not familiar with testthat's mocking schema. Is mod here the mod from line 36?

        mod <- cmdstan_model(
          stan_file = testing_stan_file("bernoulli_external"),
          exe_file = file_that_exists,
          user_header = tmpfile
        )

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

um....good question. I don't think so because that shouldn't be in scope anymore. Which begs the question "where is this mod object coming from. I'll look into it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lol thank you. Odd!!

}))

Sys.setFileTime(tmpfile, Sys.time() + 1) # touch file to trigger recompile
with_mocked_cli(compile_ret = list(status = 0), info_ret = list(), code = expect_mock_compile({
mod$compile(quiet = TRUE, user_header = tmpfile)
}))

# mock does not automatically update file mtime
Sys.setFileTime(mod$exe_file(), Sys.time() + 1) # touch file to trigger recompile

# Alternative spec of user header
with_mocked_cli(compile_ret = list(status = 0), info_ret = list(), code = expect_no_mock_compile({
mod$compile(
quiet = TRUE,
cpp_options = list(user_header = tmpfile),
dry_run = TRUE
)}))

# Error/warning messages
with_mocked_cli(compile_ret = list(status = 1), info_ret = list(), code = expect_error(
cmdstan_model(
stan_file = testing_stan_file("bernoulli_external"),
cpp_options = list(USER_HEADER = "non_existent.hpp"),
stanc_options = list("allow-undefined")
),
"header file '[^']*' does not exist"
))

with_mocked_cli(compile_ret = list(status = 1), info_ret = list(), code = expect_warning(
cmdstan_model(
stan_file = testing_stan_file("bernoulli_external"),
cpp_options = list(USER_HEADER = tmpfile, user_header = tmpfile),
dry_run = TRUE
),
"User header specified both"
))
with_mocked_cli(compile_ret = list(status = 1), info_ret = list(), code = expect_warning(
cmdstan_model(
stan_file = testing_stan_file("bernoulli_external"),
user_header = tmpfile,
cpp_options = list(USER_HEADER = tmpfile),
dry_run = TRUE
),
"User header specified both"
))
})
37 changes: 28 additions & 9 deletions tests/testthat/test-model-compile.R
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,25 @@ context("model-compile")

set_cmdstan_path()
stan_program <- cmdstan_example_file()
exe <- cmdstan_ext(strip_ext(stan_program))
if (file.exists(exe)) file.remove(exe)

mod <- cmdstan_model(stan_file = stan_program, compile = FALSE)

make_local_orig <- cmdstan_make_local()
cmdstan_make_local(cpp_options = list("PRECOMPILED_HEADERS"="false"))
on.exit(cmdstan_make_local(cpp_options = make_local_orig, append = FALSE), add = TRUE, after = FALSE)

test_that("object initialized correctly", {
expect_equal(mod$stan_file(), stan_program)
expect_equal(mod$exe_file(), character(0))
expect_equal(mod$exe_file(), exe)
expect_false(file.exists(mod$exe_file()))
expect_error(
mod$hpp_file(),
"The .hpp file does not exists. Please (re)compile the model.",
fixed = TRUE
)
})

test_that("error if no compile() before model fitting", {
expect_error(
mod$sample(),
Expand All @@ -25,7 +31,6 @@ test_that("error if no compile() before model fitting", {

test_that("compile() method works", {
# remove executable if exists
exe <- cmdstan_ext(strip_ext(mod$stan_file()))
if (file.exists(exe)) {
file.remove(exe)
}
Expand Down Expand Up @@ -381,7 +386,6 @@ test_that("check_syntax() works with pedantic=TRUE", {
fixed = TRUE
)
})

test_that("check_syntax() works with include_paths", {
stan_program_w_include <- testing_stan_file("bernoulli_include")

Expand All @@ -391,15 +395,20 @@ test_that("check_syntax() works with include_paths", {

})


# Test Failing Due to Side effect -----

test_that("check_syntax() works with include_paths on compiled model", {
stan_program_w_include <- testing_stan_file("bernoulli_include")

mod_w_include <- cmdstan_model(stan_file = stan_program_w_include, compile=TRUE,
include_paths = test_path("resources", "stan"))
include_paths = test_path("resources", "stan"),
force_recompile = TRUE)
Comment on lines 404 to +406
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the side effect at issue here? I am just reading the code right now but tomorrow should have time to run the tests

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😬 That's a mystery I didn't solve completely. Some test above was causing a side effect. (I think I have a version of the code somewhere that marked which test that was.) I realized actually there could be many such cases that are not caused by my change, but revealed by my change. In this situation (and some others), I "fixed" the problem by added force_recompile = TRUE to the impacted test instead of actually cleaning up the culprit test.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, based on looking at my git history for when I was attempting to debug this. This is the test causing this particular side effect:
https://github.com/stan-dev/cmdstanr/blob/master/tests/testthat/test-model-compile.R#L85

So if you uncomment force_recompile, there will be a failure. If you then comment out the above linked test, the later test will pass without recompilation. (If I recall correctly.)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh my. Okay it sounds like something is carrying state through the tests. I had another project yesterday to work on. Friday I can give this a whirl

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the thing that's carrying state through tests is the binary....because recompiling fixes it.

expect_true(mod_w_include$check_syntax())

})


test_that("check_syntax() works with pedantic=TRUE", {
model_code <- "
transformed data {
Expand Down Expand Up @@ -496,15 +505,22 @@ test_that("cpp_options work with settings in make/local", {

rebuild_cmdstan()
mod <- cmdstan_model(stan_file = stan_program)
expect_null(mod$cpp_options()$STAN_THREADS)
expect_null(
expect_warning(mod$cpp_options()$stan_threads, "Use mod\\$exe_info()")
)
expect_false(mod$exe_info()$stan_threads)
expect_null(mod$precompile_cpp_options()$stan_threads)

file.remove(mod$exe_file())

cmdstan_make_local(cpp_options = list(stan_threads = TRUE), append = TRUE)

file <- file.path(cmdstan_path(), "examples", "bernoulli", "bernoulli.stan")
mod <- cmdstan_model(file)
expect_true(mod$cpp_options()$STAN_THREADS)
expect_true(
expect_warning(mod$cpp_options()$stan_threads, "Use mod\\$exe_info()")
SteveBronder marked this conversation as resolved.
Show resolved Hide resolved
)
expect_true(mod$exe_info()$stan_threads)

file.remove(mod$exe_file())

Expand Down Expand Up @@ -761,7 +777,8 @@ test_that("format() works with include_paths on compiled model", {
stan_program_w_include <- testing_stan_file("bernoulli_include")

mod_w_include <- cmdstan_model(stan_file = stan_program_w_include, compile=TRUE,
include_paths = test_path("resources", "stan"))
include_paths = test_path("resources", "stan"),
force_recompile = TRUE)
expect_output(
mod_w_include$format(),
"#include ",
Expand Down Expand Up @@ -789,6 +806,8 @@ test_that("overwrite_file works with format()", {
}
"
stan_file_tmp <- write_stan_file(code)
on.exit(file.remove(stan_file_tmp))

mod_1 <- cmdstan_model(stan_file_tmp, compile = FALSE)
expect_false(
any(
Expand Down Expand Up @@ -852,4 +871,4 @@ test_that("STANCFLAGS included from make/local", {
}
expect_output(print(out), out_w_flags)
cmdstan_make_local(cpp_options = make_local_old, append = FALSE)
})
})
9 changes: 6 additions & 3 deletions tests/testthat/test-model-generate_quantities.R
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ bad_arg_values <- list(
parallel_chains = -20
)


test_that("generate_quantities() method runs when all arguments specified validly", {
# specifying all arguments validly
expect_gq_output(fit1 <- do.call(mod_gq$generate_quantities, ok_arg_values))
Expand Down Expand Up @@ -52,7 +51,11 @@ test_that("generate_quantities work for different chains and parallel_chains", {
expect_gq_output(
mod_gq$generate_quantities(data = data_list, fitted_params = fit, parallel_chains = 4)
)
mod_gq <- cmdstan_model(testing_stan_file("bernoulli_ppc"), cpp_options = list(stan_threads = TRUE))

expect_call_compilation({
mod_gq <- cmdstan_model(testing_stan_file("bernoulli_ppc"), cpp_options = list(stan_threads = TRUE))
})

expect_gq_output(
mod_gq$generate_quantities(data = data_list, fitted_params = fit_1_chain, threads_per_chain = 2)
)
Expand Down Expand Up @@ -91,4 +94,4 @@ test_that("generate_quantities() warns if threads specified but not enabled", {
expect_gq_output(fit_gq <- mod_gq$generate_quantities(data = data_list, fitted_params = fit, threads_per_chain = 4)),
"'threads_per_chain' will have no effect"
)
})
})
Loading
Loading