diff --git a/.github/workflows/header_checks.yml b/.github/workflows/header_checks.yml index 1fbd51db82c..1f3b3b91a30 100644 --- a/.github/workflows/header_checks.yml +++ b/.github/workflows/header_checks.yml @@ -39,13 +39,14 @@ jobs: Get-Command mingw32-make | Select-Object -ExpandProperty Definition shell: powershell - - name: Build Math libs - shell: powershell - run: mingw32-make -f make/standalone math-libs -j2 + - name: Install Ninja + run: python -m pip install ninja - name: Run header tests shell: powershell - run: make -j2 test-headers + run: | + cmake -S . -B build -G Ninja -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++ -DSTAN_BUILD_PROB_TESTS=OFF + cmake --build build --parallel 2 --target test-headers opencl: name: OpenCL @@ -57,7 +58,8 @@ jobs: - name: Run header tests run: | echo "STAN_OPENCL=true" > make/local - make -j2 test-headers + cmake -S . -B build -DSTAN_OPENCL=ON -DSTAN_BUILD_PROB_TESTS=OFF + cmake --build build --parallel 2 --target test-headers no_range_checks: name: NoRange runs-on: ubuntu-latest diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ccba1e931b3..8f34d0c9cd2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -48,25 +48,24 @@ jobs: with: r-version: 'release' - - name: Build Math libs + - name: Configure test tools shell: pwsh run: | - echo "$(pwd)/lib/tbb" | Out-File -Append -FilePath $env:GITHUB_PATH -Encoding utf8 + python -m pip install ninja Add-Content make\local "CPPFLAGS=-w -Wno-psabi -Wno-misleading-indentation`n" Add-Content make\local "STAN_THREADS=true`n" - make -f make/standalone math-libs -j2 - name: Run ${{ matrix.config.label }} unit tests shell: pwsh run: | - python runTests.py -j4 ${{ matrix.config.tests }} + python runTests.py --cmake-arg=-GNinja --cmake-arg=-DCMAKE_C_COMPILER=gcc --cmake-arg=-DCMAKE_CXX_COMPILER=g++ -j4 ${{ matrix.config.tests }} - name: Upload gtest_output xml uses: actions/upload-artifact@v7 if: failure() with: name: gtest_outputs_xml - path: '**/*_test.xml' + path: 'build/test-results/*.xml' mix-fun: name: mix fun and prob tests ${{ matrix.group }} (${{ matrix.os }}) @@ -91,13 +90,12 @@ jobs: r-version: 'release' - - name: Build Math libs + - name: Configure test tools shell: pwsh run: | - echo "$(pwd)/lib/tbb" | Out-File -Append -FilePath $env:GITHUB_PATH -Encoding utf8 + python -m pip install ninja Add-Content make\local "CPPFLAGS=-w -Wno-psabi -Wno-misleading-indentation`n" Add-Content make\local "STAN_THREADS=true`n" - make -f make/standalone math-libs -j2 - name: Run mix/fun unit tests shell: pwsh @@ -114,11 +112,11 @@ jobs: $MixFunProbTests[(4 * $FifthNumberTests)..($NumberTests - 1)] ` ) - python runTests.py -j2 $MixFunProbTestsArray[(${{ matrix.group }} - 1)] + python runTests.py --cmake-arg=-GNinja --cmake-arg=-DCMAKE_C_COMPILER=gcc --cmake-arg=-DCMAKE_CXX_COMPILER=g++ -j2 $MixFunProbTestsArray[(${{ matrix.group }} - 1)] - name: Upload gtest_output xml uses: actions/upload-artifact@v7 if: failure() with: name: gtest_outputs_xml - path: '**/*_test.xml' + path: 'build/test-results/*.xml' diff --git a/.gitignore b/.gitignore index c36591f03d8..7ba777a5e88 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,9 @@ lib/tbb/** # gdb debug history .gdb_history + +# CMake build trees +/build/ +/build-*/ +/.build/ +/.build-*/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 00000000000..e3c1b93dff0 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,192 @@ +cmake_minimum_required(VERSION 3.16.3) +include(cmake/MakeLocal.cmake) +# ReleaseTesting optimizes like Release but keeps assertions on (no NDEBUG), +# matching make's test builds. runTests.py uses it by default. +set(CMAKE_C_FLAGS_RELEASETESTING_INIT -O1) +set(CMAKE_CXX_FLAGS_RELEASETESTING_INIT -O1) +project(stanmath LANGUAGES C CXX) +stan_apply_make_local_flags() + +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_BINARY_DIR) + message(FATAL_ERROR "Use an out-of-source build: cmake -S . -B build") +endif() +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED YES) +set(CMAKE_CXX_EXTENSIONS NO) +stan_cache_default(CMAKE_POSITION_INDEPENDENT_CODE ON BOOL + "Compile position-independent code and link executables as PIE") +# Link executables as PIE to match their position-independent objects. +include(CheckPIESupported) +check_pie_supported() + +option(STAN_BUILD_DOCS "Build the Stan Math library documentation" OFF) +option(STAN_TEST_HEADERS "Create targets that check standalone headers" ON) +option(STAN_TEST_PCH "Reuse precompiled headers when building tests" ON) +option(STAN_BUILD_PROB_TESTS "Generate and build distribution tests" ON) +option(STAN_BUILD_EXPRESSION_TESTS "Build pre-generated expression tests" OFF) +option(STAN_TEST_ROW_VECTORS "Include row vectors in distribution tests" OFF) +option(STAN_PROB_TEST_ALL "Test all distribution autodiff modes" OFF) +option(STAN_NO_RANGE_CHECKS "Disable range checks within the Stan library" OFF) +option(STAN_MPI "Enable MPI support" OFF) +option(STAN_OPENCL "Enable OpenCL support" OFF) +option(STAN_THREADS "Enable multi-threading support" OFF) +option(STAN_SANITIZE_ADDRESS "Compile C++ sources and link with AddressSanitizer" OFF) +stan_cache_default(STAN_OPENCL_DEVICE_ID "0" STRING "OpenCL device ID") +stan_cache_default(STAN_OPENCL_PLATFORM_ID "0" STRING "OpenCL platform ID") +stan_cache_default(STAN_INTEGRATED_OPENCL "0" STRING "Enable integrated OpenCL features") +set(STAN_TEST_NAME_FILTERS "" CACHE STRING "Required substrings in selected test source paths") +set(STAN_SELECTED_TEST_REGEX "" CACHE STRING + "Tests whose source paths match this regex are built by stan_selected_tests") +include(CTest) +stan_cache_default(STAN_TEST_OPTIMIZATION "1" STRING "Optimization level for tests and solver libraries") +if(DEFINED CACHE{STAN_TEST_OPTIMIZATION}) + set_property(CACHE STAN_TEST_OPTIMIZATION PROPERTY STRINGS 0 1 2 3) +endif() +if(NOT STAN_TEST_OPTIMIZATION MATCHES "^[0-3]$") + message(FATAL_ERROR "STAN_TEST_OPTIMIZATION must be 0, 1, 2, or 3") +endif() +# Keep solver libraries at the same optimization level as the C++ tests. +if(MSVC) + if(STAN_TEST_OPTIMIZATION STREQUAL "0") + set(STAN_TEST_OPTIMIZATION_FLAGS /Od) + elseif(STAN_TEST_OPTIMIZATION STREQUAL "1") + set(STAN_TEST_OPTIMIZATION_FLAGS /O1) + else() + set(STAN_TEST_OPTIMIZATION_FLAGS /O2) + endif() +else() + set(STAN_TEST_OPTIMIZATION_FLAGS -O${STAN_TEST_OPTIMIZATION}) +endif() +# Offer ReleaseTesting to multi-config generators such as Visual Studio. +get_property(multi_config GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(multi_config AND NOT "ReleaseTesting" IN_LIST CMAKE_CONFIGURATION_TYPES) + list(APPEND CMAKE_CONFIGURATION_TYPES ReleaseTesting) + set(CMAKE_CONFIGURATION_TYPES "${CMAKE_CONFIGURATION_TYPES}" CACHE STRING + "Available build configurations" FORCE) +endif() +# Like Make's CXXFLAGS, reach every C++ compile and link, but not the +# vendored TBB build, which takes its own flags. +if(STAN_SANITIZE_ADDRESS) + if(MSVC) + add_compile_options(/fsanitize=address) + else() + add_compile_options($<$:-fsanitize=address>) + add_link_options(-fsanitize=address) + endif() +endif() + +add_library(stanmath INTERFACE) +add_library(stanmath::stanmath ALIAS stanmath) +target_compile_features(stanmath INTERFACE cxx_std_17) +target_include_directories(stanmath INTERFACE "${PROJECT_SOURCE_DIR}") +target_compile_definitions(stanmath INTERFACE BOOST_DISABLE_ASSERTS TBB_INTERFACE_NEW _REENTRANT) +foreach(feature STAN_NO_RANGE_CHECKS STAN_THREADS STAN_MPI) + if(${feature}) + target_compile_definitions(stanmath INTERFACE ${feature}) + endif() +endforeach() +add_subdirectory(cmake) + +if(STAN_OPENCL) + # Configuration and object-only header checks need headers, not a loader. + # Respect explicit FindOpenCL overrides while defaulting to vendored headers. + if(NOT DEFINED OpenCL_INCLUDE_DIR) + set(OpenCL_INCLUDE_DIR "${STAN_OPENCL_ROOT}") + endif() + find_package(OpenCL QUIET) + target_include_directories(stanmath SYSTEM INTERFACE "${OpenCL_INCLUDE_DIR}") + if(TARGET OpenCL::OpenCL) + target_link_libraries(stanmath INTERFACE OpenCL::OpenCL) + elseif(APPLE) + target_link_libraries(stanmath INTERFACE "-framework OpenCL") + else() + # Keep the ordinary platform link requirement even without a found loader. + target_link_libraries(stanmath INTERFACE OpenCL) + endif() + target_compile_definitions(stanmath INTERFACE + STAN_OPENCL OPENCL_DEVICE_ID=${STAN_OPENCL_DEVICE_ID} + OPENCL_PLATFORM_ID=${STAN_OPENCL_PLATFORM_ID} + CL_HPP_TARGET_OPENCL_VERSION=120 CL_HPP_MINIMUM_OPENCL_VERSION=120 + CL_HPP_ENABLE_EXCEPTIONS INTEGRATED_OPENCL=${STAN_INTEGRATED_OPENCL}) +endif() +if(STAN_MPI) + find_package(MPI REQUIRED COMPONENTS CXX) + if(STAN_USE_SYSTEM_BOOST) + find_package(Boost REQUIRED COMPONENTS mpi serialization) + else() + # Build the MPI libraries against the same headers used by Stan; mixing + # vendored headers with an older system Boost can change serialization ABI. + file(GLOB serialization_sources CONFIGURE_DEPENDS + "${STAN_BOOST_ROOT}/libs/serialization/src/*.cpp") + add_library(stan_boost_serialization STATIC ${serialization_sources}) + add_library(Boost::serialization ALIAS stan_boost_serialization) + target_include_directories(stan_boost_serialization SYSTEM PUBLIC "${STAN_BOOST_ROOT}") + file(GLOB mpi_sources CONFIGURE_DEPENDS "${STAN_BOOST_ROOT}/libs/mpi/src/*.cpp") + add_library(stan_boost_mpi STATIC ${mpi_sources}) + add_library(Boost::mpi ALIAS stan_boost_mpi) + target_compile_definitions(stan_boost_mpi PRIVATE BOOST_MPI_SOURCE=1) + target_compile_definitions(stan_boost_mpi INTERFACE BOOST_MPI_NO_LIB=1) + target_link_libraries(stan_boost_mpi PUBLIC Boost::serialization MPI::MPI_CXX) + endif() + target_link_libraries(stanmath INTERFACE Boost::mpi Boost::serialization MPI::MPI_CXX) + add_library(stan_mpi OBJECT stan/math/prim/functor/mpi_cluster_inst.cpp) + target_link_libraries(stan_mpi PRIVATE stanmath) +endif() + +message(STATUS "Stan Math: compiler=${CMAKE_CXX_COMPILER} " + "(${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}), " + "build type=${CMAKE_BUILD_TYPE}, test optimization=${STAN_TEST_OPTIMIZATION}") +message(STATUS "Stan Math: CMAKE_CXX_FLAGS=${CMAKE_CXX_FLAGS}, " + "CMAKE_EXE_LINKER_FLAGS=${CMAKE_EXE_LINKER_FLAGS}") +message(STATUS "Stan Math: threads=${STAN_THREADS}, OpenCL=${STAN_OPENCL}, MPI=${STAN_MPI}, " + "AddressSanitizer=${STAN_SANITIZE_ADDRESS}") +set(STAN_EFFECTIVE_MPI "${STAN_MPI}" CACHE INTERNAL "MPI setting used by test executables") +include(cmake/StanTest.cmake) +if(BUILD_TESTING) + add_subdirectory(test) +endif() +if(STAN_BUILD_DOCS) + find_package(Doxygen REQUIRED) + add_custom_target(doxygen + COMMAND ${CMAKE_COMMAND} -E make_directory doc/api + COMMAND ${DOXYGEN_EXECUTABLE} doxygen/doxygen.cfg + COMMAND ${CMAKE_COMMAND} -E copy doxygen/pretty_stuff/eigen_navtree_hacks.js + doc/api/html/eigen_navtree_hacks.js + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" + COMMENT "Generating API documentation with Doxygen" + VERBATIM) +endif() + +find_package(Python3 COMPONENTS Interpreter) +if(Python3_FOUND) + # The same checks as make/cpplint and Make's test-math-dependencies. + set(cpplint_command "${Python3_EXECUTABLE}" lib/cpplint_2.0.2/cpplint.py + --output=vs7 --counting=detailed --root=stan --extensions=hpp,cpp + "--filter=-runtime/indentation_namespace,-build/c++11,-readability/namespace,-legal/copyright,-whitespace/indent,-build/header_guard,-build/include_order,-build/include_what_you_use,-runtime/string,-build/namespaces") + # cpplint's --recursive needs Python 3.9, so pass the files as make/cpplint does. + set(cpplint_commands "") + foreach(directory stan test/unit) + file(GLOB_RECURSE lint_sources CONFIGURE_DEPENDS RELATIVE "${PROJECT_SOURCE_DIR}" + "${PROJECT_SOURCE_DIR}/${directory}/*.hpp" "${PROJECT_SOURCE_DIR}/${directory}/*.cpp") + list(APPEND cpplint_commands COMMAND ${cpplint_command} ${lint_sources}) + endforeach() + add_custom_target(cpplint ${cpplint_commands} + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" + VERBATIM) + add_custom_target(test-math-dependencies + COMMAND "${Python3_EXECUTABLE}" runChecks.py + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" + VERBATIM) +endif() + +set(STAN_COMPILE_TEST_SOURCE "" CACHE FILEPATH "Source file for compile-only compatibility checks") +if(STAN_COMPILE_TEST_SOURCE) + # Only GoogleTest's headers are needed; its targets exist only with tests. + add_library(stan_compile_test_options INTERFACE) + target_link_libraries(stan_compile_test_options INTERFACE stan_test_flags) + target_include_directories(stan_compile_test_options SYSTEM INTERFACE + "${STAN_BENCHMARK_ROOT}/googletest/googletest/include") + stan_add_pch(stan_compile_test_pch stan_compile_test_options ) + add_library(stan_compile_test OBJECT EXCLUDE_FROM_ALL "${STAN_COMPILE_TEST_SOURCE}") + stan_configure_test(stan_compile_test stan_compile_test_options stan_compile_test_pch) +endif() diff --git a/Jenkinsfile b/Jenkinsfile index b194b911beb..1e832d2d877 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -15,20 +15,43 @@ properties([ def image = 'stanorg/ci:v1' def runRemainingStages = false def CLANG_CXX = 'clang++-7' +def CLANG_CC = 'clang-7' def GCC = 'g++' def MPICXX = 'mpicxx.openmpi' def mainBranch = env.BRANCH_NAME == 'develop' || env.BRANCH_NAME == 'master' def noOptimize = !(params.optimizeUnitTests || mainBranch) -def jumboFlags = params.disableJumbo ? '' : ' --jumbo --debug' -def runTests(String local, String args) { - writeFile(file: "make/local", text: local) - sh "cat make/local" - sh "make print-compiler-flags" - sh "python3 runTests.py -j\$PARALLEL $args" - junit 'test/**/*.xml' - sh "find test -name *.xml -delete" +// Quote complete arguments, including compiler flags containing spaces. +def shellQuote(String value) { + return "'" + value.replace("'", "'\"'\"'") + "'" +} + +// Every configure of `build` starts from a fresh cache, so it uses exactly +// these settings. stanorg/ci:v1 has CMake 3.16, which lacks `cmake --fresh` +// (3.24), so the cache and top-level CMakeFiles are deleted instead, which is +// what --fresh does. Objects whose flags are unchanged are not rebuilt. +def cmakeSettings(List settings) { + return ['-DSTAN_USE_MAKE_LOCAL=OFF'] + settings +} + +def configureCmake(List settings) { + sh 'cmake -E remove -f build/CMakeCache.txt' + sh 'cmake -E remove_directory build/CMakeFiles' + sh 'cmake -S . -B build ' + cmakeSettings(settings).collect { shellQuote(it.toString()) }.join(' ') +} + +// runTests.py --fresh does the same cache reset before it configures. +def runnerSettings(List settings) { + return (['--build-dir', 'build', '--fresh'] + cmakeSettings(settings).collect { + "--cmake-arg=${it}" + }).collect { shellQuote(it.toString()) }.join(' ') +} + +def runTests(List settings, String args) { + sh 'cmake -E remove_directory build/test-results' + sh "python3 runTests.py -j\$PARALLEL ${runnerSettings(settings)} $args" + junit(testResults: 'build/test-results/*.xml', allowEmptyResults: true) } catchError { @@ -42,7 +65,7 @@ catchError { runPod(image: image, cpus: 2) { stage('Verify changes') { runRemainingStages = params.run_all || filesChanged( - 'stan', 'make', 'lib', 'test', 'runTests.py', 'runChecks.py', 'makefile', 'Jenkinsfile', '.clang-format') + 'stan', 'make', 'lib', 'test', 'cmake', 'CMakeLists.txt', 'runTests.py', 'runChecks.py', 'makefile', 'Jenkinsfile', '.clang-format') } stage("Clang-format") { @@ -85,11 +108,12 @@ up the autoformatter locally. (Check console output at ${env.BUILD_URL}) } stage('Linting & Doc checks') { - writeFile(file: "make/local", text: "CXX=$CLANG_CXX\nBOOST_PARALLEL_JOBS=\$PARALLEL") + configureCmake(["-DCMAKE_C_COMPILER=${CLANG_CC}", "-DCMAKE_CXX_COMPILER=${CLANG_CXX}", + '-DBUILD_TESTING=OFF', '-DSTAN_BUILD_DOCS=ON']) parallel( - CppLint: { sh "make cpplint" }, - Dependencies: { sh "make test-math-dependencies" }, - Documentation: { sh "make doxygen" }, + CppLint: { sh "cmake --build build --target cpplint" }, + Dependencies: { sh "cmake --build build --target test-math-dependencies" }, + Documentation: { sh "cmake --build build --target doxygen" }, ) } } @@ -99,16 +123,18 @@ up the autoformatter locally. (Check console output at ${env.BUILD_URL}) parallel headers: { runPod(image: image) { stage('Headers check') { - writeFile(file: 'make/local', text: "CXX=$CLANG_CXX -Werror") - sh 'make -j$PARALLEL test-headers' + configureCmake(["-DCMAKE_C_COMPILER=${CLANG_CC}", "-DCMAKE_CXX_COMPILER=${CLANG_CXX}", + '-DCMAKE_CXX_FLAGS=-Werror', '-DSTAN_TEST_HEADERS=ON', + '-DSTAN_BUILD_PROB_TESTS=OFF']) + sh 'cmake --build build --parallel $PARALLEL --target test-headers' } } }, unit: { if (env.CHANGE_TARGET) { runPod(image: image) { stage('Run changed unit tests') { - writeFile(file: 'make/local', text: "O=3\nCXXFLAGS+=-march=native -mtune=native") - sh './runTests.py -j$PARALLEL --changed --debug' + runTests(['-DSTAN_TEST_OPTIMIZATION=3', '-DCMAKE_CXX_FLAGS=-march=native -mtune=native'], + '--changed --debug') } } } @@ -120,49 +146,53 @@ up the autoformatter locally. (Check console output at ${env.BUILD_URL}) revfwd: { runPod(image: image) { stage('Rev/Fwd Unit Tests') { - def local = 'CXXFLAGS+= -fsanitize=address\n' - if (noOptimize) - local += 'O=0\n' - runTests(local, "test/unit/math/rev") - runTests(local, "test/unit/math/fwd") + def settings = ['-DSTAN_SANITIZE_ADDRESS=ON', + "-DSTAN_TEST_OPTIMIZATION=${noOptimize ? '0' : '3'}"] + runTests(settings, "test/unit/math/rev") + runTests(settings, "test/unit/math/fwd") } } }, mix: { runPod(image: image, cpus: 8, memory: '192Gi') { stage('Mix Unit Tests') { - def local = 'CXXFLAGS+= -fsanitize=address\n' - if (noOptimize) - local += 'O=1\n' - runTests(local, "test/unit/math/mix" + jumboFlags) + def settings = ['-DSTAN_SANITIZE_ADDRESS=ON', + "-DSTAN_TEST_OPTIMIZATION=${noOptimize ? '1' : '3'}"] + // A single mix executable exceeds the 2 GiB x86-64 small code + // model limit under ASan, so link each subfolder separately. + runTests(settings, "test/unit/math/mix/*_test.cpp") + runTests(settings, "test/unit/math/mix/constraint") + runTests(settings, "test/unit/math/mix/core") + runTests(settings, "test/unit/math/mix/fun") + runTests(settings, "test/unit/math/mix/functor") + runTests(settings, "test/unit/math/mix/meta") + runTests(settings, "test/unit/math/mix/prob") } } }, prim: { runPod(image: image) { stage('Prim Unit Tests') { - def local = 'CXXFLAGS+= -fsanitize=address\n' - if (noOptimize) - local += 'O=0\n' - runTests(local, "test/unit/*_test.cpp") - runTests(local, "test/unit/math/*_test.cpp") - runTests(local, "test/unit/math/prim" + jumboFlags) - runTests(local, "test/unit/math/memory") + def settings = ['-DSTAN_SANITIZE_ADDRESS=ON', + "-DSTAN_TEST_OPTIMIZATION=${noOptimize ? '0' : '3'}"] + runTests(settings, "test/unit/*_test.cpp") + runTests(settings, "test/unit/math/*_test.cpp") + runTests(settings, "test/unit/math/prim") + runTests(settings, "test/unit/math/memory") } } }, laplace: { runPod(image: image, memory: '32Gi') { stage('Laplace Unit Tests') { - def local = 'CXXFLAGS+= -march=native -mtune=native\nO=3\n' + def settings = ['-DCMAKE_CXX_FLAGS=-march=native -mtune=native', '-DSTAN_TEST_OPTIMIZATION=3'] - runTests(local, "test/unit/math/laplace/*_test.cpp") + runTests(settings, "test/unit/math/laplace/*_test.cpp") if (mainBranch) { // only run ASAN on a selected test to avoid extremely long CI times - sh 'make clean' - local += 'CXXFLAGS+= -fsanitize=address' - runTests(local, "test/unit/math/laplace/laplace_marginal_lpdf_moto_test.cpp") + runTests(settings + ['-DSTAN_SANITIZE_ADDRESS=ON'], + "test/unit/math/laplace/laplace_marginal_lpdf_moto_test.cpp") } } } @@ -170,58 +200,57 @@ up the autoformatter locally. (Check console output at ${env.BUILD_URL}) gpu: { runPod(image: image, memory: '32Gi', gpus: 1) { stage('OpenCL GPU tests') { - def local = """CXX=$CLANG_CXX -Werror -STAN_OPENCL=true -OPENCL_PLATFORM_ID_GPU=0 -OPENCL_DEVICE_ID=0 -LDFLAGS_OPENCL=-L/usr/local/cuda/targets/x86_64-linux/lib -""" - if (noOptimize) - local += 'O=1\n' - runTests(local, "test/unit/math/opencl") // TODO(bward): try to enable - runTests(local, "test/unit/multiple_translation_units_test.cpp") + def settings = ["-DCMAKE_C_COMPILER=${CLANG_CC}", "-DCMAKE_CXX_COMPILER=${CLANG_CXX}", + '-DCMAKE_CXX_FLAGS=-Werror', '-DSTAN_OPENCL=ON', + '-DSTAN_OPENCL_PLATFORM_ID=0', '-DSTAN_OPENCL_DEVICE_ID=0', + '-DOpenCL_LIBRARY=/usr/local/cuda/targets/x86_64-linux/lib/libOpenCL.so', + "-DSTAN_TEST_OPTIMIZATION=${noOptimize ? '1' : '3'}"] + runTests(settings, "test/unit/math/opencl") // TODO(bward): try to enable + runTests(settings, "test/unit/multiple_translation_units_test.cpp") } } }, mpi: { runPod(image: image) { stage('MPI Tests') { - def local = "CXX=$MPICXX\nCXX_TYPE=gcc\nSTAN_MPI=true\n" - runTests(local, "test/unit/math/prim/functor") - runTests(local, "test/unit/math/rev/functor") + def settings = ["-DCMAKE_CXX_COMPILER=${MPICXX}", '-DSTAN_MPI=ON', '-DSTAN_TEST_OPTIMIZATION=3'] + runTests(settings, "test/unit/math/prim/functor") + runTests(settings, "test/unit/math/rev/functor") } } }, expr: { runPod(image: image, memory: '64Gi') { stage('Expressions test') { - def local = "CXX=$CLANG_CXX -Werror\nO=0\n" - writeFile(file: 'make/local', text: local) + def settings = ["-DCMAKE_C_COMPILER=${CLANG_CC}", "-DCMAKE_CXX_COMPILER=${CLANG_CXX}", + '-DCMAKE_CXX_FLAGS=-Werror', '-DSTAN_TEST_OPTIMIZATION=0'] sh "python ./test/code_generator_test.py" sh "python ./test/signature_parser_test.py" sh "python ./test/statement_types_test.py" sh "python ./test/varmat_compatibility_summary_test.py" - sh "python ./test/varmat_compatibility_test.py" - withEnv(['PATH+TBB=./lib/tbb']) { + // These scripts configure their own CMake builds; CC, CXX, and + // CXXFLAGS set those builds' compilers and CMAKE_CXX_FLAGS. + withEnv(["CC=${CLANG_CC}", "CXX=${CLANG_CXX}", 'CXXFLAGS=-Werror']) { + sh "python ./test/varmat_compatibility_test.py" sh "python ./test/expressions/test_expression_testing_framework.py" - runTests(local, 'test/expressions') - sh "make clean-all" - runTests(local + "STAN_THREADS=true\n", 'test/expressions --only-functions reduce_sum map_rect') } + runTests(settings, 'test/expressions') + runTests(settings + ['-DSTAN_THREADS=ON'], 'test/expressions --only-functions reduce_sum map_rect') } } }, thread: { runPod(image: image, cpus: 8, memory: '128Gi') { stage('Threading tests') { - def local = "CXX=$CLANG_CXX -Werror\nSTAN_THREADS=true\n" + def settings = ["-DCMAKE_C_COMPILER=${CLANG_CC}", "-DCMAKE_CXX_COMPILER=${CLANG_CXX}", + '-DCMAKE_CXX_FLAGS=-Werror', '-DSTAN_THREADS=ON', '-DSTAN_TEST_OPTIMIZATION=3'] withEnv(['STAN_NUM_THREADS=4', 'PARALLEL=8']) { // runTests currently only runs 1 test at a time post-build if (mainBranch) { - runTests(local, "test/unit") + runTests(settings, "test/unit") } else { - runTests(local, "test/unit -f thread") - runTests(local, "test/unit -f map_rect") - runTests(local, "test/unit -f reduce_sum") + runTests(settings, "test/unit -f thread") + runTests(settings, "test/unit -f map_rect") + runTests(settings, "test/unit -f reduce_sum") } } } @@ -232,22 +261,25 @@ LDFLAGS_OPENCL=-L/usr/local/cuda/targets/x86_64-linux/lib def parallel_tests = 10 runPod(image: image, cpus: cores_per_test*parallel_tests, memory: '128Gi') { stage ('Distribution tests') { - def local = "CXX=$CLANG_CXX\nO=0\nN_TESTS=100\n" + def settings = ["-DCMAKE_C_COMPILER=${CLANG_CC}", "-DCMAKE_CXX_COMPILER=${CLANG_CXX}", + '-DSTAN_TEST_OPTIMIZATION=0', '-DSTAN_PROB_TEST_BATCH_SIZE=100'] if (params.withRowVector || mainBranch) { - local += "CXXFLAGS+= -DSTAN_TEST_ROW_VECTORS -DSTAN_PROB_TEST_ALL\n" + settings += ['-DSTAN_TEST_ROW_VECTORS=ON', '-DSTAN_PROB_TEST_ALL=ON'] } - writeFile(file: 'make/local', text: local) + // getDependencies.py lists each fixture's includes with the + // configured build's compiler flags. + sh "python3 runTests.py --configure-only ${runnerSettings(settings)}" - def cmd = 'python3 test/prob/getDependencies.py' + def cmd = 'python3 test/prob/getDependencies.py --build-dir build' if (params.runAllDistributions || mainBranch) { cmd += ' --pretend-all' } sh """ $cmd > changed-tests if [ -s changed-tests ] ; then - ./runTests.py -j${cores_per_test*parallel_tests} --make-only `cat changed-tests` + ./runTests.py -j${cores_per_test*parallel_tests} ${runnerSettings(settings)} --make-only `cat changed-tests` unset PARALLEL - parallel --halt now,fail=1 -r -j$parallel_tests ./runTests.py --test-only -j$cores_per_test {} < changed-tests + parallel --halt now,fail=1 -r -j$parallel_tests ./runTests.py --build-dir build --test-only -j$cores_per_test {} < changed-tests fi """ } @@ -271,7 +303,8 @@ LDFLAGS_OPENCL=-L/usr/local/cuda/targets/x86_64-linux/lib runPod(image: image, cpus: 2) { stage('Upload doxygen') { def branch = 'gh-pages' - sh 'make doxygen' + configureCmake(['-DBUILD_TESTING=OFF', '-DSTAN_BUILD_DOCS=ON']) + sh 'cmake --build build --target doxygen' dir('gh-pages') { def scm = scmGit( branches: [[name: "refs/heads/$branch"]], diff --git a/README.md b/README.md index 600f5712ad0..745be5b8448 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,512 @@ -
- -The Stan Math Library is a C++, reverse-mode automatic -differentiation library designed to be usable, extensive and -extensible, efficient, scalable, stable, portable, and redistributable -in order to facilitate the construction and utilization of algorithms -that utilize derivatives. - - -Documentation, Installation, and Examples --------------- - -All of Stan math's documentation is hosted on our website below. Please do not -reference articles in the wiki as they are outdated and not maintained. - -[mc-stan.org/math](https://mc-stan.org/math/) - - -Licensing ---------- -The Stan Math Library is licensed under the [new BSD -license](https://github.com/stan-dev/math/blob/develop/LICENSE%2Emd). - -The Stan Math Library depends on the Intel TBB library which is -licensed under the Apache 2.0 license. This dependency implies an -additional restriction as compared to the new BSD license alone. The -Apache 2.0 license is incompatible with GPL-2 licensed code if -distributed as a unitary binary. You may refer to the Licensing page on the [Stan wiki](https://github.com/stan-dev/stan/wiki/Stan-Licensing). +
+ +The **Stan Math Library** is a C++ library for reverse-mode automatic +differentiation. Use Stan Math to build algorithms requiring derivatives. + + +Documentation, Installation, and Examples +-------------- + +Read the Stan Math documentation at mc-stan.org. +The wiki articles are outdated. Stan Math developers do not maintain wiki articles. + +[mc-stan.org/math](https://mc-stan.org/math/) + + +Licensing +--------- +The Stan Math Library is licensed under the [new BSD +license](https://github.com/stan-dev/math/blob/develop/LICENSE%2Emd). + +Stan Math depends on Intel TBB. TBB uses the Apache 2.0 license. +The Apache 2.0 license adds restrictions to the BSD license terms. +The Apache 2.0 license is incompatible with GPL-2 licensed code in a combined binary. See the [Licensing page](https://github.com/stan-dev/stan/wiki/Stan-Licensing) for details. + +Building tests with CMake +------------------------- + +Stan Math has recently used to using cmake as its primary build system. +CMake creates build rules for a selected build tool while CTest is CMake's test runner. + +`runTests.py` is the main way to compile the tests for Stan Math and it now uses cmake on the backend. +`runTests.py` configures CMake, builds the selected executables, and runs the tests. + +```sh +# Builds a single executable for the whole `fun` directory +./runTests.py test/unit/math/prim/fun +# Builds a single executable for the `sum_test.cpp` file +./runTests.py test/unit/math/prim/fun/sum_test.cpp +``` + +Some useful options for `runTests.py` include: + +- `--fresh` resets cached CMake settings before configuration. + +CMake saves persistent build settings in a cache. +The default cache file is `build/CMakeCache.txt`. +Cached settings can override later changes to `make/local`. +`--fresh` resets the CMake configuration before building. +`--fresh` removes `CMakeCache.txt` and the top-level `CMakeFiles` directory, which is what CMake's own `--fresh` option does. +It works with any supported CMake version, while the native option needs CMake 3.24 or newer. +CMake then configures the build with the current arguments and `make/local` settings. +Objects whose compile flags did not change are not rebuilt. +Use `--fresh` when cached settings no longer match the intended build. +Pass required custom settings again because `--fresh` removes cached overrides. +`--test-only` runs an existing executable without configuration or compilation. +`--fresh` cannot be combined with `--test-only`. + +```sh +# Reset cached settings, then build and run the sum tests. +./runTests.py --fresh test/unit/math/prim/fun/sum_test.cpp +``` + +- `--build-dir PATH` selects the build directory. The default is `build`. +The build directory contains generated build rules, compiled files, and test results. + +```sh +# Store the build in `.build`. +./runTests.py --build-dir .build test/unit/math/prim/fun/sum_test.cpp +``` + +- `--config NAME` selects the build configuration such as `Debug`, `Release`, or `ReleaseTesting`. Setting a configuration changes the default behavior of the build system such as compiler flags and debug information. +The default is `ReleaseTesting`. +`ReleaseTesting` compiles with `-O1` and does not define `NDEBUG`, so assertions stay on as in the make build. +`Release` defines `NDEBUG`, which disables assertions. +`STAN_TEST_OPTIMIZATION` still sets the optimization level of the tests in every configuration. + +```sh +# Use the `Release` configuration. +./runTests.py --config Release test/unit/math/prim/fun/sum_test.cpp +``` + +- `-j N` sets the number of parallel build jobs. The default is 1. + +```sh +# Build with four jobs. +./runTests.py -j4 test/unit/math/prim/fun +``` + +- `-f TEXT` selects source files whose paths contain `TEXT`. +A directory executable includes only source files matching the filter. +Repeat `-f` to require all filters to match. + +```sh +# Build and run primitive function tests with `sum` in the source paths. +./runTests.py -f sum test/unit/math/prim/fun +``` + +- `--make-only` builds the tests without running the test executables. + +```sh +# Build the executable for `sum_test.cpp`. +./runTests.py --make-only test/unit/math/prim/fun/sum_test.cpp +``` + +Selected files and distribution fixtures build through one CMake target, `stan_selected_tests`, so all of them compile in parallel. +Each selected file is still its own executable. + +- A distribution fixture such as `test/prob/normal/normal_test.hpp` selects the tests generated from that fixture. + +```sh +# Build and run the tests generated from the normal distribution fixture. +./runTests.py -j8 test/prob/normal/normal_test.hpp +``` + +- `--configure-only` configures `` without building or running tests. +`test/prob/getDependencies.py` reads that configuration's compile commands to select the distribution fixtures whose included Stan headers changed: + +```sh +./runTests.py --configure-only +./runTests.py -j8 $(python3 test/prob/getDependencies.py --build-dir build) +``` + +- `--test-only` runs an existing test executable. +`--test-only` skips configuration and compilation. +`--test-only` does not rebuild tests after source changes. + +```sh +# Build the primitive function test executable in `.build` +./runTests.py --make-only --build-dir .build test/unit/math/prim/fun +# run the executable. +./runTests.py --test-only --build-dir .build test/unit/math/prim/fun +``` + +- `--changed` selects tests for committed changes on your branch. +`--changed` compares your branch with `origin/develop`. +`--changed` selects changed test files and tests for changed Stan headers. +Each selected file builds and runs as a separate executable: + +```sh +./runTests.py --changed +``` + +- `--run-all` continues to the next executable if a test executable fails. +`runTests.py --run-all` still returns a failure status if any tests fail. +Build failures stop `runTests.py`. + +```sh +# Run the `abs` tests even if the `sum` tests fail. +./runTests.py --run-all test/unit/math/prim/fun/sum_test.cpp test/unit/math/prim/fun/abs_test.cpp +``` + +- `--test-jobs N` runs up to `N` test executables at once. The default is 1. +Each executable prints its output when it finishes, in the order of the selected paths. +With more than one job, every executable runs even if one fails. +A trailing `/` in a pattern selects only directories. + +```sh +# Build one executable per distribution directory and run four at a time. +./runTests.py -j8 --test-jobs 4 'test/prob/*/' +``` + +`` means the directory selected by `--build-dir`. +XML results are written under `/test-results` by default. +`make/local` is an optional file for local compiler and build settings. +The CMake test setup reads `make/local`. +All CMake generators use the imported settings, including Ninja and Unix Makefiles. +The Windows and ARM GitHub jobs use CMake. + +`--cmake-arg=ARGUMENT` passes one argument to CMake configuration. +Repeat `--cmake-arg` to pass multiple arguments. +The CMake argument `-DNAME=VALUE` sets a named cache setting. + +```sh +# Build reverse-mode core tests with threading enabled. +./runTests.py --cmake-arg=-DSTAN_THREADS=ON test/unit/math/rev/core +``` + +`-DSTAN_SANITIZE_ADDRESS=ON` compiles C++ sources and links with AddressSanitizer. +The vendored TBB build does not use it. + +```sh +# Build mixed-mode tests at -O1 with AddressSanitizer, without make/local. +./runTests.py --cmake-arg=-DSTAN_USE_MAKE_LOCAL=OFF --cmake-arg=-DSTAN_SANITIZE_ADDRESS=ON \ + --cmake-arg=-DSTAN_TEST_OPTIMIZATION=1 test/unit/math/mix +``` + +The `cpplint` and `test-math-dependencies` CMake targets run the same checks as `make cpplint` and `make test-math-dependencies`: + +```sh +cmake -S . -B build -DBUILD_TESTING=OFF +cmake --build build --target cpplint +``` + +The CMake argument `-G NAME` selects a generator. +The combined form `-GNAME` selects the generator with one argument. + +```sh +# Build with Ninja and the settings from `make/local`. +./runTests.py --build-dir build-ninja --cmake-arg=-GNinja test/unit/math/prim/fun/sum_test.cpp +``` + +CMake imports the following Make settings: + +| Make setting | CMake setting | +| --- | --- | +| `CC`, `CXX` | C and C++ compilers | +| `O` | Optimization level from 0 to 3, mapped to `STAN_TEST_OPTIMIZATION` | +| `STAN_THREADS` | Enable threading support | +| `STAN_OPENCL` | Enable OpenCL GPU support | +| `STAN_MPI` | Enable MPI support for communication between processes | +| `STAN_NO_RANGE_CHECKS` | Disable range checks | +| `STAN_TEST_ROW_VECTORS` | Include row vectors in distribution tests | +| `STAN_PROB_TEST_ALL` | Enable all distribution test modes | +| `OPENCL_DEVICE_ID` | Select an OpenCL device with `STAN_OPENCL_DEVICE_ID` | +| `OPENCL_PLATFORM_ID` | Select an OpenCL platform with `STAN_OPENCL_PLATFORM_ID` | +| `INTEGRATED_OPENCL` | Enable integrated GPU settings with `STAN_INTEGRATED_OPENCL` | +| `BOOST`, `EIGEN`, `TBB`, `SUNDIALS`, `OPENCL`, `BENCHMARK` | Dependency source directories | +| `CXXFLAGS`, `CXXFLAGS_OPTIM`, `CXXFLAGS_WARNINGS` | Additional C++ compiler flags | +| `CFLAGS` | Additional C compiler flags | +| `CPPFLAGS`, `CPPFLAGS_OPTIM` | Additional C and C++ preprocessor flags | +| `LDFLAGS`, `LDFLAGS_OPTIM` | Additional linker flags | +| `LDLIBS` | Additional link libraries | + +CMake options passed with `-D` take priority over imported defaults. +Existing CMake cache values also take priority over imported defaults. +Use a new build directory after changing cached settings or compilers. +Use `--fresh` to discard cached settings in the selected build directory. +Flags from `make/local` are added to the CMake compiler and linker flags. +Changes to `make/local` and included Make files trigger CMake configuration. +Make settings outside the table are not imported. + +`CXX = clang++` selects the Clang C++ compiler. +`O = 0` disables compiler optimization. +`STAN_THREADS = true` enables threading support. +`CXXFLAGS += -g` adds compiler debug information. +Example: Set compiler settings in `make/local`. + +```make +CXX = clang++ +O = 0 +STAN_THREADS = true +CXXFLAGS += -g +``` + +Use `STAN_USE_MAKE_LOCAL=OFF` to ignore `make/local`: + +```sh +./runTests.py --cmake-arg=-DSTAN_USE_MAKE_LOCAL=OFF test/unit/math/prim/fun/sum_test.cpp +``` + +### Common CMake commands + +Run the commands from the repository root. +CMake configuration creates build rules and saves settings in `{BUILD_DIRECTORY}/CMakeCache.txt`. +Configuration does not build the test executables. +`cmake --build` compiles and links the test executables. +CTest runs test executables already built by CMake. +`runTests.py` configures, builds, and runs the selected tests automatically. + +#### Configure a build + +`-S PATH` selects the source directory. Use `.` for the repository root. +`-B PATH` selects the build directory. CMake creates the directory if needed. +`-DNAME=VALUE` sets a named cache setting. +`CMAKE_BUILD_TYPE` selects the configuration for Ninja and Unix Makefiles. +`Release` enables release compiler settings. +`ReleaseTesting` is the configuration `runTests.py` uses: `-O1` without `NDEBUG`. +Direct CMake commands also read `make/local`. +CMake test builds enable `-Wall` with GCC and Clang. +Clang test builds disable only the unused-lambda-capture warning because explicit +autodiff callback captures can be unused in individual `if constexpr` branches. +Example: Configure a release build in `build`. + +```sh +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +``` + +The native CMake option `--fresh` resets the configuration cache. +`--fresh` requires CMake 3.24 or newer. +With older CMake, delete the cache file and the top-level `CMakeFiles` directory instead. +Example: Reset cached settings and configure a release build. + +```sh +cmake --fresh -S . -B build -DCMAKE_BUILD_TYPE=Release +# CMake older than 3.24 +cmake -E remove -f build/CMakeCache.txt +cmake -E remove_directory build/CMakeFiles +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +``` + +#### List settings and descriptions + +`-N` reads the cache without configuring the project. +`-L` lists cache variables. +`H` in `-LH` or `-LAH` prints the description above each value. +`A` in `-LAH` includes advanced variables. +Advanced variables contain settings changed less often during normal builds. +Cache listings omit temporary variables and uncached `make/local` defaults. +Print the current cache values and option descriptions: + +```sh +cmake -N -LH build +``` + +Add `A` to include advanced cache settings: + +```sh +cmake -N -LAH build +``` + +To inspect project defaults, configure an empty build directory. +Disable `make/local` imports to exclude local overrides. +The `build-defaults` directory must not contain an existing CMake cache: + +```sh +cmake -S . -B build-defaults -DSTAN_USE_MAKE_LOCAL=OFF -LAH +``` + +Project option definitions contain the default values and descriptions. +Read [CMakeLists.txt](CMakeLists.txt) for the main options. +Read [cmake/CMakeLists.txt](cmake/CMakeLists.txt) for dependency options. +Read [cmake/MakeLocal.cmake](cmake/MakeLocal.cmake) for local settings options. +Read [test/prob/CMakeLists.txt](test/prob/CMakeLists.txt) for distribution options. + +`--help-variable-list` prints CMake's built-in variable names. +`--help-variable NAME` prints the definition of a built-in variable. +CMake's built-in help does not define Stan-specific options. +Use the cache descriptions or the project CMake files for Stan-specific options. +Example: List built-in variables and read the definition of `CMAKE_BUILD_TYPE`. + +```sh +cmake --help-variable-list +cmake --help-variable CMAKE_BUILD_TYPE +``` + +#### Change settings or select a generator + +Use `-DNAME=VALUE` to change a cached setting. +Example: Enable threading in `build`. + +```sh +cmake -S . -B build -DSTAN_THREADS=ON +``` + +Use `-U` to remove a named cache override. +Configuration then uses the `make/local` value or the project default. +Example: Remove the cached threading setting. + +```sh +cmake -S . -B build -U STAN_THREADS +``` + +Run `cmake --help` to list the available generators. +Use `-G` to select Ninja or Unix Makefiles. +Use a separate build directory when changing generators or compilers: + +```sh +cmake --help +cmake -S . -B build-ninja -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake -S . -B build-make -G 'Unix Makefiles' -DCMAKE_BUILD_TYPE=Release +``` + +#### List targets and build tests + +A build target names an executable, library, or build task. +`--build PATH` builds targets from the selected build directory. +`--target NAME` selects a build target. +The `help` target lists available targets for Ninja and Unix Makefiles. +`test_unit_math_prim_fun_sum_test` builds the executable for `sum_test.cpp`. +`--parallel N` sets the number of parallel build jobs. +`--verbose` prints compiler and linker commands. +List build targets for Ninja or Unix Makefiles: + +```sh +cmake --build build --target help +``` + +Build one test executable with four parallel jobs: + +```sh +cmake --build build --parallel 4 --target test_unit_math_prim_fun_sum_test +``` + +Add `--verbose` to print compiler and linker commands: + +```sh +cmake --build build --parallel 4 --verbose --target test_unit_math_prim_fun_sum_test +``` + +#### List and run tests + +CTest does not build missing executables. +Build the selected test targets before running CTest. +The CTest option `--test-dir PATH` selects the build directory containing test registrations. +A regular expression is a pattern used to match text. +The CTest option `-N` lists registered tests without running the tests. +`-R PATTERN` selects test names matching a regular expression. +`^` matches the start of a name. `$` matches the end of a name. +`--output-on-failure` prints output from failing tests. +The CTest option `--parallel N` sets the number of parallel test jobs. +List registered tests without running the test executables: + +```sh +ctest --test-dir build -N +``` + +Run one registered test and print output on failure: + +```sh +ctest --test-dir build -R '^test_unit_math_prim_fun_sum_test$' --output-on-failure +``` + +Run all registered tests with four parallel jobs: + +```sh +ctest --test-dir build --parallel 4 --output-on-failure +``` + +See the [CMake command reference](https://cmake.org/cmake/help/latest/manual/cmake.1.html) +and [CTest command reference](https://cmake.org/cmake/help/latest/manual/ctest.1.html) +for more commands. + +Each test source has a separate executable and CTest entry. +Targets including `unit_math_rev_core_subtests` and `prob_math_bernoulli_subtests` +build tests in the selected directory and the selected directory's subdirectories. +Use `-R` to select matching CTest entries: + +```sh +ctest --test-dir build -R '^test_unit_math_rev_core_' --output-on-failure +``` + +The optional `*_all_tests` targets combine a directory's tests into one executable. +The default build and CTest exclude `*_all_tests` targets to prevent duplicate test runs. +Each test source compiles into one object file. +Individual test executables and directory executables reuse the compiled object files. +Switching between individual and directory executables does not recompile the test source. +The working directory for tests is the repository root. + +CMake creates targets for all enabled tests. +Select a directory target to build only the required test executables. +The `unit_math_prim_core_subtests` target builds primitive core test executables. +The CTest label `test/unit/math/prim/core/` selects primitive core tests. +The CTest option `-L PATTERN` selects labels matching a regular expression. + +```sh +cmake --build build --parallel 4 --target unit_math_prim_core_subtests +ctest --test-dir build -L '^test/unit/math/prim/core/' --output-on-failure +``` + +Precompiled headers (PCH) are enabled by default with `STAN_TEST_PCH=ON`. +Upstream Clang 9 and 10 automatically compile without PCH because of a compiler +defect fixed in Clang 11. Configuration reports the requested and effective +settings; the same setting applies to var-matrix compatibility checks. +Automatic differentiation (AD) computes derivatives through the Stan Math type system. +Unit tests reuse a PCH for the automatic differentiation layer under test. +Sources including `test_ad.hpp` use the AD helper PCH instead. +The PCH choice for each source also applies to directory executables. +Sources changing Stan or Eigen configuration macros skip PCH. +Sources without configuration macro changes continue to use PCH. +Umbrella header checks and tests with multiple translation units compile without PCH. + +Distribution tests precompile distribution fixture headers. +The distribution test PCH excludes generated typedefs. +PCH builds and test builds use identical compiler flags and definitions. +GCC reports an invalid PCH as an error. +Use `-DSTAN_TEST_PCH=OFF` to compile tests without PCH. +Standalone header targets never use PCH. +Examples include `test-headers` and `stan_math_prim_fun_sum-test`. + +Tests and the included SUNDIALS library use `STAN_TEST_OPTIMIZATION`. +`STAN_TEST_OPTIMIZATION` defaults to `1`. Optimization level `1` matches Make builds with `O=1`. + +The build generates distribution test sources under `/test/prob`. +The distribution test generator is `test/prob/generate_tests.cpp`. +`STAN_PROB_TEST_BATCH_SIZE` defaults to 100 cases per source. +The build generates distribution test sources once per fixture before compilation. +The build regenerates distribution test sources when a fixture or the generator changes. + +`STAN_THREADS`, `STAN_OPENCL`, `STAN_MPI`, and `STAN_NO_RANGE_CHECKS` work as before. +The build excludes OpenCL tests when `STAN_OPENCL=OFF`. +The build excludes MPI tests when `STAN_MPI=OFF`. +Use `STAN_USE_SYSTEM_{BOOST,EIGEN,TBB,SUNDIALS}` to select installed dependencies. +Use `STAN_{BOOST,EIGEN,TBB,SUNDIALS,OPENCL,BENCHMARK}_ROOT` to set dependency paths. +The included TBB source supports GCC and Clang builds on Linux and macOS. +MinGW and Rtools builds on Windows use the included TBB source. +MSVC builds require an installed TBB. +MPI builds use the included Boost sources. +Set `STAN_USE_SYSTEM_BOOST=ON` to use installed Boost libraries. +Set `BUILD_TESTING=OFF` to use the `stanmath` interface target without configuring tests. + +Jenkins uses CMake and CTest. Each worker reuses the `build` directory. +Jenkins passes `--fresh` when a configuration cache already exists. +Test reports are saved under `test-results` with a directory for each test stage. +`PARALLEL` sets the number of build jobs. +The pipeline includes ASan, GPU, MPI, threading, and expression tests. +`STAN_TEST_ROW_VECTORS` and `STAN_PROB_TEST_ALL` enable the additional +distribution cases requested by Jenkins. +`runTests.py generate-expressions PATH` creates expression test sources in +`PATH/expression-sources`. +`STAN_BUILD_EXPRESSION_TESTS=ON` enables generated expression tests. +`runTests.py` also generates expression sources when you select `test/expressions`. +The runner option `-e N` sets the number of expression source files. +The runner option `--only-functions` selects functions for expression tests. +Example: Generate expression test sources in `build-expressions/expression-sources`. + +```sh +python3 runTests.py generate-expressions build-expressions +``` + +Expression framework self-tests use the CMake runner. +Var-matrix compatibility checks compile through CMake. +The Makefiles remain available for upstream packages. diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt new file mode 100644 index 00000000000..9bc0435e8c1 --- /dev/null +++ b/cmake/CMakeLists.txt @@ -0,0 +1,183 @@ +# Share dependency versions with Make; cache paths may be overridden. +file(STRINGS "${PROJECT_SOURCE_DIR}/make/libraries" library_locations + REGEX "^(BOOST|EIGEN|OPENCL|TBB|SUNDIALS|BENCHMARK) ") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${PROJECT_SOURCE_DIR}/make/libraries") +foreach(line IN LISTS library_locations) + if(line MATCHES "^([A-Z]+) .*lib/([^ ]+)") + set(dependency "${CMAKE_MATCH_1}") + set(vendored_root "${PROJECT_SOURCE_DIR}/lib/${CMAKE_MATCH_2}") + stan_cache_default(STAN_${dependency}_ROOT "" PATH "Override ${dependency} source directory (empty: vendored)") + if(NOT STAN_${dependency}_ROOT) + set(STAN_${dependency}_ROOT "${vendored_root}") + endif() + set(STAN_${dependency}_ROOT "${STAN_${dependency}_ROOT}" PARENT_SCOPE) + endif() +endforeach() +option(STAN_USE_SYSTEM_TBB "Use an installed TBB library" OFF) +option(STAN_USE_SYSTEM_EIGEN "Use an installed Eigen library" OFF) +option(STAN_USE_SYSTEM_BOOST "Use installed Boost headers" OFF) +option(STAN_USE_SYSTEM_SUNDIALS "Use installed SUNDIALS libraries" OFF) +find_package(Threads REQUIRED) +target_link_libraries(stanmath INTERFACE Threads::Threads) + +set(stan_arm64 FALSE) +if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(ARM64|arm64|aarch64)$") + set(stan_arm64 TRUE) +endif() +# Match make/compiler_flags for ARM64, including the solver C sources. +if(stan_arm64 AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang" AND NOT MSVC) + set(stan_arm64_fp_options -ffp-contract=off) + target_compile_options(stanmath INTERFACE ${stan_arm64_fp_options}) +endif() + +if(STAN_USE_SYSTEM_EIGEN) + find_package(Eigen3 REQUIRED) + target_link_libraries(stanmath INTERFACE Eigen3::Eigen) +else() + target_include_directories(stanmath SYSTEM INTERFACE "${STAN_EIGEN_ROOT}") +endif() +if(STAN_USE_SYSTEM_BOOST) + find_package(Boost REQUIRED) + target_link_libraries(stanmath INTERFACE Boost::boost) +else() + target_include_directories(stanmath SYSTEM INTERFACE "${STAN_BOOST_ROOT}") +endif() + +if(WIN32) + target_compile_definitions(stanmath INTERFACE _USE_MATH_DEFINES) + # Match Make's windows.h macro probe, without requiring an executable link. + function(stan_detect_ucrt) + include(CheckCXXSourceCompiles) + set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) + check_cxx_source_compiles("#include + #ifndef _UCRT + #error Not UCRT + #endif + int main() { return 0; }" STAN_WINDOWS_UCRT) + endfunction() + stan_detect_ucrt() + if(STAN_WINDOWS_UCRT) + target_compile_definitions(stanmath INTERFACE _UCRT) + endif() +endif() + +if(STAN_USE_SYSTEM_TBB) + find_package(TBB REQUIRED COMPONENTS tbb) +else() + # Vendored TBB predates oneTBB's CMake build. One nested Make job keeps + # dependency compilation within the outer build's parallelism limit. + include(ExternalProject) + find_program(STAN_TBB_MAKE NAMES gmake make mingw32-make) + if(NOT STAN_TBB_MAKE) + message(FATAL_ERROR "Building vendored TBB requires GNU Make") + endif() + set(tbb_extra_args "") + set(tbb_extra_cxx_flags "") + if(STAN_WINDOWS_UCRT) + set(tbb_extra_cxx_flags "-D_UCRT") + endif() + if(WIN32 AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + set(tbb_compiler gcc) + if(stan_arm64) + list(APPEND tbb_extra_args WINARM64=true) + string(APPEND tbb_extra_cxx_flags " -DTBB_USE_GCC_BUILTINS") + set(tbb_interface_definitions TBB_USE_GCC_BUILTINS) + endif() + elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang") + set(tbb_compiler clang) + elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + set(tbb_compiler gcc) + else() + message(FATAL_ERROR "Use STAN_USE_SYSTEM_TBB with this compiler") + endif() + if(APPLE) + set(tbb_library "${CMAKE_CURRENT_BINARY_DIR}/tbb/libtbb.dylib") + elseif(WIN32) + set(tbb_library "${CMAKE_CURRENT_BINARY_DIR}/tbb/tbb.dll") + else() + set(tbb_library "${CMAKE_CURRENT_BINARY_DIR}/tbb/libtbb.so.2") + endif() + ExternalProject_Add(stan_tbb_build + SOURCE_DIR "${STAN_TBB_ROOT}" + BINARY_DIR "${CMAKE_CURRENT_BINARY_DIR}/tbb" + DOWNLOAD_COMMAND "" + CONFIGURE_COMMAND "" + BUILD_COMMAND ${STAN_TBB_MAKE} -j1 -r -f "${STAN_TBB_ROOT}/build/Makefile.tbb" + "tbb_root=${STAN_TBB_ROOT}" compiler=${tbb_compiler} cfg=release stdver=c++17 + "CPLUS=${CMAKE_CXX_COMPILER}" "CONLY=${CMAKE_C_COMPILER}" + "CXXFLAGS=-Wno-deprecated-declarations -Wno-deprecated-copy ${STAN_MAKE_LOCAL_CPP_FLAGS} ${STAN_MAKE_LOCAL_CXX_FLAGS} ${tbb_extra_cxx_flags}" + ${tbb_extra_args} + BUILD_BYPRODUCTS "${tbb_library}" + BUILD_ALWAYS TRUE + INSTALL_COMMAND "") + add_library(TBB::tbb SHARED IMPORTED GLOBAL) + set_target_properties(TBB::tbb PROPERTIES + IMPORTED_LOCATION "${tbb_library}" + INTERFACE_INCLUDE_DIRECTORIES "${STAN_TBB_ROOT}/include" + INTERFACE_COMPILE_DEFINITIONS "${tbb_interface_definitions}") + if(WIN32) + set_property(TARGET TBB::tbb PROPERTY IMPORTED_IMPLIB "${tbb_library}") + endif() + add_dependencies(TBB::tbb stan_tbb_build) +endif() +target_link_libraries(stanmath INTERFACE TBB::tbb) + +if(STAN_USE_SYSTEM_SUNDIALS) + find_package(SUNDIALS REQUIRED COMPONENTS cvodes idas kinsol arkode nvecserial) + target_link_libraries(stanmath INTERFACE + SUNDIALS::cvodes SUNDIALS::idas SUNDIALS::kinsol SUNDIALS::arkode + SUNDIALS::nvecserial) +else() + # The pruned vendored distribution has no upstream CMakeLists.txt. + # Match make/libraries, including Stan's printf override and common modules. + file(GLOB sundials_sources CONFIGURE_DEPENDS + "${STAN_SUNDIALS_ROOT}/src/cvodes/*.c" + "${STAN_SUNDIALS_ROOT}/src/idas/*.c" + "${STAN_SUNDIALS_ROOT}/src/kinsol/*.c" + "${STAN_SUNDIALS_ROOT}/src/arkode/*.c" + "${STAN_SUNDIALS_ROOT}/src/sundials/*.c" + "${STAN_SUNDIALS_ROOT}/src/sunmatrix/band/*.c" + "${STAN_SUNDIALS_ROOT}/src/sunmatrix/dense/*.c" + "${STAN_SUNDIALS_ROOT}/src/sunlinsol/band/*.c" + "${STAN_SUNDIALS_ROOT}/src/sunlinsol/dense/*.c" + "${STAN_SUNDIALS_ROOT}/src/sunnonlinsol/newton/*.c" + "${STAN_SUNDIALS_ROOT}/src/sunnonlinsol/fixedpoint/*.c" + "${STAN_SUNDIALS_ROOT}/src/nvector/serial/nvector_serial.c") + list(FILTER sundials_sources EXCLUDE REGEX "/f[^/]*\\.c$|/sundials_profiler\\.c$") + add_library(stan_sundials STATIC ${sundials_sources}) + target_compile_options(stan_sundials PRIVATE ${stan_arm64_fp_options}) + if(BUILD_TESTING) + target_compile_options(stan_sundials PRIVATE ${STAN_TEST_OPTIMIZATION_FLAGS}) + endif() + target_include_directories(stan_sundials SYSTEM PUBLIC "${STAN_SUNDIALS_ROOT}/include") + target_include_directories(stan_sundials PRIVATE "${STAN_SUNDIALS_ROOT}/src/sundials") + target_compile_definitions(stan_sundials PRIVATE NO_FPRINTF_OUTPUT) + if(MSVC) + target_compile_options(stan_sundials PRIVATE + "/FI${STAN_SUNDIALS_ROOT}/include/stan_sundials_printf_override.hpp") + else() + target_compile_options(stan_sundials PRIVATE + -include "${STAN_SUNDIALS_ROOT}/include/stan_sundials_printf_override.hpp") + target_link_libraries(stan_sundials PUBLIC m) + endif() + target_link_libraries(stanmath INTERFACE stan_sundials) +endif() +if(BUILD_TESTING) + set(BUILD_GMOCK OFF CACHE BOOL "Build Google Mock") + set(INSTALL_GTEST OFF CACHE BOOL "Install Google Test") + function(stan_add_googletest) + # CMake 4 removed policies older than 3.5. Limit the compatibility setting + # to this old dependency, preserving a higher caller-provided minimum. + if(NOT DEFINED CMAKE_POLICY_VERSION_MINIMUM + OR CMAKE_POLICY_VERSION_MINIMUM VERSION_LESS "3.5") + set(CMAKE_POLICY_VERSION_MINIMUM 3.5) + endif() + add_subdirectory("${STAN_BENCHMARK_ROOT}/googletest" + "${CMAKE_CURRENT_BINARY_DIR}/googletest" EXCLUDE_FROM_ALL) + endfunction() + stan_add_googletest() + if(WIN32) + target_compile_definitions(gtest PUBLIC GTEST_HAS_PTHREAD=0) + endif() +endif() diff --git a/cmake/MakeLocal.cmake b/cmake/MakeLocal.cmake new file mode 100644 index 00000000000..3190411533f --- /dev/null +++ b/cmake/MakeLocal.cmake @@ -0,0 +1,47 @@ +option(STAN_USE_MAKE_LOCAL "Read supported settings from make/local" ON) +set(STAN_MAKE_LOCAL "${CMAKE_SOURCE_DIR}/make/local" CACHE FILEPATH "Local Make settings file") + +macro(stan_cache_default name value type description) + if(NOT DEFINED ${name}) + set(${name} "${value}" CACHE ${type} "${description}") + endif() +endmacro() + +if(STAN_USE_MAKE_LOCAL) + file(GLOB local_settings CONFIGURE_DEPENDS "${STAN_MAKE_LOCAL}") + if(local_settings) + find_package(Python3 REQUIRED COMPONENTS Interpreter) + find_program(STAN_SETTINGS_MAKE NAMES gmake make mingw32-make) + if(NOT STAN_SETTINGS_MAKE) + message(FATAL_ERROR "Reading make/local requires GNU Make. Use STAN_USE_MAKE_LOCAL=OFF to ignore make/local.") + endif() + execute_process( + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_LIST_DIR}/make_local.py" + --make "${STAN_SETTINGS_MAKE}" --source "${CMAKE_SOURCE_DIR}" + --local "${STAN_MAKE_LOCAL}" --output "${CMAKE_BINARY_DIR}/make-local.cmake" + RESULT_VARIABLE settings_status + ERROR_VARIABLE settings_error) + if(NOT settings_status EQUAL 0) + message(FATAL_ERROR "${settings_error}") + endif() + include("${CMAKE_BINARY_DIR}/make-local.cmake") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${CMAKE_CURRENT_LIST_DIR}/make_local.py") + message(STATUS "Loaded Make settings from ${STAN_MAKE_LOCAL}") + endif() +endif() + +function(stan_apply_make_local_flags) + foreach(language C CXX) + separate_arguments(flags NATIVE_COMMAND "${STAN_MAKE_LOCAL_${language}_FLAGS}") + foreach(flag IN LISTS flags) + add_compile_options("$<$:${flag}>") + endforeach() + endforeach() + separate_arguments(flags NATIVE_COMMAND "${STAN_MAKE_LOCAL_CPP_FLAGS}") + add_compile_options(${flags}) + separate_arguments(flags NATIVE_COMMAND "${STAN_MAKE_LOCAL_LINK_FLAGS}") + add_link_options(${flags}) + separate_arguments(libraries NATIVE_COMMAND "${STAN_MAKE_LOCAL_LIBRARIES_FLAGS}") + link_libraries(${libraries}) +endfunction() diff --git a/cmake/StanTest.cmake b/cmake/StanTest.cmake new file mode 100644 index 00000000000..e681b6576ac --- /dev/null +++ b/cmake/StanTest.cmake @@ -0,0 +1,48 @@ +# Test flags and precompiled headers shared by the test suite and the +# compile-only checks. A single set of flags and dependencies keeps PCH +# producers and consumers compatible. Do not enable IPO or add +# target-specific optimization flags. +set(STAN_EFFECTIVE_TEST_PCH ${STAN_TEST_PCH}) +if(STAN_TEST_PCH AND CMAKE_CXX_COMPILER_ID STREQUAL "Clang" + AND CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL "9" + AND CMAKE_CXX_COMPILER_VERSION VERSION_LESS "11") + # Clang 9/10 cannot deserialize Eigen's __builtin_bit_cast expressions. + # Fixed in LLVM 95d7ccb70b9cbd53f1f137c0b2411852c42c122b (Clang 11). + set(STAN_EFFECTIVE_TEST_PCH OFF) + message(STATUS "Test PCH disabled: Clang 9/10 PCH bit_cast defect (fixed in Clang 11)") +endif() + +add_library(stan_test_flags INTERFACE) +target_link_libraries(stan_test_flags INTERFACE stanmath) +if(MSVC) + target_compile_options(stan_test_flags INTERFACE ${STAN_TEST_OPTIMIZATION_FLAGS} /W3) +else() + target_compile_options(stan_test_flags INTERFACE + ${STAN_TEST_OPTIMIZATION_FLAGS} -Wall -Wno-deprecated-declarations -Wno-sign-compare) + if(CMAKE_CXX_COMPILER_ID MATCHES "Clang") + # Keep explicit autodiff callback captures: dependent if-constexpr branches + # can leave a capture unused for an individual template instantiation. + target_compile_options(stan_test_flags INTERFACE -Wno-unused-lambda-capture) + endif() + if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + target_compile_options(stan_test_flags INTERFACE + -Werror=invalid-pch -Wno-misleading-indentation) + endif() +endif() + +# Producers and consumers of a PCH must link the same options target. +function(stan_add_pch target options) + if(STAN_EFFECTIVE_TEST_PCH) + add_library(${target} OBJECT EXCLUDE_FROM_ALL "${PROJECT_SOURCE_DIR}/cmake/empty.cpp") + target_link_libraries(${target} PRIVATE ${options}) + target_precompile_headers(${target} PRIVATE ${ARGN}) + endif() +endfunction() + +# A PCH target exists only when precompiled headers are in effect. +function(stan_configure_test target options pch) + target_link_libraries(${target} PRIVATE ${options}) + if(TARGET "${pch}") + target_precompile_headers(${target} REUSE_FROM ${pch}) + endif() +endfunction() diff --git a/cmake/empty.cpp b/cmake/empty.cpp new file mode 100644 index 00000000000..1b2de39da12 --- /dev/null +++ b/cmake/empty.cpp @@ -0,0 +1 @@ +// Empty translation unit for shared PCH providers and standalone header checks. diff --git a/cmake/make_local.py b/cmake/make_local.py new file mode 100644 index 00000000000..2af6bde2b54 --- /dev/null +++ b/cmake/make_local.py @@ -0,0 +1,120 @@ +"""Evaluate supported make/local settings and export CMake defaults.""" + +import argparse +import os +from pathlib import Path +import re +import shlex +import subprocess + + +SETTINGS = { + "CC": "CMAKE_C_COMPILER", + "CXX": "CMAKE_CXX_COMPILER", + "O": "STAN_TEST_OPTIMIZATION", + "OPENCL_DEVICE_ID": "STAN_OPENCL_DEVICE_ID", + "OPENCL_PLATFORM_ID": "STAN_OPENCL_PLATFORM_ID", + **{name: name for name in ( + "STAN_THREADS", "STAN_OPENCL", "STAN_MPI", "STAN_NO_RANGE_CHECKS", + "STAN_TEST_ROW_VECTORS", "STAN_PROB_TEST_ALL")}, + **{name: "STAN_" + name + "_ROOT" for name in ( + "BOOST", "EIGEN", "TBB", "SUNDIALS", "OPENCL", "BENCHMARK")}, + "INTEGRATED_OPENCL": "STAN_INTEGRATED_OPENCL", +} +FLAGS = { + "CXX": ("CXXFLAGS", "CXXFLAGS_OPTIM", "CXXFLAGS_WARNINGS"), + "C": ("CFLAGS",), + "CPP": ("CPPFLAGS", "CPPFLAGS_OPTIM"), + "LINK": ("LDFLAGS", "LDFLAGS_OPTIM"), + "LIBRARIES": ("LDLIBS",), +} +FEATURES = {name for name in SETTINGS if name.startswith("STAN_")} + + +def bracket(value): + delimiter = "=" + while "]" + delimiter + "]" in value: + delimiter += "=" + return "[" + delimiter + "[" + value + "]" + delimiter + "]" + + +def read_local(make, source, local): + names = list(SETTINGS) + [name for group in FLAGS.values() for name in group] + escaped_local = str(local).replace("\\", "/").replace(" ", "\\ ").replace("#", "\\#").replace("$", "$$") + makefile = "include " + escaped_local + "\n" + for name in names: + makefile += (f"$(info __STAN_CMAKE_BEGIN_{name}__)\n" + f"$(info $(if $(filter file override,$(origin {name})),$({name})))\n" + f"$(info __STAN_CMAKE_END_{name}__)\n") + makefile += ("$(info __STAN_CMAKE_BEGIN_FILES__)\n$(info $(MAKEFILE_LIST))\n" + "$(info __STAN_CMAKE_END_FILES__)\n" + ".PHONY: stan-cmake-settings\nstan-cmake-settings:\n") + environment = dict(os.environ) + for name in ("MAKEFLAGS", "MFLAGS", "GNUMAKEFLAGS", "MAKELEVEL"): + environment.pop(name, None) + result = subprocess.run([make, "--no-print-directory", "-s", "-f", "-", + "stan-cmake-settings"], cwd=source, env=environment, + input=makefile, text=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + if result.returncode: + raise ValueError(f"Cannot evaluate {local}:\n{result.stderr}") + values = {} + for name in names + ["FILES"]: + match = re.search(f"__STAN_CMAKE_BEGIN_{name}__\n(.*?)\n__STAN_CMAKE_END_{name}__", + result.stdout, re.DOTALL) + if not match: + raise ValueError(f"Missing GNU Make output for {name} in {local}") + values[name] = match.group(1).strip() + return values + + +def cmake_settings(values, source): + output = [] + for name, target in SETTINGS.items(): + value = values[name] + if name in FEATURES or name == "INTEGRATED_OPENCL": + value = ("ON" if value else "OFF") if name in FEATURES else ("1" if value else "0") + elif not value: + continue + elif target.endswith("_ROOT"): + path = Path(value) + value = str((source / path).resolve()) + elif name in ("CC", "CXX"): + value = ";".join(shlex.split(value)) + output.append(f"if(NOT DEFINED {target})\n set({target} {bracket(value)})\nendif()\n") + for group, names in FLAGS.items(): + value = " ".join(values[name] for name in names if values[name]) + output.append(f"set(STAN_MAKE_LOCAL_{group}_FLAGS {bracket(value)})\n") + files = [] + words = values["FILES"].split() + index = 0 + while index < len(words): + for end in range(index + 1, len(words) + 1): + path = source / " ".join(words[index:end]) + if path.is_file(): + files.append(str(path.resolve())) + index = end - 1 + break + index += 1 + output.append("set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS\n") + output.extend(" " + bracket(path) + "\n" for path in files) + output.append(")\n") + return "".join(output) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--make", required=True) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--local", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + try: + values = read_local(args.make, args.source, args.local) + args.output.write_text(cmake_settings(values, args.source)) + except ValueError as error: + parser.exit(1, str(error) + "\n") + + +if __name__ == "__main__": + main() diff --git a/cmake/stanc.cmake b/cmake/stanc.cmake new file mode 100644 index 00000000000..09583e4fda8 --- /dev/null +++ b/cmake/stanc.cmake @@ -0,0 +1,26 @@ +if(NOT EXISTS "${STAN_STANC_FILE}") + if(CMAKE_HOST_WIN32) + set(platform windows) + elseif(CMAKE_HOST_APPLE) + set(platform mac) + else() + set(platform linux) + endif() + find_program(curl_program NAMES curl) + if(NOT curl_program) + message(FATAL_ERROR "Downloading the expression test compiler requires curl") + endif() + execute_process(COMMAND "${curl_program}" -L --fail --retry 5 --retry-delay 10 + "https://github.com/stan-dev/stanc3/releases/download/nightly/${platform}-stanc" + -o "${STAN_STANC_FILE}" RESULT_VARIABLE status) + if(NOT status EQUAL 0) + file(REMOVE "${STAN_STANC_FILE}") + message(FATAL_ERROR "Downloading the expression test compiler failed") + endif() + if(NOT CMAKE_HOST_WIN32) + execute_process(COMMAND chmod +x "${STAN_STANC_FILE}" RESULT_VARIABLE status) + if(NOT status EQUAL 0) + message(FATAL_ERROR "Setting expression test compiler permissions failed") + endif() + endif() +endif() diff --git a/runTests.py b/runTests.py index ddf65fe83c1..c0bd7935c9c 100755 --- a/runTests.py +++ b/runTests.py @@ -1,76 +1,19 @@ #!/usr/bin/env python3 +"""Build and run Stan Math tests with CMake.""" -""" -Replacement for runtest target in Makefile. - -Call script with '-h' as an option to see a helpful message. -""" - -from __future__ import print_function - +from concurrent.futures import ThreadPoolExecutor import glob +import json import os -import os.path -import platform +from pathlib import Path import re +import shlex +import shutil import subprocess import sys -import time from argparse import ArgumentParser, RawTextHelpFormatter -winsfx = ".exe" -testsfx = "_test.cpp" - -allowed_paths_with_jumbo = [ - "test/unit/math/prim/", - "test/unit/math/prim/constraint", - "test/unit/math/", - "test/unit/math/rev/", - "test/unit/math/rev/constraint", - "test/unit/math/fwd/", - "test/unit/math/fwd/constraint", - "test/unit/math/mix/", - "test/unit/math/mix/fun/", - "test/unit/math/opencl/", - "test/unit/", -] - -jumbo_folders = [ - "test/unit/math/prim/constraint", - "test/unit/math/prim/core", - "test/unit/math/prim/err", - "test/unit/math/prim/fun", - "test/unit/math/prim/functor", - "test/unit/math/prim/meta", - "test/unit/math/prim/prob", - "test/unit/math/rev/constraint", - "test/unit/math/rev/core", - "test/unit/math/rev/err", - "test/unit/math/rev/fun", - "test/unit/math/rev/functor", - "test/unit/math/rev/meta", - "test/unit/math/rev/prob", - "test/unit/math/fwd/constraint", - "test/unit/math/fwd/core", - "test/unit/math/fwd/fun", - "test/unit/math/fwd/functor", - "test/unit/math/fwd/meta", - "test/unit/math/fwd/prob", - "test/unit/math/mix/core", - "test/unit/math/mix/fun", - "test/unit/math/mix/functor", - "test/unit/math/mix/meta", - "test/unit/math/mix/prob", - "test/unit/math/opencl/device_functions", - "test/unit/math/opencl/kernel_generator", - "test/unit/math/opencl/prim", - "test/unit/math/opencl/prim/constraint", - "test/unit/math/opencl/rev", - "test/unit/math/opencl/rev/constraint", -] - - -def processCLIArgs(): +def processCLIArgs(arguments): """ Define and process the command line interface to the runTests.py script. """ @@ -83,9 +26,29 @@ def processCLIArgs(): formatter_class=RawTextHelpFormatter, ) - # Now define all the rules of the command line args and opts + parser.set_defaults(command=None) + if arguments and arguments[0] in ("changed-unit-filter", "generate-expressions", "build-tests"): + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("changed-unit-filter") + expressions = commands.add_parser("generate-expressions") + expressions.add_argument("build_dir") + expressions.add_argument("--functions", nargs="*", default=[]) + expressions.add_argument("--batches", type=int, default=6) + build = commands.add_parser("build-tests") + build.add_argument("build_dir") + build.add_argument("--source-regex", required=True) + build.add_argument("--jobs", type=int, default=1) + inputs = parser.parse_args(arguments) + if getattr(inputs, "jobs", 1) < 1 or getattr(inputs, "batches", 1) < 1: + parser.error("job and batch counts must be positive") + return inputs + + parser.add_argument( + "-j", metavar="N", type=int, default=1, help="parallel build jobs (default: 1)" + ) parser.add_argument( - "-j", metavar="N", type=int, default=1, help="number of cores for make to use" + "--test-jobs", metavar="N", type=int, default=1, + help="test executables run at once (default: 1)" ) parser.add_argument( @@ -123,6 +86,12 @@ def processCLIArgs(): action="store_true", help="Don't make tests, just try to run them (must already be made).", ) + parser.add_argument( + "--configure-only", + dest="configure_only", + action="store_true", + help="Only configure the CMake build directory.", + ) parser.add_argument( "--run-all", dest="run_all", @@ -140,323 +109,313 @@ def processCLIArgs(): "--jumbo-test", dest="do_jumbo", action="store_true", - help="Build/run jumbo tests.", + help="accepted for compatibility, directory tests already use one executable", ) tests_help_msg = "The path(s) to the test case(s) to run.\n" tests_help_msg += "Example: 'test/unit', 'test/prob', and/or\n" - tests_help_msg += " 'test/unit/math/prim/fun/abs_test.cpp'" + tests_help_msg += " 'test/unit/math/prim/fun/abs_test.cpp', and/or\n" + tests_help_msg += " 'test/prob/normal/normal_test.hpp' (a distribution fixture)" parser.add_argument("tests", nargs="*", type=str, help=tests_help_msg) + parser.add_argument("--build-dir", default="build", help="CMake build directory") + parser.add_argument("--fresh", action="store_true", + help="reset the CMake cache before configuring, like cmake --fresh") + parser.add_argument("--cmake-arg", action="append", default=[], + help="extra configure argument, e.g. --cmake-arg=-DSTAN_THREADS=ON") + parser.add_argument("--config", default="ReleaseTesting", + help="CMake build configuration (ReleaseTesting: -O1 without NDEBUG)") # And parse the command line against those rules - return parser.parse_args() - - -def stopErr(msg, returncode): - """Report an error message to stderr and exit with a given code.""" - sys.stderr.write("%s\n" % msg) - sys.stderr.write("exit now (%s)\n" % time.strftime("%x %X %Z")) - sys.exit(returncode) - - -def isWin(): - return platform.system().lower().startswith( - "windows" - ) or os.name.lower().startswith("windows") - - -batchSize = 20 if isWin() else 200 -jumboSize = 5 if isWin() else 12 -maxChangedTests = 20 - - -def mungeName(name): - """Set up the makefile target name""" - if name.endswith(testsfx): - name = name.replace(testsfx, "_test") - - if isWin(): - name += winsfx - name = name.replace("\\", "/") - return name - - -def doCommand(command, exit_on_failure=True): - """Run command as a shell command and report/exit on errors.""" - print("------------------------------------------------------------") - print("%s" % command) - p1 = subprocess.Popen(command, shell=True) - p1.wait() - if exit_on_failure and (not (p1.returncode is None) and not (p1.returncode == 0)): - stopErr("%s failed" % command, p1.returncode) - - -def generateTests(j): - """Generate all tests and pass along the j parameter to make.""" - doCommand("make -j%d generate-tests -s" % (j or 1)) - - -def divide_chunks(l, n): - # looping till length l - for i in range(0, len(l), n): - yield l[i : i + n] - -include_pattern = re.compile(r'^\s*#include\s+<.*>$') - -def generateJumboTests(paths, debug=False): - jumbo_files_to_create = [] - jumbo_files = [] - for p in paths: - if not p.endswith(testsfx) and not p.endswith("/"): - p = p + "/" - if p in allowed_paths_with_jumbo: - jumbo_files_to_create.extend([x for x in jumbo_folders if x.startswith(p)]) - else: - stopErr("The --jumbo flag is only allowed with top level folders.", 10) - for jf in jumbo_files_to_create: - tests_in_subfolder = sorted(os.path.join(jf, x) for x in os.listdir(jf) if x.endswith(testsfx)) - for i, tests in enumerate(divide_chunks(tests_in_subfolder, jumboSize)): - - includes = [] - jumbo_contents = [] - for t in tests: - with open(t, "r") as test_file: - contents_raw = test_file.read() - - test_contents = [] - for l in contents_raw.splitlines(): - if include_pattern.fullmatch(l.strip()): - includes.append(l.strip()) - continue - test_contents.append(l) - - jumbo_contents.append('\n'.join(test_contents)) - - jumbo_file_path = jf + "_" + str(i) + testsfx - jumbo_files.append(jumbo_file_path) - if debug: - print("Generating jumbo test file '{}' with tests: {}".format( jumbo_file_path, ', '.join(tests))) - with open(jumbo_file_path, "w") as jumbo_file: - jumbo_file.write('\n'.join(list(dict.fromkeys(includes)))) - jumbo_file.write('\n') - jumbo_file.write('\n'.join(jumbo_contents)) - return jumbo_files - - -def cleanupJumboTests(paths): - for f in paths: - if os.path.exists(f): - os.remove(f) - - -def makeTest(name, j): - """Run the make command for a given single test.""" - doCommand("make -j%d %s" % (j or 1, name)) - - -def commandExists(command): - p = subprocess.Popen( - command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE - ) - p.wait() - return p.returncode != 127 - - -def runTest(name, run_all=False, mpi=False, j=1): - executable = mungeName(name).replace("/", os.sep) - xml = mungeName(name).replace(winsfx, "") - command = '%s --gtest_output="xml:%s.xml"' % (executable, xml) - if mpi: - if not commandExists("mpirun"): - stopErr( - "Error: need to have mpi (and mpirun) installed to run mpi tests", - -1, - ) - if "mpi_" in name: - j = j > 2 and j or 2 - else: - j = 1 - command = "mpirun -np {} {}".format(j, command) - doCommand(command, not run_all) - - -def test_files_in_folder(folder): - """Returns a list of test files (*_test.cpp) in the folder and all - its subfolders recursively. The folder can be written with - wildcards as with the Unix find command. - """ - files = [] - for f in glob.glob(folder): - if os.path.isdir(f): - files.extend(test_files_in_folder(f + os.sep + "**")) - else: - if f.endswith(testsfx): - files.append(f) - return files - - -def findTests(base_path, filter_names, do_jumbo=False): - tests = [] - for path in base_path: - if (not os.path.isdir(path)) and path.endswith("_test"): - tests.append(path + ".cpp") - else: - tests.extend(test_files_in_folder(path)) - tests = map(mungeName, tests) - tests = [ - test - for test in tests - if all(filter_name in test for filter_name in filter_names) - ] - if do_jumbo: - filtered_jumbo_tests = [] - for t in tests: - add = True - for k in jumbo_folders: - k = k + "/" - if t.startswith(k): - add = False - break - if add: - filtered_jumbo_tests.append(t) - tests = filtered_jumbo_tests - return tests - - -def findChangedTests(debug): - import subprocess - - changed_files = subprocess.run( - ["git", "diff", "--name-only", "--diff-filter=d", "origin/develop...HEAD"], - text=True, capture_output=True - ).stdout.splitlines() - if debug: - print("Changed files:", changed_files) - - # changes in prim should also test other non-prim signatures - test_deps = {"prim/": ["prim/", "rev/", "mix/", "fwd/"], "rev/": ["rev/", "mix/"], "fwd/": ["fwd/", "mix/"]} - - changed_tests = set() - for f in changed_files: - if 'stan/' in f and f.endswith('.hpp') and 'opencl' not in f: # changed fn - maybe_test = f.replace('stan/', 'test/unit/').replace('.hpp', testsfx) - for (path, replacements) in test_deps.items(): - if path in maybe_test: - for rep in replacements: - maybe_test = maybe_test.replace(path, rep) - if os.path.exists(maybe_test): - changed_tests.add(maybe_test) - - if f.endswith(testsfx) and 'opencl' not in f: - changed_tests.add(f) - - if len(changed_tests) > maxChangedTests: - stopErr("Number of changed tests excluded maximum, not running priority tests", 0) - - return list(map(mungeName, changed_tests)) - - -def batched(tests): - return [tests[i : i + batchSize] for i in range(0, len(tests), batchSize)] - - -def handleExpressionTests(tests, only_functions, n_test_files): - expression_tests = False - for n, i in list(enumerate(tests))[::-1]: - if "test/expressions" in i or "test\\expressions" in i: - del tests[n] - expression_tests = True - if expression_tests: - HERE = os.path.dirname(os.path.realpath(__file__)) - sys.path.append(os.path.join(HERE, "test")) - sys.path.append(os.path.join(HERE, "test/expressions")) - import generate_expression_tests - - generate_expression_tests.main(only_functions, n_test_files) - for i in range(n_test_files): - tests.append("test/expressions/tests%d_test.cpp" % i) - elif only_functions: - stopErr( - "--only-functions can only be specified if running expression tests (test/expressions)", - -1, - ) - - -def checkToolchainPathWindows(): - if isWin(): - p1 = subprocess.Popen( - "where.exe make", - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - universal_newlines=True, - ) - out, err = p1.communicate() - if re.search(r" |\(|\)", out): - stopErr( - "The RTools toolchain is installed in a path with spaces or bracket. Please reinstall to a valid path.", - -1, - ) - - -def main(): - inputs = processCLIArgs() - checkToolchainPathWindows() - - try: - with open("make/local") as f: - stan_mpi = "STAN_MPI" in f.read() - except IOError: - stan_mpi = False - - jumboFiles = [] - - if inputs.changed: - tests = findChangedTests(inputs.debug) + inputs = parser.parse_args(arguments) + if inputs.j < 1 or inputs.test_jobs < 1: + parser.error("-j and --test-jobs must be positive") + if inputs.test_only and (inputs.make_only or inputs.configure_only): + parser.error("--test-only cannot be combined with --make-only or --configure-only") + if inputs.fresh and inputs.test_only: + parser.error("--fresh cannot be combined with --test-only") + return inputs + + +def findChangedTests(files, existing_tests, limit=20): + """Select priority tests from supplied changes and existing test paths.""" + dependencies = {"prim": ("prim", "rev", "mix", "fwd"), + "rev": ("rev", "mix"), "fwd": ("fwd", "mix")} + selected = set() + for path in files: + if "opencl" in path: + continue + if path.endswith("_test.cpp"): + selected.add(path) + elif path.startswith("stan/") and path.endswith(".hpp"): + candidate = path.replace("stan/", "test/unit/", 1)[:-4] + "_test.cpp" + layer = candidate.split("/")[3] + for replacement in dependencies.get(layer, (layer,)): + test = candidate.replace("/" + layer + "/", "/" + replacement + "/", 1) + if test in existing_tests: + selected.add(test) + return sorted(selected) if len(selected) <= limit else [] + + +def is_fixture(path): + return re.fullmatch(r"test/prob/[^/]+/[^/]+_test\.hpp", path) is not None + + +def cmake_escape(text): + return re.sub(r"([.+*?^$()\[\]{}|\\])", r"\\\1", text) + + +def selection_regex(paths): + """Match the source paths of the selected files and fixtures' generated tests.""" + patterns = [cmake_escape(path[:-len("_test.hpp")]) + "_[0-9]+_generated_.*" if is_fixture(path) + else cmake_escape(path) for path in sorted(paths) + if is_fixture(path) or path.endswith(".cpp")] + return "^(" + "|".join(patterns) + ")$" if patterns else "" + + +def fixture_tests(inventory, fixtures): + """Expand fixtures to the generated sources CMake registered for them.""" + prefixes = tuple(fixture[:-len("_test.hpp")] + "_" for fixture in fixtures) + sources = set() + for test in inventory["tests"]: + for entry in test.get("properties", []): + if entry["name"] != "LABELS": + continue + for label in entry["value"]: + for prefix in prefixes: + if label.startswith(prefix) and re.match( + r"[0-9]+_generated_.*_test\.cpp$", label[len(prefix):]): + sources.add(label) + return sorted(sources) + + +def select_paths(matches, directories): + """Validate expanded paths and remove selections covered by a directory.""" + paths = set() + for argument, expanded in matches.items(): + if not expanded: + raise ValueError("No matching tests: " + argument) + for path in expanded: + if not any(path == base or path.startswith(base + "/") + for base in ("test/unit", "test/prob", "test/expressions")): + raise ValueError("Unsupported test path: " + path) + if path not in directories and not path.endswith("_test.cpp") and not is_fixture(path): + raise ValueError("Expected a test directory, *_test.cpp, or " + "test/prob/*/*_test.hpp: " + path) + paths.add(path) + return [path for path in sorted(paths) + if not any(path.startswith(parent + "/") for parent in directories)] + + +def target_for(path): + if path.endswith(".cpp"): + target = path[:-4].replace("/", "_") else: - if not inputs.test_only: - # pass 0: generate all auto-generated tests - if any("test/prob" in arg for arg in inputs.tests): - generateTests(inputs.j) - - if inputs.do_jumbo: - jumboFiles = generateJumboTests(inputs.tests) - if inputs.e == -1: - if inputs.j == 1: - num_expr_test_files = 1 - else: - num_expr_test_files = inputs.j * 4 - else: - num_expr_test_files = inputs.e - handleExpressionTests(inputs.tests, inputs.only_functions, num_expr_test_files) - - tests = findTests(inputs.tests, inputs.f, inputs.do_jumbo) - - if not tests: - if inputs.changed: - stopErr("No changed tests were found!", 0) - stopErr( - "No matching tests found. Check the path passed on the command line", -1 - ) - + prefix = path.replace("test/unit/math", "unit_math", 1) + prefix = prefix.replace("test/unit", "unit", 1) + prefix = prefix.replace("test/prob", "prob_math", 1) + prefix = prefix.replace("test/expressions", "expressions", 1) + target = prefix.replace("/", "_") + "_all_tests" + folder = "test/unit" if path.startswith("test/unit") else ( + "test/prob" if path.startswith("test/prob") else "test") + return target, folder + + +def build_commands(inputs, paths, root, build_dir): + """Plan configuration and builds for the CMake targets selected by the user.""" + if inputs.test_only: + return [] + expressions = any(path.startswith("test/expressions") for path in paths) + regex = selection_regex(paths) + configure = ["cmake", "-S", str(root), "-B", str(build_dir), + "-DCMAKE_BUILD_TYPE=" + inputs.config, "-DBUILD_TESTING=ON", + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", "-DSTAN_TEST_HEADERS=OFF", + "-DSTAN_BUILD_PROB_TESTS=" + ("ON" if any(path.startswith("test/prob") for path in paths) else "OFF"), + "-DSTAN_BUILD_EXPRESSION_TESTS=" + ("ON" if expressions else "OFF"), + "-DSTAN_TEST_NAME_FILTERS=" + ";".join(inputs.f), + "-DSTAN_SELECTED_TEST_REGEX=" + regex] + if expressions: + configure.append("-DSTAN_EXPRESSION_TEST_DIR=" + str(build_dir / "expression-sources")) + if inputs.configure_only: + return [configure + inputs.cmake_arg] + # Individual files and fixtures build through one aggregate target. + targets = [target_for(path)[0] for path in paths if not path.endswith(".cpp") and not is_fixture(path)] + if regex: + targets.append("stan_selected_tests") + build = ["cmake", "--build", str(build_dir), "--config", inputs.config, + "--parallel", str(inputs.j), "--target"] + targets + return [configure + inputs.cmake_arg, build] + + +def parse_cache(text): + cache = {} + for line in text.splitlines(): + if not line.startswith(("//", "#")) and "=" in line and ":" in line.split("=", 1)[0]: + key, value = line.split("=", 1) + cache[key.split(":", 1)[0]] = value + return cache + + +def test_command(path, build_dir, config, cache, windows): + """Plan an executable invocation using supplied CMake settings.""" + target, folder = target_for(path) + executable = build_dir / folder / target + if cache.get("CMAKE_CONFIGURATION_TYPES"): + executable = executable.parent / config / executable.name + if cache.get("CMAKE_SYSTEM_NAME") == "Windows" or windows: + executable = executable.with_suffix(".exe") + args = [str(executable), "--gtest_output=xml:" + str(build_dir / "test-results" / (target + ".xml"))] + if cache.get("STAN_EFFECTIVE_MPI", cache.get("STAN_MPI")) in ("ON", "TRUE", "1"): + ranks = "2" if not path.endswith(".cpp") or "mpi_" in path else "1" + args = [cache.get("MPIEXEC_EXECUTABLE", "mpirun"), + cache.get("MPIEXEC_NUMPROC_FLAG", "-np"), ranks] + [ + flag for flag in cache.get("MPIEXEC_PREFLAGS", "").split(";") if flag] + args + [ + flag for flag in cache.get("MPIEXEC_POSTFLAGS", "").split(";") if flag] + return args + + +def source_filter(paths): + return "^(" + "|".join(re.escape(path) for path in sorted(set(paths))) + ")$" if paths else "" + + +def build_tests(inventory, source_pattern, build_dir, jobs, batch_size): + """Plan builds using the source labels registered by CMake.""" + pattern = re.compile(source_pattern) + selected, groups = [], {} + for test in inventory["tests"]: + labels = [label for entry in test.get("properties", []) + if entry["name"] == "LABELS" for label in entry["value"]] + if any(pattern.search(label) for label in labels if not label.startswith("stan_build:")): + selected.append(test["name"]) + for label in labels: + if label.startswith("stan_build:"): + groups.setdefault(label[len("stan_build:"):], set()).add(test["name"]) + if not selected: + raise ValueError("No registered tests match: " + source_pattern) + # Makefile generators serialize separate top-level targets. An existing + # aggregate shares one dependency graph and honors the requested job count. + remaining, targets = set(selected), [] + for target, members in sorted(groups.items(), key=lambda item: (-len(item[1]), item[0])): + if len(members) > 1 and members <= remaining: + targets.append(target) + remaining.difference_update(members) + targets.extend(name for name in selected if name in remaining) + return [["cmake", "--build", str(build_dir), "--parallel", str(jobs), "--target"] + + targets[offset:offset + batch_size] for offset in range(0, len(targets), batch_size)] + + +def main(arguments, root, working_dir, windows, error_stream): + """Perform command-line I/O. Helpers above only transform explicit inputs.""" + inputs = processCLIArgs(arguments) try: - # pass 1: make test executables - if not inputs.test_only: - for batch in batched(tests): - if inputs.debug: - print("Test batch: ", batch) - makeTest(" ".join(batch), inputs.j) - if not inputs.make_only: - # pass 2: run test targets - for t in tests: - if inputs.debug: - print("run single test: %s" % t) - runTest(t, inputs.run_all, mpi=stan_mpi, j=inputs.j) - except BaseException as e: - print(e, file=sys.stderr) - sys.exit(1) - finally: - cleanupJumboTests(jumboFiles) - pass + if getattr(inputs, "changed", False) or inputs.command == "changed-unit-filter": + files = subprocess.run(["git", "diff", "--name-only", "--diff-filter=d", + "origin/develop...HEAD"], cwd=root, check=True, + text=True, capture_output=True).stdout.splitlines() + if getattr(inputs, "debug", False): + print("Changed files:", files) + existing = {path.relative_to(root).as_posix() + for path in (root / "test/unit").rglob("*_test.cpp")} + paths = findChangedTests(files, existing) + if inputs.command == "changed-unit-filter": + print(source_filter(paths)) + return 0 + if not paths: + print("No changed tests were found!") + return 0 + elif inputs.command is None: + matches = {} + for argument in inputs.tests: + # os.path.join keeps a trailing slash, so "test/prob/*/" matches directories. + expanded = glob.glob(os.path.join(str(root), argument)) + if not expanded and argument.replace("\\", "/").lstrip("./").startswith( + ("test/prob/", "test/expressions/")) and argument.endswith("_test.cpp"): + expanded = [str(root / argument)] + matches[argument] = [Path(path).resolve().relative_to(root).as_posix() + for path in expanded] + directories = {path for expanded in matches.values() for path in expanded + if (root / path).is_dir()} + paths = select_paths(matches, directories) + build_dir = (working_dir / inputs.build_dir).resolve() + if inputs.command == "build-tests": + inventory = subprocess.run(["ctest", "--show-only=json-v1"], + cwd=build_dir, check=True, text=True, capture_output=True) + commands = build_tests(json.loads(inventory.stdout), inputs.source_regex, + build_dir, inputs.jobs, 50 if windows else 500) + else: + generate = inputs.command == "generate-expressions" + if not generate: + paths = [path for path in paths if not path.endswith(".cpp") + or all(value in path for value in inputs.f)] + if not paths and not inputs.configure_only: + raise ValueError("No matching tests found") + expressions = any(path.startswith("test/expressions") for path in paths) + if inputs.only_functions and not expressions: + raise ValueError("--only-functions requires test/expressions") + generate = expressions and not inputs.test_only + if generate: + # Keep the legacy generator's output setting inside the I/O boundary. + sys.path.insert(0, str(root / "test")) + import generate_expression_tests + output = build_dir / "expression-sources" + output.mkdir(parents=True, exist_ok=True) + for source in output.glob("*test.cpp"): + source.unlink() + functions = inputs.functions if inputs.command else inputs.only_functions + # Like develop, four files per job keep each translation unit's memory bounded. + batches = inputs.batches if inputs.command else (inputs.e if inputs.e > 0 else inputs.j * 4) + generate_expression_tests.src_folder = str(output) + "/" + generate_expression_tests.main(functions, batches) + if not list(output.glob("*test.cpp")): + raise RuntimeError("Expression generator produced no tests") + if inputs.command == "generate-expressions": + return 0 + commands = build_commands(inputs, paths, root, build_dir) + if inputs.fresh: + # cmake --fresh (CMake 3.24+) deletes the same two paths. + (build_dir / "CMakeCache.txt").unlink(missing_ok=True) + shutil.rmtree(build_dir / "CMakeFiles", ignore_errors=True) + for command in commands: + print(" ".join(shlex.quote(str(arg)) for arg in command), flush=True) + subprocess.run(command, cwd=root, check=True) + if inputs.command == "build-tests" or inputs.make_only or inputs.configure_only: + return 0 + fixtures = [path for path in paths if is_fixture(path)] + if fixtures: + inventory = subprocess.run(["ctest", "--show-only=json-v1"], + cwd=build_dir, check=True, text=True, capture_output=True) + generated =fixture_tests(json.loads(inventory.stdout), fixtures) + if not generated: + raise ValueError("No generated tests found for: " + " ".join(fixtures)) + paths = [path for path in paths if not is_fixture(path)] + generated + cache_file = build_dir / "CMakeCache.txt" + cache = parse_cache(cache_file.read_text() if cache_file.exists() else "") + (build_dir / "test-results").mkdir(parents=True, exist_ok=True) + failed = False + commands = [test_command(path, build_dir, inputs.config, cache, windows) for path in paths] + if inputs.test_jobs > 1: + # Concurrent executables print their output in order once each finishes. + def run(command): + return subprocess.run(command, cwd=root, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + with ThreadPoolExecutor(inputs.test_jobs) as pool: + for command, result in zip(commands, pool.map(run, commands)): + print(" ".join(shlex.quote(arg) for arg in command), flush=True) + print(result.stdout, end="", flush=True) + failed = failed or result.returncode != 0 + return int(failed) + for command in commands: + print(" ".join(shlex.quote(arg) for arg in command), flush=True) + try: + subprocess.run(command, cwd=root, check=True) + except subprocess.CalledProcessError: + if not inputs.run_all: + raise + failed = True + return int(failed) + except (ValueError, OSError, RuntimeError, subprocess.CalledProcessError) as error: + print(error, file=error_stream) + return 1 if __name__ == "__main__": - main() + sys.exit(main(sys.argv[1:], Path(__file__).resolve().parent, + Path.cwd(), os.name == "nt", sys.stderr)) diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt new file mode 100644 index 00000000000..a539af84512 --- /dev/null +++ b/test/CMakeLists.txt @@ -0,0 +1,213 @@ +# Stan's vendored gtest_main initializes the MPI cluster and includes Stan +# headers even when MPI is disabled. Use the test flags instead of +# GoogleTest's internal warning policy for its own sources. +target_link_libraries(gtest_main PRIVATE stan_test_flags) +set_property(TARGET gtest_main PROPERTY COMPILE_FLAGS "") +add_library(stan_test_options INTERFACE) +target_link_libraries(stan_test_options INTERFACE stan_test_flags gtest_main) + +# Set out to TRUE when path contains every STAN_TEST_NAME_FILTERS substring. +function(stan_matches_name_filters path out) + foreach(filter IN LISTS STAN_TEST_NAME_FILTERS) + string(FIND "${path}" "${filter}" match_index) + if(match_index EQUAL -1) + set(${out} FALSE PARENT_SCOPE) + return() + endif() + endforeach() + set(${out} TRUE PARENT_SCOPE) +endfunction() + +# Every test executable is created here so each gets the same dependencies. +function(stan_add_test_binary target) + # Xcode needs a real source alongside object-library references. + add_executable(${target} "${PROJECT_SOURCE_DIR}/cmake/empty.cpp" ${ARGN}) + if(STAN_MPI) + # A static archive can discard the unreferenced registration initializer. + target_sources(${target} PRIVATE $) + endif() + stan_configure_test(${target} stan_test_options "") + if(WIN32) + add_custom_command(TARGET ${target} POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "$" "$" + VERBATIM) + endif() +endfunction() + +# Add the CTest test `name` for one source, labeled with ARGN and the +# source's repository path. Its objects (`name`_objects) are shared with the +# executables of stan_add_test_group. +function(stan_add_gtest name source repo_relative pch) + add_library(${name}_objects OBJECT EXCLUDE_FROM_ALL "${source}") + stan_configure_test(${name}_objects stan_test_options "${pch}") + get_source_file_property(generator_target "${source}" STAN_GENERATOR_TARGET) + if(generator_target) + # Every consuming executable needs generation before object compilation. + add_dependencies(${name}_objects ${generator_target}) + endif() + stan_add_test_binary(${name} "$") + if(STAN_MPI) + set(mpi_ranks 1) + if(repo_relative MATCHES "mpi_") + set(mpi_ranks 2) + endif() + add_test(NAME ${name} COMMAND ${MPIEXEC_EXECUTABLE} ${MPIEXEC_NUMPROC_FLAG} ${mpi_ranks} + ${MPIEXEC_PREFLAGS} $ ${MPIEXEC_POSTFLAGS}) + else() + add_test(NAME ${name} COMMAND ${name}) + endif() + set_tests_properties(${name} PROPERTIES + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" + LABELS "${ARGN};${repo_relative}") + if(STAN_SELECTED_TEST_REGEX AND repo_relative MATCHES "${STAN_SELECTED_TEST_REGEX}") + set_property(GLOBAL APPEND PROPERTY STAN_SELECTED_TESTS ${name}) + endif() +endfunction() + +# Build the tests in ARGN with `prefix`_subtests, or as one executable with +# `prefix`_all_tests. The stan_build: label lets runTests.py find the group. +function(stan_add_test_group prefix) + add_custom_target(${prefix}_subtests DEPENDS ${ARGN}) + set_property(TEST ${ARGN} APPEND PROPERTY LABELS "stan_build:${prefix}_subtests") + set(objects "") + foreach(test IN LISTS ARGN) + list(APPEND objects "$") + endforeach() + stan_add_test_binary(${prefix}_all_tests ${objects}) + set_target_properties(${prefix}_all_tests PROPERTIES EXCLUDE_FROM_ALL TRUE) +endfunction() + +# Add a test for each source in SOURCES, or for each *test.cpp below +# DIRECTORY, named after its path below REPO_PATH. Add a group for every +# directory level, starting with PREFIX. Return the selected tests in +# `PREFIX`_selected_tests. Sources use PCH, or `LAYER_PCH_PREFIX`__pch +# when it exists for their first directory. With AD_PCH, read each source to +# choose AD_PCH or no PCH. +function(add_gtest_grouped_test) + cmake_parse_arguments(PARSE_ARGV 0 arg "" + "PREFIX;REPO_PATH;DIRECTORY;PCH;LAYER_PCH_PREFIX;AD_PCH" "SOURCES") + set(sources ${arg_SOURCES}) + if(NOT sources) + file(GLOB_RECURSE sources CONFIGURE_DEPENDS "${arg_DIRECTORY}/*test.cpp") + endif() + set(directory_keys "") + set(scanned_sources "") + foreach(src IN LISTS sources) + file(RELATIVE_PATH relative_source "${arg_DIRECTORY}" "${src}") + set(repo_relative "${arg_REPO_PATH}/${relative_source}") + if(NOT STAN_OPENCL AND repo_relative MATCHES "/opencl/") + continue() + endif() + if(NOT STAN_MPI AND repo_relative MATCHES "/[^/]*mpi[^/]*test\\.cpp$") + continue() + endif() + string(REPLACE "/" "_" name "${repo_relative}") + string(REGEX REPLACE "\\.cpp$" "" name "${name}") + set(pch "${arg_PCH}") + if(arg_LAYER_PCH_PREFIX) + string(REGEX MATCH "^[^/]+" layer "${relative_source}") + if(TARGET ${arg_LAYER_PCH_PREFIX}_${layer}_pch) + set(pch ${arg_LAYER_PCH_PREFIX}_${layer}_pch) + endif() + endif() + if(arg_AD_PCH) + # Precompile the AD helpers only for sources that already include them; + # their global aliases must not leak into unrelated tests. A source + # that sets STAN_/EIGEN_ macros before including Stan/Eigen gets no PCH. + file(STRINGS "${src}" directives + REGEX "^#(include |[ \t]*(define|undef)[ \t]+(STAN_|EIGEN_))") + list(APPEND scanned_sources "${src}") + if(directives MATCHES "(^|;)#[ \t]*(define|undef)") + set(pch "") + elseif(directives AND TARGET ${arg_AD_PCH}) + set(pch ${arg_AD_PCH}) + endif() + endif() + get_filename_component(relative_dir "${relative_source}" DIRECTORY) + string(REPLACE "/" ";" labels "${relative_dir}") + stan_add_gtest(${name} "${src}" "${repo_relative}" "${pch}" ${arg_PREFIX} ${labels}) + + stan_matches_name_filters("${repo_relative}" selected) + if(NOT selected) + continue() + endif() + set(key "${relative_dir}") + while(TRUE) + if(key STREQUAL "") + set(key ROOT) + endif() + string(REPLACE "/" "_" key "${key}") + if(NOT DEFINED subtests_${key}) + list(APPEND directory_keys "${key}") + endif() + list(APPEND subtests_${key} "${name}") + if(key STREQUAL "ROOT") + break() + endif() + # The next ancestor comes from the path, not the underscore target name. + get_filename_component(relative_dir "${relative_dir}" DIRECTORY) + set(key "${relative_dir}") + endwhile() + endforeach() + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${scanned_sources}) + foreach(key IN LISTS directory_keys) + if(key STREQUAL "ROOT") + stan_add_test_group(${arg_PREFIX} ${subtests_ROOT}) + else() + stan_add_test_group(${arg_PREFIX}_${key} ${subtests_${key}}) + endif() + endforeach() + set(${arg_PREFIX}_selected_tests "${subtests_ROOT}" PARENT_SCOPE) +endfunction() + +add_subdirectory(unit) +if(STAN_BUILD_PROB_TESTS) + add_subdirectory(prob) +endif() +if(STAN_BUILD_EXPRESSION_TESTS) + set(STAN_EXPRESSION_TEST_DIR "${PROJECT_BINARY_DIR}/expression-sources" + CACHE PATH "Directory containing generated expression tests") + file(GLOB expression_sources CONFIGURE_DEPENDS "${STAN_EXPRESSION_TEST_DIR}/*test.cpp") + if(NOT expression_sources) + message(FATAL_ERROR "Generate expression tests before configuring STAN_BUILD_EXPRESSION_TESTS") + endif() + # The expression helper has no include guard; precompile its heavy guarded + # dependencies so each generated source can still include the helper once. + stan_add_pch(expression_pch stan_test_options ) + add_gtest_grouped_test(PREFIX expressions REPO_PATH test/expressions + DIRECTORY "${STAN_EXPRESSION_TEST_DIR}" PCH expression_pch SOURCES ${expression_sources}) +endif() + +# Makefile generators build separate top-level targets one at a time; one +# aggregate builds a selection of individual tests with every job. +get_property(selected_tests GLOBAL PROPERTY STAN_SELECTED_TESTS) +if(selected_tests) + add_custom_target(stan_selected_tests DEPENDS ${selected_tests}) +endif() + +if(STAN_TEST_HEADERS) + # A PCH would conceal missing includes. Force each header into an otherwise + # empty translation unit, and do not inherit the tests' precompiled headers. + file(GLOB_RECURSE headers CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/stan/*.hpp") + add_custom_target(test-headers) + foreach(header IN LISTS headers) + file(RELATIVE_PATH relative_header "${PROJECT_SOURCE_DIR}" "${header}") + if(NOT STAN_OPENCL AND relative_header MATCHES "/opencl/") + continue() + endif() + if(NOT STAN_MPI AND relative_header MATCHES "/mpi_[^/]+\\.hpp$") + continue() + endif() + string(REPLACE "/" "_" name "${relative_header}") + string(REGEX REPLACE "\\.hpp$" "-test" name "${name}") + add_library(${name} OBJECT EXCLUDE_FROM_ALL "${PROJECT_SOURCE_DIR}/cmake/empty.cpp") + target_link_libraries(${name} PRIVATE stanmath) + if(MSVC) + target_compile_options(${name} PRIVATE "/FI${header}") + else() + target_compile_options(${name} PRIVATE -O0 -include "${header}" -Wunused-local-typedefs) + endif() + add_dependencies(test-headers ${name}) + endforeach() +endif() diff --git a/test/cmake.py b/test/cmake.py new file mode 100644 index 00000000000..e8469d437e5 --- /dev/null +++ b/test/cmake.py @@ -0,0 +1,217 @@ +"""Check CMake command planning. Run with python3 test/cmake.py.""" + +import io +import json +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "test" / "prob")) +import getDependencies as get_dependencies +import runTests as runner + + +def inputs(*flags): + return runner.processCLIArgs(["test/unit/math/prim/fun", *flags]) + + +class CMakeTest(unittest.TestCase): + def test_directory_build_with_fresh_filters_and_jobs(self): + configure, build = runner.build_commands(inputs("--fresh", "-j20", "-f", "sum"), + ["test/unit/math/prim/fun"], Path("source"), Path("build")) + self.assertNotIn("--fresh", configure) + self.assertIn("-DCMAKE_BUILD_TYPE=ReleaseTesting", configure) + self.assertEqual(build[build.index("--config") + 1], "ReleaseTesting") + self.assertIn("-DSTAN_TEST_NAME_FILTERS=sum", configure) + self.assertEqual(build[build.index("--parallel") + 1], "20") + self.assertEqual(build[build.index("--target") + 1:], ["unit_math_prim_fun_all_tests"]) + execute = runner.test_command("test/unit/math/prim/fun", Path("build"), "Release", {}, False) + self.assertEqual(Path(execute[0]).name, "unit_math_prim_fun_all_tests") + + def test_fresh_deletes_the_cache_and_keeps_objects(self): + commands = [] + + def run(command, **kwargs): + commands.append(command) + return subprocess.CompletedProcess(command, 0, stdout="") + + with tempfile.TemporaryDirectory() as working_dir: + build = Path(working_dir) / "build" + objects = build / "test/unit/CMakeFiles/sum.dir" + for directory in (build / "CMakeFiles/3.16.3", objects): + directory.mkdir(parents=True) + (build / "CMakeCache.txt").write_text("STAN_THREADS:BOOL=ON\n") + (objects / "sum_test.cpp.o").write_text("") + with patch.object(runner.subprocess, "run", run), patch.object(sys, "stdout", io.StringIO()): + code = runner.main(["--configure-only", "--fresh"], ROOT, Path(working_dir), False, io.StringIO()) + self.assertEqual(code, 0) + self.assertFalse((build / "CMakeCache.txt").exists()) + self.assertFalse((build / "CMakeFiles").exists()) + self.assertTrue((objects / "sum_test.cpp.o").exists()) + self.assertEqual(len(commands), 1) + self.assertNotIn("--fresh", commands[0]) + + def test_build_only_and_test_only(self): + build = runner.build_commands(inputs("--make-only"), ["test/unit/math/prim/fun"], + Path("source"), Path("build")) + self.assertEqual(len(build), 2) + self.assertNotIn("--fresh", build[0]) + self.assertEqual(runner.build_commands(inputs("--test-only"), ["test/unit/math/prim/fun"], + Path("source"), Path("build")), []) + + def test_cmake_cache_controls_configuration_and_mpi_launch(self): + cache = runner.parse_cache("// comment\nCMAKE_CONFIGURATION_TYPES:STRING=Debug;Release\n" + "STAN_EFFECTIVE_MPI:INTERNAL=ON\nMPIEXEC_PREFLAGS:STRING=--oversubscribe\n") + command = runner.test_command("test/unit/math/prim/fun", Path("build"), "Debug", cache, True) + self.assertEqual(command[:4], ["mpirun", "-np", "2", "--oversubscribe"]) + self.assertEqual(Path(command[4]), Path("build/test/unit/Debug/unit_math_prim_fun_all_tests.exe")) + + def test_cli_reads_explicit_arguments(self): + self.assertEqual(inputs().j, 1) + self.assertEqual(inputs("-j20").j, 20) + parsed = runner.processCLIArgs(["build-tests", "build", "--source-regex", "^test/unit/", "--jobs", "4"]) + self.assertEqual((parsed.command, parsed.jobs), ("build-tests", 4)) + self.assertEqual(inputs("--test-jobs", "6").test_jobs, 6) + for flags in (("-j0",), ("--test-jobs", "0"), ("--fresh", "--test-only"), + ("--make-only", "--test-only"), ("--configure-only", "--test-only")): + with self.subTest(flags=flags), patch.object(sys, "stderr"): + with self.assertRaises(SystemExit) as error: + inputs(*flags) + self.assertEqual(error.exception.code, 2) + + def test_parallel_test_runs_print_in_order_and_report_failures(self): + codes = {"unit_math_prim_fun_all_tests": 1, "unit_math_rev_fun_all_tests": 0} + + def run(command, **kwargs): + name = Path(command[0]).name + return subprocess.CompletedProcess(command, codes[name], stdout=name + " output\n") + + output = io.StringIO() + with tempfile.TemporaryDirectory() as working_dir, \ + patch.object(runner.subprocess, "run", run), patch.object(sys, "stdout", output): + code = runner.main(["--test-only", "--test-jobs", "2", "test/unit/math/prim/fun", + "test/unit/math/rev/fun"], ROOT, Path(working_dir), False, io.StringIO()) + self.assertEqual(code, 1) + printed = output.getvalue() + self.assertLess(printed.index("unit_math_prim_fun_all_tests output"), + printed.index("unit_math_rev_fun_all_tests output")) + + def test_trailing_slash_selects_distribution_directories(self): + commands = [] + + def run(command, **kwargs): + commands.append(command) + return subprocess.CompletedProcess(command, 0, stdout="") + + with tempfile.TemporaryDirectory() as working_dir, \ + patch.object(runner.subprocess, "run", run), patch.object(sys, "stdout", io.StringIO()): + code = runner.main(["--test-only", "--test-jobs", "2", "test/prob/*/"], + ROOT, Path(working_dir), False, io.StringIO()) + self.assertEqual(code, 0) + names = {Path(command[0]).name for command in commands} + self.assertIn("prob_math_bernoulli_all_tests", names) + self.assertEqual(len(names), len([path for path in (ROOT / "test/prob").iterdir() if path.is_dir()])) + + def test_selection_removes_overlap_and_rejects_invalid_paths(self): + directory = "test/unit/math/prim/fun" + matches = {directory: [directory], "sum": [directory + "/sum_test.cpp"]} + self.assertEqual(runner.select_paths(matches, {directory}), [directory]) + self.assertEqual(runner.target_for(directory + "/sum_test.cpp")[0], + "test_unit_math_prim_fun_sum_test") + with self.assertRaises(ValueError): + runner.select_paths({"stan/math": ["stan/math"]}, {"stan/math"}) + with self.assertRaises(ValueError): + runner.select_paths({"missing": []}, set()) + + def test_changed_headers_select_existing_dependent_tests(self): + existing = {"test/unit/math/" + layer + "/fun/sum_test.cpp" for layer in ("prim", "rev", "fwd", "mix")} + selected = runner.findChangedTests(["stan/math/prim/fun/sum.hpp"], existing) + self.assertEqual(set(selected), existing) + self.assertEqual(runner.findChangedTests(["stan/math/prim/fun/sum.hpp"], existing, limit=1), []) + self.assertRegex(selected[0], runner.source_filter(selected)) + + def test_ci_build_reuses_registered_cmake_labels(self): + inventory = {"tests": [{"name": "sum", "properties": [{"name": "LABELS", "value": + ["prim", "test/unit/math/prim/fun/sum_test.cpp"]}]}, {"name": "grad"}]} + self.assertEqual(runner.build_tests(inventory, "^test/unit/math/prim/", "build", 4, 500), + [["cmake", "--build", "build", "--parallel", "4", "--target", "sum"]]) + with self.assertRaisesRegex(ValueError, "No registered tests match"): + runner.build_tests({"tests": []}, "^test/unit/", "build", 1, 500) + + def test_files_and_fixtures_build_through_one_selected_target(self): + fixture = "test/prob/bernoulli/bernoulli_test.hpp" + source = "test/unit/math/prim/fun/sum_test.cpp" + configure, build = runner.build_commands(inputs(), ["test/unit/math/rev", source, fixture], + Path("source"), Path("build")) + self.assertIn("-DSTAN_BUILD_PROB_TESTS=ON", configure) + self.assertIn("-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", configure) + self.assertEqual(build[build.index("--target") + 1:], + ["unit_math_rev_all_tests", "stan_selected_tests"]) + regex = next(argument for argument in configure + if argument.startswith("-DSTAN_SELECTED_TEST_REGEX="))[len("-DSTAN_SELECTED_TEST_REGEX="):] + self.assertRegex(source, regex) + self.assertIsNone(re.search(regex, "test/unit/math/prim/fun/cumulative_sum_test.cpp")) + for mode in ("v", "ffv", "vv", "fd", "fv", "ffd"): + self.assertRegex(f"test/prob/bernoulli/bernoulli_00012_generated_{mode}_test.cpp", regex) + self.assertIsNone(re.search(regex, "test/prob/bernoulli/bernoulli_cdf_00000_generated_v_test.cpp")) + directory_only = runner.build_commands(inputs(), ["test/unit/math/rev"], Path("source"), Path("build")) + self.assertIn("-DSTAN_SELECTED_TEST_REGEX=", directory_only[0]) + self.assertNotIn("stan_selected_tests", directory_only[1]) + only_configure = runner.build_commands(inputs("--configure-only"), [], Path("source"), Path("build")) + self.assertEqual(len(only_configure), 1) + self.assertEqual(only_configure[0][:2], ["cmake", "-S"]) + + def test_fixtures_run_their_generated_tests(self): + labels = ["test/prob/bernoulli/bernoulli_00000_generated_v_test.cpp", + "test/prob/bernoulli/bernoulli_00001_generated_fd_test.cpp", + "test/prob/bernoulli/bernoulli_cdf_00000_generated_v_test.cpp"] + inventory = {"tests": [{"name": label, "properties": [{"name": "LABELS", "value": + ["prob_math", "bernoulli", label]}]} for label in labels]} + self.assertEqual(runner.fixture_tests(inventory, ["test/prob/bernoulli/bernoulli_test.hpp"]), + labels[:2]) + commands = [] + + def run(command, **kwargs): + commands.append(command) + if command[0] == "ctest": + return subprocess.CompletedProcess(command, 0, stdout=json.dumps(inventory)) + return subprocess.CompletedProcess(command, 0, stdout="") + + with tempfile.TemporaryDirectory() as working_dir, \ + patch.object(runner.subprocess, "run", run), patch.object(sys, "stdout", io.StringIO()): + code = runner.main(["--test-only", "--test-jobs", "2", "test/prob/bernoulli/bernoulli_test.hpp"], + ROOT, Path(working_dir), False, io.StringIO()) + self.assertEqual(code, 0) + self.assertEqual({Path(command[0]).name for command in commands[1:]}, + {"test_prob_bernoulli_bernoulli_00000_generated_v_test", + "test_prob_bernoulli_bernoulli_00001_generated_fd_test"}) + + def test_distribution_dependencies_use_the_build_compile_command(self): + compile_commands = [{"file": "/src/lib/gtest_main.cc", "command": + "clang++ -DSTAN -I/src -O0 -MD -MT x.o -MF x.d -o x.o -c /src/lib/gtest_main.cc"}] + command = get_dependencies.dependency_command(compile_commands) + self.assertEqual(command, ["clang++", "-DSTAN", "-I/src", "-O0", "-MM", "-MG", "-x", "c++"]) + output = ("normal_test.o: test/prob/normal/normal_test.hpp \\\n" + " /src/stan/math/prim/prob/normal_lpdf.hpp /usr/include/eigen.h \\\n" + " /src/stan/math/prim/fun/constants.hpp\n") + self.assertEqual(get_dependencies.parse_dependencies(output, Path("/src")), + {"stan/math/prim/prob/normal_lpdf.hpp", "stan/math/prim/fun/constants.hpp"}) + + def test_ci_build_aggregates_only_complete_selected_groups(self): + inventory = {"tests": [{"name": name, "properties": [{"name": "LABELS", "value": + ["test/unit/" + name + ".cpp", "stan_build:all"] + + (["stan_build:pair"] if name != "three" else [])}]} for name in ("one", "two", "three")]} + command = ["cmake", "--build", "build", "--parallel", "4", "--target"] + self.assertEqual(runner.build_tests(inventory, "^test/unit/", "build", 4, 500), [command + ["all"]]) + self.assertEqual(runner.build_tests(inventory, "^test/unit/(one|two)", "build", 4, 500), [command + ["pair"]]) + self.assertEqual(runner.build_tests(inventory, "one", "build", 4, 500), [command + ["one"]]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/expressions/test_expression_testing_framework.py b/test/expressions/test_expression_testing_framework.py index 487ee8b9bfa..05370e703b7 100644 --- a/test/expressions/test_expression_testing_framework.py +++ b/test/expressions/test_expression_testing_framework.py @@ -16,6 +16,8 @@ class TestExpressionTestingFramework(unittest.TestCase): def runCommand(self, command): + command = command + ("--build-dir", "build", "--fresh", + "--cmake-arg=-DSTAN_TEST_OPTIMIZATION=0") p1 = subprocess.Popen(command, stdout = subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) out, err = p1.communicate() return p1.returncode, out, err diff --git a/test/prob/CMakeLists.txt b/test/prob/CMakeLists.txt new file mode 100644 index 00000000000..9ec3b74901c --- /dev/null +++ b/test/prob/CMakeLists.txt @@ -0,0 +1,88 @@ +# Use the existing Make test generator without importing the branch's C++ +# generator/fixture changes or its large generated typedef headers. +set(STAN_PROB_TEST_BATCH_SIZE 100 CACHE STRING "Distribution test cases per generated source") +if(NOT STAN_PROB_TEST_BATCH_SIZE MATCHES "^[1-9][0-9]*$") + message(FATAL_ERROR "STAN_PROB_TEST_BATCH_SIZE must be a positive integer") +endif() +add_executable(generate_tests generate_tests.cpp) +foreach(feature STAN_TEST_ROW_VECTORS STAN_PROB_TEST_ALL) + if(${feature}) + target_compile_definitions(generate_tests PRIVATE ${feature}) + endif() +endforeach() +if(STAN_USE_SYSTEM_BOOST) + target_link_libraries(generate_tests PRIVATE Boost::boost) +else() + target_include_directories(generate_tests SYSTEM PRIVATE "${STAN_BOOST_ROOT}") +endif() +# The fixtures stay out of the PCH: their TYPED_TEST_P and +# REGISTER_TYPED_TEST_SUITE_P statics must initialize in definition order, and +# Clang 7 reorders static initializers that come from a PCH. The PCH holds +# their dependencies instead. +stan_add_pch(prob_pch stan_test_options + ) + +# These are the argument combinations used by generate_tests.cpp with its +# selected row-vector setting. Non-reverse modes omit all-constant combinations. +set(vector_types 3) +if(STAN_TEST_ROW_VECTORS) + set(vector_types 4) +endif() +set(modes v ffv vv) +if(STAN_PROB_TEST_ALL) + list(APPEND modes fd fv ffd) +endif() +file(GLOB_RECURSE fixtures CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/*_test.hpp") +set(generated_sources "") +add_custom_target(run_generate_tests) +foreach(fixture IN LISTS fixtures) + file(RELATIVE_PATH relative_fixture "${PROJECT_SOURCE_DIR}" "${fixture}") + string(REGEX REPLACE "_test\\.hpp$" "" base "${relative_fixture}") + configure_file("${fixture}" "${PROJECT_BINARY_DIR}/${relative_fixture}" COPYONLY) + file(STRINGS "${fixture}" arguments REGEX "^// Arguments:") + string(REGEX REPLACE "^// Arguments: *" "" arguments "${arguments}") + string(REGEX REPLACE ", *" ";" arguments "${arguments}") + set(combinations 1) + set(constants 1) + foreach(argument IN LISTS arguments) + if(argument STREQUAL "Ints") + math(EXPR combinations "${combinations} * ${vector_types}") + math(EXPR constants "${constants} * ${vector_types}") + elseif(argument STREQUAL "Doubles") + math(EXPR combinations "${combinations} * 2 * ${vector_types}") + math(EXPR constants "${constants} * ${vector_types}") + else() + message(FATAL_ERROR "Unsupported distribution argument '${argument}' in ${fixture}") + endif() + endforeach() + set(outputs "") + foreach(mode IN LISTS modes) + set(count ${combinations}) + if(NOT mode STREQUAL "v") + math(EXPR count "${count} - ${constants}") + endif() + # The existing generator deliberately emits one extra (possibly empty) + # batch when the case count is a multiple of the batch size. + math(EXPR last_batch "${count} / ${STAN_PROB_TEST_BATCH_SIZE}") + foreach(batch RANGE 0 ${last_batch}) + math(EXPR padded "100000 + ${batch}") + string(SUBSTRING "${padded}" 1 5 padded) + list(APPEND outputs "${PROJECT_BINARY_DIR}/${base}_${padded}_generated_${mode}_test.cpp") + endforeach() + endforeach() + add_custom_command(OUTPUT ${outputs} + COMMAND generate_tests "${relative_fixture}" ${STAN_PROB_TEST_BATCH_SIZE} + DEPENDS generate_tests "${fixture}" "${PROJECT_BINARY_DIR}/${relative_fixture}" + WORKING_DIRECTORY "${PROJECT_BINARY_DIR}" + COMMENT "Generating distribution tests for ${relative_fixture}" + VERBATIM) + string(REPLACE "/" "_" generation_target "generate_${base}") + add_custom_target(${generation_target} DEPENDS ${outputs}) + add_dependencies(run_generate_tests ${generation_target}) + set_source_files_properties(${outputs} PROPERTIES STAN_GENERATOR_TARGET ${generation_target}) + list(APPEND generated_sources ${outputs}) +endforeach() +if(generated_sources) + add_gtest_grouped_test(PREFIX prob_math REPO_PATH test/prob + DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" PCH prob_pch SOURCES ${generated_sources}) +endif() diff --git a/test/prob/getDependencies.py b/test/prob/getDependencies.py index 88ff363c0bd..5272567252e 100755 --- a/test/prob/getDependencies.py +++ b/test/prob/getDependencies.py @@ -1,76 +1,108 @@ #!/usr/bin/env python3 """ This script determines which distributions may have changed -based on the files #included by their test. It then prints a set of strings -which will run said tests when fed to the top-level runTests.py script +based on the files #included by their test. It then prints the distribution +fixtures (test/prob//_test.hpp) whose tests should run; feed +them to the top-level runTests.py script. + +The dependencies come from the compiler with the flags of a configured CMake +build directory (runTests.py --configure-only), so they match the test build. """ -from typing import Set -import sys -import subprocess +from concurrent.futures import ThreadPoolExecutor +import argparse +import json +import os from pathlib import Path -import glob +import shlex +import subprocess +import sys +from typing import List, Set -def get_dependencies(file: Path) -> Set[str]: - file_dot_d = file.with_suffix(".d") - subprocess.run(["make", str(file_dot_d)], stdout=subprocess.DEVNULL) - contents = file_dot_d.read_text() - file_dot_d.unlink() +ROOT = Path(__file__).resolve().parents[2] - deps = set( - filter( - lambda s: "stan/math" in s and s.endswith(".hpp"), - map(str.strip, contents.split(" ")), - ) - ) - return deps | {str(file)} +def dependency_command(compile_commands: List[dict]) -> List[str]: + """Turn the gtest_main compile command into a dependency listing command.""" + for entry in compile_commands: + if entry["file"].endswith("gtest_main.cc"): + arguments = entry.get("arguments") or shlex.split(entry["command"]) + break + else: + raise ValueError("No gtest_main.cc compile command; configure with tests enabled") + command, skip = [], False + for argument in arguments: + if skip: + skip = False + elif argument in ("-o", "-MT", "-MF", "-MQ"): + skip = True + elif argument in ("-c", "-MD", "-MMD") or argument.endswith("gtest_main.cc"): + continue + else: + command.append(argument) + return command + ["-MM", "-MG", "-x", "c++"] -def get_changed() -> Set[str]: - changed_files = subprocess.run( - ["git", "diff", "--name-only", "origin/develop...HEAD"], - text=True, - capture_output=True, - ).stdout.splitlines() - return set(changed_files) +def parse_dependencies(output: str, root: Path) -> Set[str]: + """Keep the stan/math headers from make-style dependency output.""" + words = output.replace("\\\n", " ").split(":", 1)[-1].split() + deps = set() + for word in words: + path = Path(word) if Path(word).is_absolute() else root / word + try: + relative = Path(os.path.normpath(path)).relative_to(root).as_posix() + except ValueError: + continue + if "stan/math" in relative and relative.endswith(".hpp"): + deps.add(relative) + return deps -def add_tests_from_hpp(tests_to_run, test): - pattern = str(test).replace("_test.hpp", "_0*_test.cpp") - tests = glob.glob(pattern) - tests_to_run.extend(tests) +def get_dependencies(command: List[str], fixture: str, root: Path) -> Set[str]: + result = subprocess.run(command + [fixture], cwd=root, text=True, capture_output=True) + if result.returncode != 0: + raise RuntimeError(f"Listing dependencies of {fixture} failed:\n{result.stderr}") + return parse_dependencies(result.stdout, root) | {fixture} -if __name__ == "__main__": - if Path("makefile") not in Path(".").iterdir(): - raise ValueError("getDependencies must be ran from the top-level repository") - pretend = "--pretend-all" in sys.argv +def get_changed(root: Path) -> Set[str]: + return set(subprocess.run(["git", "diff", "--name-only", "origin/develop...HEAD"], + cwd=root, text=True, capture_output=True, + check=True).stdout.splitlines()) - changed = get_changed() - tests_to_run = [] - distribution_tests = Path("test", "prob") +def main(arguments, root: Path) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pretend-all", action="store_true", + help="select every distribution fixture") + parser.add_argument("--build-dir", default="build", + help="configured CMake build directory (default: build)") + inputs = parser.parse_args(arguments) - subprocess.run(["make", "generate-tests"], stdout=subprocess.DEVNULL) + fixtures = sorted(path.relative_to(root).as_posix() + for path in (root / "test" / "prob").glob("*/*_test.hpp")) + if inputs.pretend_all: + selected = fixtures + else: + compile_commands = Path(inputs.build_dir) / "compile_commands.json" + if not compile_commands.is_absolute(): + compile_commands = Path.cwd() / compile_commands + command = dependency_command(json.loads(compile_commands.read_text())) + changed = get_changed(root) + with ThreadPoolExecutor(os.cpu_count()) as pool: + all_deps = list(pool.map(lambda fixture: get_dependencies(command, fixture, root), + fixtures)) + selected = [] + for fixture, deps in zip(fixtures, all_deps): + intersection = changed & deps + if intersection: + print(f"{fixture} has {len(intersection)} changed dependencies out of " + f"{len(deps)} files #included.", file=sys.stderr) + selected.append(fixture) + if selected: + print("\n".join(selected)) + return 0 - for dist in distribution_tests.iterdir(): - if not dist.is_dir(): - continue - print(f"Considering {dist}.", file=sys.stderr) - for test in dist.iterdir(): - if test.suffix != ".hpp": continue - if pretend: - add_tests_from_hpp(tests_to_run, test) - else: - deps = get_dependencies(test) - intersection = changed & deps - if intersection: - print( - f"{test} has {len(intersection)} changed dependencies out of {len(deps)} files #included.", - file=sys.stderr, - ) - add_tests_from_hpp(tests_to_run, test) - - if tests_to_run: - print("\n".join(set(tests_to_run))) + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:], ROOT)) diff --git a/test/sig_utils.py b/test/sig_utils.py index c19073598ca..f109e1c6d98 100644 --- a/test/sig_utils.py +++ b/test/sig_utils.py @@ -1,6 +1,7 @@ import itertools import numbers import os +from pathlib import Path import re import subprocess import sys @@ -275,25 +276,20 @@ def get_signatures(): Retrieves function signatures from stanc3 :return: list of signatures """ - if os.name == "nt": - stanc3 = ".\\test\\expressions\\stanc.exe" - else: - stanc3 = "./test/expressions/stanc" - p = subprocess.Popen((make, stanc3.replace("\\", "/"))) + root = Path(__file__).resolve().parent.parent + stanc3 = root / "test/expressions" / ("stanc.exe" if os.name == "nt" else "stanc") + p = subprocess.Popen(("cmake", "-DSTAN_STANC_FILE=" + str(stanc3), + "-P", str(root / "cmake/stanc.cmake"))) if p.wait() != 0: sys.stderr.write("Error in making stanc3!") sys.exit(-1) - p = subprocess.Popen( - (stanc3 + " --dump-stan-math-signatures"), - stdout=subprocess.PIPE, - universal_newlines=True, - shell=True, - ) - res = parse_signature_file(p.stdout) - if p.wait() != 0: - sys.stderr.write("Error in getting signatures from stanc3!\n") - sys.exit(-1) + with subprocess.Popen([str(stanc3), "--dump-stan-math-signatures"], + stdout=subprocess.PIPE, universal_newlines=True) as p: + res = parse_signature_file(p.stdout) + if p.wait() != 0: + sys.stderr.write("Error in getting signatures from stanc3!\n") + sys.exit(-1) return res + internal_signatures diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt new file mode 100644 index 00000000000..960a1977872 --- /dev/null +++ b/test/unit/CMakeLists.txt @@ -0,0 +1,36 @@ +# Keep each autodiff layer's PCH small. The AD helper header includes mix.hpp +# and test_ad_matvar.hpp, so those need no separate entries in its PCH. +foreach(layer prim fwd rev mix) + stan_add_pch(unit_${layer}_pch stan_test_options ) +endforeach() +stan_add_pch(unit_ad_pch stan_test_options ) +add_gtest_grouped_test(PREFIX unit_math REPO_PATH test/unit/math + DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/math" PCH unit_mix_pch + LAYER_PCH_PREFIX unit AD_PCH unit_ad_pch) +# The timeout covers every parameterized case in the executable. Some +# Laplace suites exceed CTest's default 1500 seconds even with -O3. +get_property(laplace_tests DIRECTORY PROPERTY TESTS) +list(FILTER laplace_tests INCLUDE REGEX "^test_unit_math_laplace_") +if(laplace_tests) + set_tests_properties(${laplace_tests} PROPERTIES TIMEOUT 3600) +endif() + +# Umbrella and multiple-translation-unit checks must compile without a PCH. +# The public runner also accepts test/unit, so group them with test/unit/math. +set(unit_tests ${unit_math_selected_tests}) +foreach(source math_c11_test.cpp math_include_test.cpp multiple_translation_units_test.cpp) + get_filename_component(name "${source}" NAME_WE) + stan_add_gtest(test_unit_${name} "${source}" "test/unit/${source}" "" unit) + stan_matches_name_filters("test/unit/${source}" selected) + if(selected) + list(APPEND unit_tests test_unit_${name}) + endif() +endforeach() +add_library(stan_multiple_translation_units SHARED + multiple_translation_units1.cpp multiple_translation_units2.cpp) +target_link_libraries(stan_multiple_translation_units PRIVATE stanmath) +target_link_libraries(test_unit_multiple_translation_units_test PRIVATE stan_multiple_translation_units) +if(unit_tests) + stan_add_test_group(unit ${unit_tests}) + target_link_libraries(unit_all_tests PRIVATE stan_multiple_translation_units) +endif() diff --git a/test/varmat_compatibility.py b/test/varmat_compatibility.py index cfd4100430e..62d693db31b 100755 --- a/test/varmat_compatibility.py +++ b/test/varmat_compatibility.py @@ -4,6 +4,8 @@ import json from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser import os +from pathlib import Path +import shutil import queue as Queue import subprocess import re @@ -11,7 +13,7 @@ import tempfile import threading -from sig_utils import make, handle_function_list, get_signatures +from sig_utils import handle_function_list, get_signatures from signature_parser import SignatureParser from code_generator import CodeGenerator @@ -19,10 +21,39 @@ TEST_FOLDER = os.path.abspath(os.path.join(HERE, "..", "test")) sys.path.append(TEST_FOLDER) WORKING_FOLDER = "test/varmat-compatibility" - +_compile_worker = threading.local() + + +def compile_signature(cpp_path): + if not hasattr(_compile_worker, "directory"): + _compile_worker.directory = tempfile.TemporaryDirectory(prefix="cmake_", dir=WORKING_FOLDER) + directory = Path(_compile_worker.directory.name).resolve() + _compile_worker.source = directory / "check.cpp" + _compile_worker.build = directory / "build" + _compile_worker.source.write_text("\n") + successful, stdout, stderr = run_command([ + "cmake", "-S", str(Path(HERE).parent), "-B", str(_compile_worker.build), + "-DBUILD_TESTING=OFF", "-DSTAN_TEST_OPTIMIZATION=0", + "-DSTAN_COMPILE_TEST_SOURCE=" + str(_compile_worker.source)]) + if successful: + successful, stdout, stderr = run_command([ + "cmake", "--build", str(_compile_worker.build), + "--parallel", "1", "--target", "stan_compile_test"]) + if not successful: + _compile_worker.directory.cleanup() + del _compile_worker.directory + raise RuntimeError("CMake compilation setup failed:\n" + + stdout.decode("utf-8") + stderr.decode("utf-8")) + shutil.copyfile(cpp_path, _compile_worker.source) + return run_command(["cmake", "--build", str(_compile_worker.build), + "--parallel", "1", "--target", "stan_compile_test"]) + +# Returning the result and external linkage keep -Wall -Werror builds free of +# unused variable and unused function warnings. TEST_TEMPLATE = """ -static void {test_name}() {{ +auto {test_name}() {{ {code} +return {result}; }} """ @@ -61,7 +92,7 @@ def build_signature(prefix, cpp_code, debug): stdout_path = cpp_path.replace(".cpp", ".stdout") stderr_path = cpp_path.replace(".cpp", ".stderr") - successful, stdout, stderr = run_command([make, object_path]) + successful, stdout, stderr = compile_signature(cpp_path) if successful or not debug: try: @@ -156,11 +187,12 @@ def main(functions_or_sigs, results_file, cores, debug): arg_list.append(arg) - cg.function_call_assign("stan::math::" + sp.function_name, *arg_list) + result = cg.function_call_assign("stan::math::" + sp.function_name, *arg_list) cpp_code += TEST_TEMPLATE.format( test_name = sp.function_name + repr(m), code=cg.cpp(), + result=result.name, ) if any_overload_uses_varmat: @@ -175,6 +207,7 @@ def main(functions_or_sigs, results_file, cores, debug): os.mkdir(WORKING_FOLDER) work_queue_original_length = work_queue.qsize() + compilation_errors = [] # Test if each cpp file builds and update the output file # This part is done in parallel @@ -189,7 +222,13 @@ def worker(): prefix = re.sub('[^0-9a-zA-Z]+', '_', signature.strip()) # Test the signature - successful = build_signature(prefix, cpp_code, debug) + try: + successful = build_signature(prefix, cpp_code, debug) + except Exception as error: + with output_lock: + compilation_errors.append(error) + work_queue.task_done() + continue # Acquire a lock to do I/O with output_lock: @@ -208,6 +247,8 @@ def worker(): threading.Thread(target = worker).start() work_queue.join() + if compilation_errors: + raise RuntimeError("CMake compilation setup failed") from compilation_errors[0] with open(results_file, "w") as f: json.dump({ "compatible_signatures" : list(compatible_signatures), diff --git a/test/varmat_compatibility_test.py b/test/varmat_compatibility_test.py index e14c46ee48a..a161d3aacdd 100644 --- a/test/varmat_compatibility_test.py +++ b/test/varmat_compatibility_test.py @@ -3,9 +3,30 @@ import tempfile import unittest import os +import threading +from unittest.mock import patch class VarmatCompatibilityMainTest(unittest.TestCase): + def test_initial_build_failure_propagates_compiler_output(self): + with tempfile.TemporaryDirectory() as directory, patch.object( + varmat_compatibility, "WORKING_FOLDER", directory), patch.object( + varmat_compatibility, "_compile_worker", threading.local()), patch.object( + varmat_compatibility, "run_command", side_effect=[ + (True, b"configured", b""), + (False, b"PCH build failed", b"compiler diagnostic")]) as command: + with self.assertRaisesRegex(RuntimeError, "PCH build failedcompiler diagnostic"): + varmat_compatibility.compile_signature("unused.cpp") + self.assertEqual(command.call_count, 2) + self.assertEqual(command.call_args.args[0][-1], "stan_compile_test") + + def test_configuration_failure_propagates(self): + with tempfile.TemporaryDirectory() as directory, patch.object( + varmat_compatibility, "get_signatures", return_value=["sum(matrix) => real\n"]), patch.object( + varmat_compatibility, "build_signature", side_effect=RuntimeError("configuration failed")): + with self.assertRaisesRegex(RuntimeError, "CMake compilation setup failed"): + varmat_compatibility.main(["sum"], os.path.join(directory, "results.json"), 1, False) + def test_sum_works(self): f = tempfile.NamedTemporaryFile("w", suffix="_test.json", delete=False) f.close()