diff --git a/CMakeLists.txt b/CMakeLists.txt index 79e59cec..3e68802d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,7 +12,6 @@ if(MSVC) endif() option(TEST_COVERAGE "C++ test coverage" OFF) -option(USE_OPENMP "Use OpenMP" ON) option(BUILD_CPP_TEST "Build C++ tests" OFF) option(BUILD_STATIC_LIBS "Build static libs, in addition to dynamic libs" OFF) option(DETECT_CONDA_ENV "Enable detection of conda environment for dependencies" ON) diff --git a/cmake/TreeliteConfig.cmake.in b/cmake/TreeliteConfig.cmake.in index bc5de108..18a875c4 100644 --- a/cmake/TreeliteConfig.cmake.in +++ b/cmake/TreeliteConfig.cmake.in @@ -2,10 +2,8 @@ include(CMakeFindDependencyMacro) -set(USE_OPENMP @USE_OPENMP@) -if(USE_OPENMP) - find_dependency(OpenMP) -endif() +find_dependency(Threads) + if(NOT TARGET treelite::treelite) include(${CMAKE_CURRENT_LIST_DIR}/TreeliteTargets.cmake) endif() diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index cee954ff..7fb97183 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -11,16 +11,7 @@ endif(UNIX) add_library(objtreelite_common OBJECT) # Component shared by both main package and runtime -if(USE_OPENMP) - if (APPLE) - # Require CMake 3.16+ on Mac OSX, as previous versions of CMake had trouble locating - # OpenMP on Mac. See https://github.com/dmlc/xgboost/pull/5146#issuecomment-568312706 - cmake_minimum_required(VERSION 3.16) - endif (APPLE) - find_package(OpenMP REQUIRED) -else() - message(STATUS "Disabling OpenMP") -endif() +find_package(Threads REQUIRED) if(ENABLE_ALL_WARNINGS) foreach(target objtreelite objtreelite_runtime objtreelite_runtime) @@ -33,6 +24,7 @@ foreach(lib objtreelite objtreelite_runtime objtreelite_common) $ $ $/include>) + target_link_libraries(${lib} PUBLIC Threads::Threads) if(MSVC) target_compile_options(${lib} PRIVATE /MP) target_compile_definitions(${lib} PRIVATE -DNOMINMAX) @@ -40,10 +32,6 @@ foreach(lib objtreelite objtreelite_runtime objtreelite_common) else() target_compile_options(${lib} PRIVATE -funroll-loops) endif() - if(USE_OPENMP) - target_link_libraries(${lib} PUBLIC OpenMP::OpenMP_CXX) - target_compile_definitions(${lib} PRIVATE -DTREELITE_OPENMP_SUPPORT) - endif() if(TEST_COVERAGE) if(MSVC) message(FATAL_ERROR "Test coverage not available on Windows") @@ -98,6 +86,7 @@ target_sources(objtreelite gtil/predict.cc gtil/pred_transform.h gtil/pred_transform.cc + threading_utils/parallel_for.h annotator.cc filesystem.cc optable.cc @@ -112,7 +101,6 @@ target_sources(objtreelite ${PROJECT_SOURCE_DIR}/include/treelite/frontend.h ${PROJECT_SOURCE_DIR}/include/treelite/frontend_impl.h ${PROJECT_SOURCE_DIR}/include/treelite/gtil.h - ${PROJECT_SOURCE_DIR}/include/treelite/omp.h ${PROJECT_SOURCE_DIR}/include/treelite/optional.h ${PROJECT_SOURCE_DIR}/include/treelite/thread_local.h ${PROJECT_SOURCE_DIR}/include/treelite/tree.h diff --git a/src/annotator.cc b/src/annotator.cc index 905f7a12..513c0c0f 100644 --- a/src/annotator.cc +++ b/src/annotator.cc @@ -8,13 +8,14 @@ #include #include #include -#include #include #include #include #include #include +#include #include +#include "threading_utils/parallel_for.h" namespace { @@ -73,18 +74,14 @@ inline void ComputeBranchLoopImpl( std::vector> inst(nthread * dmat->num_col, {-1}); const size_t ntree = model.trees.size(); TREELITE_CHECK_LE(rbegin, rend); - TREELITE_CHECK_LT(static_cast(rend), std::numeric_limits::max()); const size_t num_col = dmat->num_col; const ElementType missing_value = dmat->missing_value; const bool nan_missing = treelite::math::CheckNAN(missing_value); - const auto rbegin_i = static_cast(rbegin); - const auto rend_i = static_cast(rend); - #pragma omp parallel for schedule(static) num_threads(nthread) - for (int64_t rid = rbegin_i; rid < rend_i; ++rid) { - const int tid = omp_get_thread_num(); + treelite::threading_utils::ParallelFor(rbegin, rend, nthread, + [&](std::size_t rid, std::size_t thread_id) { const ElementType* row = &dmat->data[rid * num_col]; - const size_t off = dmat->num_col * tid; - const size_t off2 = count_row_ptr[ntree] * tid; + const size_t off = dmat->num_col * thread_id; + const size_t off2 = count_row_ptr[ntree] * thread_id; for (size_t j = 0; j < num_col; ++j) { if (treelite::math::CheckNAN(row[j])) { TREELITE_CHECK(nan_missing) @@ -99,7 +96,7 @@ inline void ComputeBranchLoopImpl( for (size_t j = 0; j < num_col; ++j) { inst[off + j].missing = -1; } - } + }); } template @@ -110,14 +107,10 @@ inline void ComputeBranchLoopImpl( std::vector> inst(nthread * dmat->num_col, {-1}); const size_t ntree = model.trees.size(); TREELITE_CHECK_LE(rbegin, rend); - TREELITE_CHECK_LT(static_cast(rend), std::numeric_limits::max()); - const auto rbegin_i = static_cast(rbegin); - const auto rend_i = static_cast(rend); - #pragma omp parallel for schedule(static) num_threads(nthread) - for (int64_t rid = rbegin_i; rid < rend_i; ++rid) { - const int tid = omp_get_thread_num(); - const size_t off = dmat->num_col * tid; - const size_t off2 = count_row_ptr[ntree] * tid; + treelite::threading_utils::ParallelFor(rbegin, rend, nthread, + [&](std::size_t rid, std::size_t thread_id) { + const size_t off = dmat->num_col * thread_id; + const size_t off2 = count_row_ptr[ntree] * thread_id; const size_t ibegin = dmat->row_ptr[rid]; const size_t iend = dmat->row_ptr[rid + 1]; for (size_t i = ibegin; i < iend; ++i) { @@ -129,7 +122,7 @@ inline void ComputeBranchLoopImpl( for (size_t i = ibegin; i < iend; ++i) { inst[off + dmat->col_ind[i]].missing = -1; } - } + }); } template @@ -199,7 +192,7 @@ AnnotateImpl( count_row_ptr = {0}; const size_t ntree = model.trees.size(); - const int max_thread = omp_get_max_threads(); + const int max_thread = static_cast(std::thread::hardware_concurrency()); nthread = (nthread == 0) ? max_thread : std::min(nthread, max_thread); for (const treelite::Tree& tree : model.trees) { count_row_ptr.push_back(count_row_ptr.back() + tree.num_nodes); diff --git a/src/threading_utils/parallel_for.h b/src/threading_utils/parallel_for.h new file mode 100644 index 00000000..c966b779 --- /dev/null +++ b/src/threading_utils/parallel_for.h @@ -0,0 +1,84 @@ +/*! +* Copyright (c) 2021 by Contributors +* \file parallel_for.h +* \brief Implemenation of parallel for loop +* \author Hyunsu Cho +*/ +#ifndef TREELITE_THREADING_UTILS_PARALLEL_FOR_H_ +#define TREELITE_THREADING_UTILS_PARALLEL_FOR_H_ + +#include +#include +#include +#include +#include +#include + +namespace treelite { +namespace threading_utils { + +template +std::vector ComputeWorkRange(IndexType begin, IndexType end, std::size_t nthread); + +template +void ParallelFor(IndexType begin, IndexType end, std::size_t nthread, FuncType func) { + TREELITE_CHECK_GT(nthread, 0) << "nthread must be positive"; + TREELITE_CHECK_LE(nthread, std::thread::hardware_concurrency()) + << "nthread cannot exceed " << std::thread::hardware_concurrency(); + if (begin == end) { + return; + } + /* Divide the range [begin, end) equally among the threads. + * The i-th thread gets the range [work_range[i], work_range[i+1]). */ + std::vector work_range = ComputeWorkRange(begin, end, nthread); + + // Launch (nthread - 1) threads, as the main thread should also perform work. + std::vector> async_tasks; + for (std::size_t thread_id = 1; thread_id < nthread; ++thread_id) { + async_tasks.push_back(std::async(std::launch::async, [&work_range, &func, thread_id]() { + const IndexType begin_ = work_range[thread_id]; + const IndexType end_ = work_range[thread_id + 1]; + for (IndexType i = begin_; i < end_; ++i) { + func(i, thread_id); + } + })); + } + { + const IndexType begin_ = work_range[0]; + const IndexType end_ = work_range[1]; + for (IndexType i = begin_; i < end_; ++i) { + func(i, 0); + } + } + // Join threads + for (auto& task : async_tasks) { + task.get(); + } +} + +template +std::vector ComputeWorkRange(IndexType begin, IndexType end, std::size_t nthread) { + TREELITE_CHECK_GE(end, 0) << "end must be 0 or greater"; + TREELITE_CHECK_GE(begin, 0) << "begin must be 0 or greater"; + TREELITE_CHECK_GE(end, begin) << "end cannot be less than begin"; + TREELITE_CHECK_GT(nthread, 0) << "nthread must be positive"; + IndexType num_elem = end - begin; + const IndexType portion = num_elem / nthread + !!(num_elem % nthread); + // integer division, rounded-up + + std::vector work_range(nthread + 1); + work_range[0] = begin; + IndexType acc = begin; + for (std::size_t i = 0; i < nthread; ++i) { + acc += portion; + work_range[i + 1] = std::min(acc, end); + } + TREELITE_CHECK_EQ(work_range[nthread], end); + + return work_range; +} + +} // namespace threading_utils +} // namespace treelite + +#endif // TREELITE_THREADING_UTILS_PARALLEL_FOR_H_ diff --git a/tests/cpp/CMakeLists.txt b/tests/cpp/CMakeLists.txt index f331a900..64315e3b 100644 --- a/tests/cpp/CMakeLists.txt +++ b/tests/cpp/CMakeLists.txt @@ -26,10 +26,11 @@ target_sources(treelite_cpp_test test_serializer.cc test_frontend.cc test_compiler_param.cc + test_threading_utils.cc ) target_include_directories(treelite_cpp_test - PRIVATE ../../src/frontend + PRIVATE ../../src/frontend ../../src/ ) msvc_use_static_runtime() diff --git a/tests/cpp/test_threading_utils.cc b/tests/cpp/test_threading_utils.cc new file mode 100644 index 00000000..5adf4b3b --- /dev/null +++ b/tests/cpp/test_threading_utils.cc @@ -0,0 +1,137 @@ +/*! + * Copyright (c) 2021 by Contributors + * \file test_threading_utils.cc + * \author Hyunsu Cho + * \brief C++ tests for threading utilities + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include "threading_utils/parallel_for.h" + +namespace { + +class RandomGenerator { + public: + RandomGenerator() + : rng_(std::random_device()()), + int_dist_(std::numeric_limits::min(), std::numeric_limits::max()), + real_dist_(0.0, 1.0) {} + + int64_t DrawInteger(int64_t low, int64_t high) { + TREELITE_CHECK_LT(low, high); + int64_t out = int_dist_(rng_); + int64_t rem = out % (high - low); + int64_t ret; + if (rem < 0) { + ret = high + rem; + } else { + ret = low + rem; + } + TREELITE_CHECK_GE(ret, low); + TREELITE_CHECK_LT(ret, high); + return ret; + } + + double DrawReal(double low, double high) { + TREELITE_CHECK_LT(low, high); + return real_dist_(rng_) * (high - low) + low; + } + + private: + std::mt19937 rng_; + std::uniform_int_distribution int_dist_; + std::uniform_real_distribution real_dist_; +}; + +} // namespace anonymous + +namespace treelite { +namespace threading_utils { + +TEST(ThreadingUtils, ComputeWorkRange) { + /* Test error handling */ + EXPECT_THROW(ComputeWorkRange(0, 100, 0), treelite::Error); + EXPECT_THROW(ComputeWorkRange(-100, 100, 3), treelite::Error); + EXPECT_THROW(ComputeWorkRange(-200, -100, 3), treelite::Error); + EXPECT_THROW(ComputeWorkRange(200, 100, 3), treelite::Error); + + /* Property-based testing with randomly generated parameters */ + RandomGenerator rng; + + constexpr int kNumTrial = 200; + for (int i = 0; i < kNumTrial; ++i) { + int64_t begin = rng.DrawInteger(0, 10000); + std::size_t nthread = static_cast(rng.DrawInteger(1, 100)); + int64_t end = rng.DrawInteger(begin, 10000); + auto range = ComputeWorkRange(begin, end, nthread); + EXPECT_EQ(range.size(), nthread + 1); + EXPECT_EQ(range[0], begin); + EXPECT_EQ(range[nthread], end); + for (std::size_t i = 0; i < nthread; ++i) { + EXPECT_GE(range[i + 1], range[i]); + } + } + // Test the case with begin == end + for (int i = 0; i < 10; ++i) { + int64_t begin = rng.DrawInteger(0, 10000); + int64_t end = begin; + std::size_t nthread = static_cast(rng.DrawInteger(1, 100)); + auto range = ComputeWorkRange(begin, end, nthread); + EXPECT_EQ(range.size(), nthread + 1); + EXPECT_EQ(range[0], begin); + EXPECT_EQ(range[nthread], begin); + for (std::size_t i = 0; i < nthread; ++i) { + EXPECT_EQ(range[i + 1], range[i]); + } + } +} + +TEST(ThreadingUtils, ParallelFor) { + /* Test error handling */ + const int max_thread = std::thread::hardware_concurrency(); + + auto dummy_func = [](int, std::size_t) {}; + EXPECT_THROW(ParallelFor(0, 100, 0, dummy_func), treelite::Error); + EXPECT_THROW(ParallelFor(200, 100, 3, dummy_func), treelite::Error); + EXPECT_THROW(ParallelFor(-100, 100, 3, dummy_func), treelite::Error); + EXPECT_THROW(ParallelFor(-200, -100, 3, dummy_func), treelite::Error); + EXPECT_THROW(ParallelFor(200, 100, 3, dummy_func), treelite::Error); + EXPECT_THROW(ParallelFor(10, 20, 3 * max_thread, dummy_func), treelite::Error); + + /* Property-based testing with randomly generated parameters */ + constexpr int kVectorLength = 10000; + RandomGenerator rng; + std::vector a(kVectorLength); + std::vector b(kVectorLength); + std::generate_n(a.begin(), kVectorLength, [&rng]() { return rng.DrawReal(-1.0, 1.0); }); + std::generate_n(b.begin(), kVectorLength, [&rng]() { return rng.DrawReal(-10.0, 10.0); }); + + constexpr int kNumTrial = 200; + for (int i = 0; i < kNumTrial; ++i) { + std::vector c(kVectorLength); + // Fill c with dummy values + std::generate_n(c.begin(), kVectorLength, [&rng]() { return rng.DrawReal(100.0, 200.0); }); + + // Compute c := a + b on range [begin, end) + int64_t begin = rng.DrawInteger(0, kVectorLength); + std::size_t nthread = static_cast(rng.DrawInteger(1, max_thread + 1)); + int64_t end = rng.DrawInteger(begin, kVectorLength); + + ParallelFor(begin, end, nthread, [&a, &b, &c](int64_t i, std::size_t) { + c[i] = a[i] + b[i]; + }); + + for (int64_t i = begin; i < end; ++i) { + EXPECT_FLOAT_EQ(c[i], a[i] + b[i]) << ", at index " << i; + } + } +} + +} // namespace threading_utils +} // namespace treelite diff --git a/tests/travis/run_test.sh b/tests/travis/run_test.sh index 012e692b..ae8f0ae6 100755 --- a/tests/travis/run_test.sh +++ b/tests/travis/run_test.sh @@ -9,18 +9,20 @@ then conda activate python3 conda --version python --version + conda install -c conda-forge numpy scipy pandas pytest pytest-cov scikit-learn coverage ninja lcov cmake # Run coverage test set -x rm -rf build/ mkdir build cd build - cmake .. -DTEST_COVERAGE=ON -DUSE_OPENMP=ON -DBUILD_CPP_TEST=ON -GNinja + cmake .. -DTEST_COVERAGE=ON -DBUILD_CPP_TEST=ON -GNinja ninja cd .. - conda install -c conda-forge numpy scipy pandas pytest pytest-cov scikit-learn coverage - python -m pip install --pre xgboost - python -m pip install lightgbm codecov + # Install XGBoost and LightGBM without OpenMP + python -m pip install --pre xgboost --no-binary :all: + python -m pip install lightgbm --no-binary :all: + python -m pip install codecov ./build/treelite_cpp_test PYTHONPATH=./python:./runtime/python python -m pytest --cov=treelite --cov=treelite_runtime -v --fulltrace tests/python lcov --directory . --capture --output-file coverage.info @@ -36,13 +38,14 @@ then conda activate python3 conda --version python --version + conda install -c conda-forge ninja cmake # Install Treelite C++ library into the Conda env set -x rm -rf build/ mkdir build cd build - cmake .. -DCMAKE_INSTALL_PREFIX="$CONDA_PREFIX" -DCMAKE_INSTALL_LIBDIR="lib" -DUSE_OPENMP=ON -DBUILD_STATIC_LIBS=ON -GNinja + cmake .. -DCMAKE_INSTALL_PREFIX="$CONDA_PREFIX" -DCMAKE_INSTALL_LIBDIR="lib" -DBUILD_STATIC_LIBS=ON -GNinja ninja install # Try compiling a sample application @@ -60,13 +63,14 @@ then conda activate python3 conda --version python --version + conda install -c conda-forge numpy scipy pandas pytest scikit-learn coverage ninja cmake # Build binary wheel set -x rm -rf build/ mkdir build cd build - cmake .. -DUSE_OPENMP=ON -GNinja + cmake .. -GNinja ninja cd .. rm -rfv python/dist python/build @@ -88,9 +92,9 @@ then python -m pip install ./runtime/python/dist/treelite_runtime-*-py3-none-${TAG}.whl # Run tests - conda install -c conda-forge numpy scipy pandas pytest scikit-learn coverage - python -m pip install --pre xgboost - python -m pip install lightgbm + # Install XGBoost and LightGBM without OpenMP + python -m pip install --pre xgboost --no-binary :all: + python -m pip install lightgbm --no-binary :all: python -m pytest -v --fulltrace tests/python # Deploy binary wheel to S3 @@ -109,7 +113,7 @@ fi if [ ${TASK} == "python_sdist_test" ]; then conda activate python3 python --version - conda install numpy scipy + conda install -c conda-forge numpy scipy pandas pytest scikit-learn coverage cmake ninja # Build source distribution make pippack @@ -119,9 +123,9 @@ if [ ${TASK} == "python_sdist_test" ]; then python -m pip install -v treelite_runtime-*.tar.gz # Run tests - conda install -c conda-forge numpy scipy pandas pytest scikit-learn coverage - python -m pip install --pre xgboost - python -m pip install lightgbm + # Install XGBoost and LightGBM without OpenMP + python -m pip install --pre xgboost --no-binary :all: + python -m pip install lightgbm --no-binary :all: python -m pytest -v --fulltrace tests/python # Deploy source wheel to S3 diff --git a/tests/travis/setup.sh b/tests/travis/setup.sh index a7b0a2e0..d89b05e2 100755 --- a/tests/travis/setup.sh +++ b/tests/travis/setup.sh @@ -3,12 +3,6 @@ set -eo pipefail if [ ${TRAVIS_OS_NAME} == "osx" ]; then - brew update - # Use libomp 11.1.0: https://github.com/dmlc/xgboost/issues/7039 - wget https://raw.githubusercontent.com/Homebrew/homebrew-core/679923b4eb48a8dc7ecc1f05d06063cd79b3fc00/Formula/libomp.rb -O $(find $(brew --repository) -name libomp.rb) - brew install cmake libomp lcov ninja - brew pin libomp - wget -O conda.sh https://repo.anaconda.com/miniconda/Miniconda3-latest-MacOSX-x86_64.sh else wget -O conda.sh https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh