From 32ac06378d6f5756ade33d68aed145fe6bec8aff Mon Sep 17 00:00:00 2001 From: Mark Bates Date: Wed, 9 Aug 2017 15:34:54 +0200 Subject: [PATCH] Initial commit --- .gitignore | 7 + CMakeLists.txt | 159 +++ Cpufit/CMakeLists.txt | 29 + Cpufit/Cpufit.def | 4 + Cpufit/README.md | 1 + Cpufit/cpufit.cpp | 76 ++ Cpufit/cpufit.h | 56 + Cpufit/info.cpp | 30 + Cpufit/info.h | 28 + Cpufit/interface.cpp | 118 ++ Cpufit/interface.h | 57 + Cpufit/lm_fit.cpp | 57 + Cpufit/lm_fit.h | 137 +++ Cpufit/lm_fit_cpp.cpp | 711 +++++++++++ Cpufit/matlab/CMakeLists.txt | 62 + Cpufit/matlab/README.md | 3 + Cpufit/matlab/cpufit.m | 119 ++ Cpufit/matlab/examples/gauss2d.m | 182 +++ Cpufit/matlab/examples/gauss2d_plot.m | 117 ++ Cpufit/matlab/mex/CpufitMex.cpp | 145 +++ Gpufit/CMakeLists.txt | 160 +++ Gpufit/Gpufit.def | 7 + Gpufit/cauchy_2d_elliptic.cuh | 107 ++ Gpufit/cuda_gaussjordan.cu | 279 +++++ Gpufit/cuda_gaussjordan.cuh | 15 + Gpufit/cuda_kernels.cu | 1081 +++++++++++++++++ Gpufit/cuda_kernels.cuh | 108 ++ Gpufit/definitions.h | 12 + Gpufit/examples/CMakeLists.txt | 14 + Gpufit/examples/Gauss_Fit_2D_Example.cpp | 260 ++++ Gpufit/examples/Linear_Regression_Example.cpp | 207 ++++ Gpufit/examples/Simple_Example.cpp | 94 ++ Gpufit/gauss_1d.cuh | 91 ++ Gpufit/gauss_2d.cuh | 97 ++ Gpufit/gauss_2d_elliptic.cuh | 100 ++ Gpufit/gauss_2d_rotated.cuh | 106 ++ Gpufit/gpu_data.cu | 175 +++ Gpufit/gpu_data.cuh | 122 ++ Gpufit/gpufit.cpp | 130 ++ Gpufit/gpufit.h | 63 + Gpufit/info.cpp | 124 ++ Gpufit/info.cu | 31 + Gpufit/info.h | 48 + Gpufit/interface.cpp | 123 ++ Gpufit/interface.h | 63 + Gpufit/linear_1d.cuh | 103 ++ Gpufit/lm_fit.cpp | 92 ++ Gpufit/lm_fit.h | 88 ++ Gpufit/lm_fit_cuda.cpp | 57 + Gpufit/lm_fit_cuda.cu | 253 ++++ Gpufit/lse.cuh | 186 +++ Gpufit/matlab/CMakeLists.txt | 69 ++ Gpufit/matlab/EstimatorID.m | 6 + Gpufit/matlab/ModelID.m | 10 + Gpufit/matlab/README.txt | 19 + Gpufit/matlab/examples/gauss2d.m | 182 +++ Gpufit/matlab/examples/gauss2d_comparison.m | 206 ++++ Gpufit/matlab/examples/gauss2d_plot.m | 117 ++ Gpufit/matlab/examples/simple.m | 26 + Gpufit/matlab/gpufit.m | 119 ++ Gpufit/matlab/mex/GpufitMex.cpp | 150 +++ Gpufit/matlab/tests/gauss_fit_1d_test.m | 35 + Gpufit/matlab/tests/run_tests.m | 8 + Gpufit/mle.cuh | 179 +++ Gpufit/python/CMakeLists.txt | 53 + Gpufit/python/README.txt | 27 + Gpufit/python/examples/gauss2d.py | 112 ++ Gpufit/python/examples/gauss2d_plot.py | 114 ++ Gpufit/python/examples/simple.py | 30 + Gpufit/python/pygpufit/__init__.py | 0 Gpufit/python/pygpufit/gpufit.py | 201 +++ Gpufit/python/requirements.txt | 1 + Gpufit/python/setup.cfg | 2 + Gpufit/python/setup.py | 40 + Gpufit/python/tests/run_tests.py | 19 + Gpufit/python/tests/test_gaussian_fit_1d.py | 76 ++ Gpufit/python/tests/test_linear_regression.py | 60 + Gpufit/tests/CMakeLists.txt | 10 + Gpufit/tests/Cauchy_Fit_2D_Elliptic.cpp | 73 ++ Gpufit/tests/Error_Handling.cpp | 51 + Gpufit/tests/Gauss_Fit_1D.cpp | 87 ++ Gpufit/tests/Gauss_Fit_2D.cpp | 96 ++ Gpufit/tests/Gauss_Fit_2D_Elliptic.cpp | 74 ++ Gpufit/tests/Gauss_Fit_2D_Rotated.cpp | 77 ++ Gpufit/tests/Linear_Fit_1D.cpp | 101 ++ LICENSE.txt | 21 + README.md | 62 + docs/_static/style.css | 15 + docs/_templates/layout.html | 4 + docs/appendix.rst | 31 + docs/bindings.rst | 413 +++++++ docs/conf.py | 457 +++++++ docs/customization.rst | 299 +++++ docs/epilog.txt | 48 + docs/examples.rst | 394 ++++++ docs/fit_estimator_functions.rst | 54 + docs/fit_model_functions.rst | 193 +++ docs/gpufit_api.rst | 377 ++++++ .../GPUFIT_CPUFIT_Performance_Comparison.png | Bin 0 -> 38471 bytes ...PUfit_PassmarkG3D_relative_performance.png | Bin 0 -> 53574 bytes docs/images/algorithm_gpufit_flowchart.png | Bin 0 -> 16334 bytes docs/images/algorithm_gpufit_flowchart.vsdx | Bin 0 -> 47745 bytes .../gpufit_program_flow_skeleton_v2.png | Bin 0 -> 83227 bytes docs/images/gpufit_program_flow_v2.png | Bin 0 -> 121269 bytes docs/index.rst | 22 + docs/installation.rst | 220 ++++ docs/introduction.rst | 87 ++ docs/license.rst | 25 + docs/make.bat | 281 +++++ examples/CMakeLists.txt | 20 + .../Gpufit_Cpufit_Nvidia_Profiler_Test.cpp | 340 ++++++ .../Gpufit_Cpufit_Performance_Comparison.cpp | 450 +++++++ ...t_Cpufit_Performance_Comparison_readme.txt | 106 ++ package/README.md | 48 + package/create_package.bat | 170 +++ package/sdk_readme.txt | 10 + tests/CMakeLists.txt | 4 + tests/Consistency.cpp | 220 ++++ tests/utils.cpp | 60 + tests/utils.h | 176 +++ 120 files changed, 13531 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 Cpufit/CMakeLists.txt create mode 100644 Cpufit/Cpufit.def create mode 100644 Cpufit/README.md create mode 100644 Cpufit/cpufit.cpp create mode 100644 Cpufit/cpufit.h create mode 100644 Cpufit/info.cpp create mode 100644 Cpufit/info.h create mode 100644 Cpufit/interface.cpp create mode 100644 Cpufit/interface.h create mode 100644 Cpufit/lm_fit.cpp create mode 100644 Cpufit/lm_fit.h create mode 100644 Cpufit/lm_fit_cpp.cpp create mode 100644 Cpufit/matlab/CMakeLists.txt create mode 100644 Cpufit/matlab/README.md create mode 100644 Cpufit/matlab/cpufit.m create mode 100644 Cpufit/matlab/examples/gauss2d.m create mode 100644 Cpufit/matlab/examples/gauss2d_plot.m create mode 100644 Cpufit/matlab/mex/CpufitMex.cpp create mode 100644 Gpufit/CMakeLists.txt create mode 100644 Gpufit/Gpufit.def create mode 100644 Gpufit/cauchy_2d_elliptic.cuh create mode 100644 Gpufit/cuda_gaussjordan.cu create mode 100644 Gpufit/cuda_gaussjordan.cuh create mode 100644 Gpufit/cuda_kernels.cu create mode 100644 Gpufit/cuda_kernels.cuh create mode 100644 Gpufit/definitions.h create mode 100644 Gpufit/examples/CMakeLists.txt create mode 100644 Gpufit/examples/Gauss_Fit_2D_Example.cpp create mode 100644 Gpufit/examples/Linear_Regression_Example.cpp create mode 100644 Gpufit/examples/Simple_Example.cpp create mode 100644 Gpufit/gauss_1d.cuh create mode 100644 Gpufit/gauss_2d.cuh create mode 100644 Gpufit/gauss_2d_elliptic.cuh create mode 100644 Gpufit/gauss_2d_rotated.cuh create mode 100644 Gpufit/gpu_data.cu create mode 100644 Gpufit/gpu_data.cuh create mode 100644 Gpufit/gpufit.cpp create mode 100644 Gpufit/gpufit.h create mode 100644 Gpufit/info.cpp create mode 100644 Gpufit/info.cu create mode 100644 Gpufit/info.h create mode 100644 Gpufit/interface.cpp create mode 100644 Gpufit/interface.h create mode 100644 Gpufit/linear_1d.cuh create mode 100644 Gpufit/lm_fit.cpp create mode 100644 Gpufit/lm_fit.h create mode 100644 Gpufit/lm_fit_cuda.cpp create mode 100644 Gpufit/lm_fit_cuda.cu create mode 100644 Gpufit/lse.cuh create mode 100644 Gpufit/matlab/CMakeLists.txt create mode 100644 Gpufit/matlab/EstimatorID.m create mode 100644 Gpufit/matlab/ModelID.m create mode 100644 Gpufit/matlab/README.txt create mode 100644 Gpufit/matlab/examples/gauss2d.m create mode 100644 Gpufit/matlab/examples/gauss2d_comparison.m create mode 100644 Gpufit/matlab/examples/gauss2d_plot.m create mode 100644 Gpufit/matlab/examples/simple.m create mode 100644 Gpufit/matlab/gpufit.m create mode 100644 Gpufit/matlab/mex/GpufitMex.cpp create mode 100644 Gpufit/matlab/tests/gauss_fit_1d_test.m create mode 100644 Gpufit/matlab/tests/run_tests.m create mode 100644 Gpufit/mle.cuh create mode 100644 Gpufit/python/CMakeLists.txt create mode 100644 Gpufit/python/README.txt create mode 100644 Gpufit/python/examples/gauss2d.py create mode 100644 Gpufit/python/examples/gauss2d_plot.py create mode 100644 Gpufit/python/examples/simple.py create mode 100644 Gpufit/python/pygpufit/__init__.py create mode 100644 Gpufit/python/pygpufit/gpufit.py create mode 100644 Gpufit/python/requirements.txt create mode 100644 Gpufit/python/setup.cfg create mode 100644 Gpufit/python/setup.py create mode 100644 Gpufit/python/tests/run_tests.py create mode 100644 Gpufit/python/tests/test_gaussian_fit_1d.py create mode 100644 Gpufit/python/tests/test_linear_regression.py create mode 100644 Gpufit/tests/CMakeLists.txt create mode 100644 Gpufit/tests/Cauchy_Fit_2D_Elliptic.cpp create mode 100644 Gpufit/tests/Error_Handling.cpp create mode 100644 Gpufit/tests/Gauss_Fit_1D.cpp create mode 100644 Gpufit/tests/Gauss_Fit_2D.cpp create mode 100644 Gpufit/tests/Gauss_Fit_2D_Elliptic.cpp create mode 100644 Gpufit/tests/Gauss_Fit_2D_Rotated.cpp create mode 100644 Gpufit/tests/Linear_Fit_1D.cpp create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 docs/_static/style.css create mode 100644 docs/_templates/layout.html create mode 100644 docs/appendix.rst create mode 100644 docs/bindings.rst create mode 100644 docs/conf.py create mode 100644 docs/customization.rst create mode 100644 docs/epilog.txt create mode 100644 docs/examples.rst create mode 100644 docs/fit_estimator_functions.rst create mode 100644 docs/fit_model_functions.rst create mode 100644 docs/gpufit_api.rst create mode 100644 docs/images/GPUFIT_CPUFIT_Performance_Comparison.png create mode 100644 docs/images/GPUfit_PassmarkG3D_relative_performance.png create mode 100644 docs/images/algorithm_gpufit_flowchart.png create mode 100644 docs/images/algorithm_gpufit_flowchart.vsdx create mode 100644 docs/images/gpufit_program_flow_skeleton_v2.png create mode 100644 docs/images/gpufit_program_flow_v2.png create mode 100644 docs/index.rst create mode 100644 docs/installation.rst create mode 100644 docs/introduction.rst create mode 100644 docs/license.rst create mode 100644 docs/make.bat create mode 100644 examples/CMakeLists.txt create mode 100644 examples/Gpufit_Cpufit_Nvidia_Profiler_Test.cpp create mode 100644 examples/Gpufit_Cpufit_Performance_Comparison.cpp create mode 100644 examples/Gpufit_Cpufit_Performance_Comparison_readme.txt create mode 100644 package/README.md create mode 100644 package/create_package.bat create mode 100644 package/sdk_readme.txt create mode 100644 tests/CMakeLists.txt create mode 100644 tests/Consistency.cpp create mode 100644 tests/utils.cpp create mode 100644 tests/utils.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a7c293 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Python +**/.idea +__pycache__ + +# docs +/docs/_build + diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..9590a65 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,159 @@ +# Levenberg Marquardt curve fitting in CUDA +# https://github.com/gpufit/Gpufit +# see also CMake configuration in /docs/installation.rst + +# CMake + +cmake_minimum_required( VERSION 3.7 ) +set_property( GLOBAL PROPERTY USE_FOLDERS ON ) + +if( NOT PROJECT_NAME ) + project( Gpufit VERSION 1.0.0 ) + include( CTest ) +endif() + +if( MSVC ) # link runtime statically + foreach( type ${CMAKE_CONFIGURATION_TYPES} ${CMAKE_BUILD_TYPE} ) + string( TOUPPER ${type} TYPE ) + foreach( flags CMAKE_C_FLAGS_${TYPE} CMAKE_CXX_FLAGS_${TYPE} ) + get_property( help CACHE ${flags} PROPERTY HELPSTRING ) + string( REPLACE "/MD" "/MT" ${flags} "${${flags}}" ) + set( ${flags} "${${flags}}" CACHE STRING "${help}" FORCE ) + endforeach() + endforeach() +endif() + +function( add_launcher target executable arguments working_directory ) + if( MSVC12 OR MSVC14 ) + file( WRITE ${CMAKE_CURRENT_BINARY_DIR}/${target}.vcxproj.user +"\n" +"\n" +" \n" +" ${executable}\n" +" ${arguments}\n" +" ${working_directory}\n" +" \n" +"\n" + ) + endif() +endfunction() + +# Boost + +find_package( Boost 1.58.0 ) +if( Boost_FOUND ) + function( add_boost_test modules name ) + string( REPLACE ";" "_" prefix "${modules}" ) + set( target ${prefix}_Test_${name} ) + add_executable( ${target} ${name}.cpp + ${PROJECT_SOURCE_DIR}/Tests/utils.h + ${PROJECT_SOURCE_DIR}/Tests/utils.cpp + ) + target_include_directories( ${target} PRIVATE ${PROJECT_SOURCE_DIR} ) + target_link_libraries( ${target} ${modules} Boost::boost ) + set_property( TARGET ${target} + PROPERTY RUNTIME_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}" ) + set_property( TARGET ${target} PROPERTY FOLDER Tests ) + + add_test( NAME ${target} + COMMAND ${target} --build_info --log_level=all --report_level=detailed ) + endfunction() +else() + set( BUILD_TESTING OFF ) + message( WARNING "Boost NOT found - skipping tests! (set BOOST_ROOT manually)" ) +endif() + +# MATLAB + +find_package( Matlab ) +if( Matlab_FOUND ) + find_program( Matlab_EXECUTABLE matlab + PATHS "${Matlab_ROOT_DIR}/bin" PATH_SUFFIXES win32 win64 NO_DEFAULT_PATH ) + function( add_matlab_launcher target ) + set( paths "${CMAKE_BINARY_DIR}/$(Configuration)" ${ARGN} ) + list( GET paths -1 working_directory ) + string( REPLACE ";" "','" paths "${paths}" ) + set( arguments "-r addpath('${paths}');addpath(genpath(pwd))" ) + add_launcher( ${target} "${Matlab_EXECUTABLE}" "${arguments}" "${working_directory}" ) + endfunction() +endif() + +# Python + +find_package( PythonInterp ) +if( PYTHONINTERP_FOUND ) + function( add_python_launcher target ) + set( paths "${CMAKE_BINARY_DIR}/$(Configuration)" ${ARGN} ) + list( GET paths -1 working_directory ) + string( REPLACE ";" "')\nsys.path.append('" paths "${paths}" ) + set( arguments "-i -c \"import sys\nsys.path.append('${paths}')\"" ) + add_launcher( ${target} "${PYTHON_EXECUTABLE}" "${arguments}" "${working_directory}" ) + endfunction() +endif() + +# Cpufit + +add_subdirectory( Cpufit ) + +# Gpufit + +add_subdirectory( Gpufit ) + +# Examples using Gpufit and Cpufit + +add_subdirectory( examples ) + +# Launcher +# +# Uses the following variables: +# +# Matlab_WORKING_DIRECTORY (Default: user home directory) +# -- Working directory for MATLAB applications using Cpufit and Gpufit. +# Python_WORKING_DIRECTORY (Default: user home directory) +# -- Working directory for Python applications using Gpufit. + +if( WIN32 ) + file( TO_CMAKE_PATH "$ENV{HOMEPATH}" home ) +else() + file( TO_CMAKE_PATH "$ENV{HOME}" home ) +endif() + +if( Matlab_FOUND ) + set( Matlab_WORKING_DIRECTORY "${home}" CACHE PATH "MATLAB working directory" ) + if( Matlab_WORKING_DIRECTORY ) + add_custom_target( RUN_MATLAB ) + set_property( TARGET RUN_MATLAB PROPERTY FOLDER CMakePredefinedTargets ) + add_dependencies( RUN_MATLAB CpufitMex GpufitMex ) + add_matlab_launcher( RUN_MATLAB + "${CMAKE_SOURCE_DIR}/Cpufit/matlab" + "${CMAKE_SOURCE_DIR}/Gpufit/matlab" + "${Matlab_WORKING_DIRECTORY}" + ) + endif() +endif() + +if( PYTHONINTERP_FOUND ) + set( Python_WORKING_DIRECTORY "${home}" CACHE PATH "Python working directory" ) + if( Python_WORKING_DIRECTORY ) + add_custom_target( RUN_PYTHON ) + set_property( TARGET RUN_PYTHON PROPERTY FOLDER CMakePredefinedTargets ) + add_dependencies( RUN_PYTHON Gpufit ) + add_python_launcher( RUN_PYTHON + "${CMAKE_SOURCE_DIR}/Gpufit/python" + "${Python_WORKING_DIRECTORY}" + ) + endif() +endif() + +# Tests + +if( BUILD_TESTING ) + add_subdirectory( tests ) +endif() + +# Package + +#set( CPACK_PACKAGE_VERSION ${PROJECT_VERSION} ) +#set( CPACK_GENERATOR ZIP ) + +#include( CPack ) diff --git a/Cpufit/CMakeLists.txt b/Cpufit/CMakeLists.txt new file mode 100644 index 0000000..9af1643 --- /dev/null +++ b/Cpufit/CMakeLists.txt @@ -0,0 +1,29 @@ + +# Cpufit + +set( CpuHeaders + Cpufit.h + info.h + lm_fit.h + interface.h +) + +set( CpuSources + Cpufit.cpp + info.cpp + lm_fit.cpp + lm_fit_cpp.cpp + interface.cpp + Cpufit.def +) + +add_library( Cpufit SHARED + ${CpuHeaders} + ${CpuSources} +) +set_property( TARGET Cpufit + PROPERTY RUNTIME_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}" ) + +#install( TARGETS Cpufit RUNTIME DESTINATION bin ) + +add_subdirectory( matlab ) diff --git a/Cpufit/Cpufit.def b/Cpufit/Cpufit.def new file mode 100644 index 0000000..07c1849 --- /dev/null +++ b/Cpufit/Cpufit.def @@ -0,0 +1,4 @@ +LIBRARY "Cpufit" +EXPORTS + cpufit @1 + cpufit_get_last_error @2 \ No newline at end of file diff --git a/Cpufit/README.md b/Cpufit/README.md new file mode 100644 index 0000000..cee0619 --- /dev/null +++ b/Cpufit/README.md @@ -0,0 +1 @@ +# Cpufit \ No newline at end of file diff --git a/Cpufit/cpufit.cpp b/Cpufit/cpufit.cpp new file mode 100644 index 0000000..c8c74cb --- /dev/null +++ b/Cpufit/cpufit.cpp @@ -0,0 +1,76 @@ +#include "cpufit.h" +#include "interface.h" + +#include + +std::string last_error ; + +int cpufit +( + size_t n_fits, + size_t n_points, + float * data, + float * weights, + int model_id, + float * initial_parameters, + float tolerance, + int max_n_iterations, + int * parameters_to_fit, + int estimator_id, + size_t user_info_size, + char * user_info, + float * output_parameters, + int * output_states, + float * output_chi_squares, + int * output_n_iterations +) +try +{ + __int32 n_points_32 = 0; + if (n_points <= (unsigned int)(std::numeric_limits<__int32>::max())) + { + n_points_32 = __int32(n_points); + } + else + { + throw std::runtime_error("maximum number of data points per fit exceeded"); + } + + FitInterface fi( + data, + weights, + n_fits, + n_points_32, + tolerance, + max_n_iterations, + estimator_id, + initial_parameters, + parameters_to_fit, + user_info, + user_info_size, + output_parameters, + output_states, + output_chi_squares, + output_n_iterations); + + fi.fit(model_id); + + return STATUS_OK; +} +catch (std::exception & exception) +{ + last_error = exception.what(); + + return STATUS_ERROR; +} +catch (...) +{ + last_error = "Unknown Error"; + + return STATUS_ERROR; +} + +char const * cpufit_get_last_error() +{ + return last_error.c_str(); +} diff --git a/Cpufit/cpufit.h b/Cpufit/cpufit.h new file mode 100644 index 0000000..1575636 --- /dev/null +++ b/Cpufit/cpufit.h @@ -0,0 +1,56 @@ +#ifndef CPU_FIT_H_INCLUDED +#define CPU_FIT_H_INCLUDED + +// fitting model ID +#define GAUSS_1D 0 +#define GAUSS_2D 1 +#define GAUSS_2D_ELLIPTIC 2 +#define GAUSS_2D_ROTATED 3 +#define CAUCHY_2D_ELLIPTIC 4 +#define LINEAR_1D 5 + +// estimator ID +#define LSE 0 +#define MLE 1 + +// fit state +#define STATE_CONVERGED 0 +#define STATE_MAX_ITERATION 1 +#define STATE_SINGULAR_HESSIAN 2 +#define STATE_NEG_CURVATURE_MLE 3 + +// cpufit return state +#define STATUS_OK 0 +#define STATUS_ERROR -1 + +#ifdef __cplusplus +extern "C" { +#endif + +int cpufit +( + size_t n_fits, + size_t n_points, + float * data, + float * weights, + int model_id, + float * initial_parameters, + float tolerance, + int max_n_iterations, + int * parameters_to_fit, + int estimator_id, + size_t user_info_size, + char * user_info, + float * output_parameters, + int * output_states, + float * output_chi_squares, + int * output_n_iterations +) ; + +char const * cpufit_get_last_error() ; + +#ifdef __cplusplus +} +#endif + +#endif // CPU_FIT_H_INCLUDED diff --git a/Cpufit/info.cpp b/Cpufit/info.cpp new file mode 100644 index 0000000..dcd5085 --- /dev/null +++ b/Cpufit/info.cpp @@ -0,0 +1,30 @@ +#include "info.h" + +Info::Info(void) : + n_parameters_(0), + n_parameters_to_fit_(0), + max_n_iterations_(0), + n_fits_(0), + n_points_(0), + model_id_(0), + estimator_id_(0), + user_info_size_(0) +{ +} + +Info::~Info(void) +{ +} + +void Info::set_number_of_parameters_to_fit(int const * parameters_to_fit) +{ + n_parameters_to_fit_ = n_parameters_; + + for (int i = 0; i < n_parameters_; i++) + { + if (!parameters_to_fit[i]) + { + n_parameters_to_fit_--; + } + } +} \ No newline at end of file diff --git a/Cpufit/info.h b/Cpufit/info.h new file mode 100644 index 0000000..0faa764 --- /dev/null +++ b/Cpufit/info.h @@ -0,0 +1,28 @@ +#ifndef CPUFIT_PARAMETERS_H_INCLUDED +#define CPUFIT_PARAMETERS_H_INCLUDED + +#include + +class Info +{ +public: + Info(); + virtual ~Info(); + void set_number_of_parameters_to_fit(int const * parameters_to_fit); + +private: + +public: + int n_parameters_; + int n_parameters_to_fit_; + std::size_t n_fits_; + std::size_t n_points_; + int max_n_iterations_; + int model_id_; + int estimator_id_; + std::size_t user_info_size_; + +private: +}; + +#endif diff --git a/Cpufit/interface.cpp b/Cpufit/interface.cpp new file mode 100644 index 0000000..50dc01d --- /dev/null +++ b/Cpufit/interface.cpp @@ -0,0 +1,118 @@ +#include "cpufit.h" +#include "interface.h" + +FitInterface::FitInterface( + float const * data, + float const * weights, + std::size_t n_fits, + int n_points, + float tolerance, + int max_n_iterations, + int estimator_id, + float const * initial_parameters, + int const * parameters_to_fit, + char * user_info, + std::size_t user_info_size, + float * output_parameters, + int * output_states, + float * output_chi_squares, + int * output_n_iterations) : + data_(data), + weight_(weights), + n_fits_(n_fits), + n_points_(n_points), + tolerance_(tolerance), + max_n_iterations_(max_n_iterations), + estimator_id_(estimator_id), + initial_parameters_(initial_parameters), + parameters_to_fit_(parameters_to_fit), + user_info_(user_info), + user_info_size_(user_info_size), + output_parameters_(output_parameters), + output_states_(output_states), + output_chi_squares_(output_chi_squares), + output_n_iterations_(output_n_iterations), + n_parameters_(0) +{} + +FitInterface::~FitInterface() +{} + +void FitInterface::check_sizes() +{ + std::size_t maximum_size = std::numeric_limits< std::size_t >::max(); + + if (n_fits_ > maximum_size / n_points_ / sizeof(float)) + { + throw std::runtime_error("maximum absolute number of data points exceeded"); + } + + if (n_fits_ > maximum_size / n_parameters_ / sizeof(float)) + { + throw std::runtime_error("maximum number of fits and/or parameters exceeded"); + } +} + +void FitInterface::configure_info(Info & info, int const model_id) +{ + info.model_id_ = model_id; + info.n_fits_ = n_fits_; + info.n_points_ = n_points_; + info.max_n_iterations_ = max_n_iterations_; + info.estimator_id_ = estimator_id_; + info.user_info_size_ = user_info_size_; + info.n_parameters_ = n_parameters_; + + info.set_number_of_parameters_to_fit(parameters_to_fit_); +} + +void FitInterface::set_number_of_parameters(int const model_id) +{ + switch (model_id) + { + case GAUSS_1D: + n_parameters_ = 4; + break; + case GAUSS_2D: + n_parameters_ = 5; + break; + case GAUSS_2D_ELLIPTIC: + n_parameters_ = 6; + break; + case GAUSS_2D_ROTATED: + n_parameters_ = 7; + break; + case CAUCHY_2D_ELLIPTIC: + n_parameters_ = 6; + break; + case LINEAR_1D: + n_parameters_ = 2; + break; + default: + break; + } +} + +void FitInterface::fit(int const model_id) +{ + set_number_of_parameters(model_id); + + check_sizes(); + + Info info; + configure_info(info, model_id); + + LMFit lmfit( + data_, + weight_, + info, + initial_parameters_, + parameters_to_fit_, + user_info_, + output_parameters_, + output_states_, + output_chi_squares_, + output_n_iterations_); + + lmfit.run(tolerance_); +} diff --git a/Cpufit/interface.h b/Cpufit/interface.h new file mode 100644 index 0000000..09bdc11 --- /dev/null +++ b/Cpufit/interface.h @@ -0,0 +1,57 @@ +#ifndef CPUFIT_INTERFACE_H_INCLUDED +#define CPUFIT_INTERFACE_H_INCLUDED + +#include "lm_fit.h" + +class FitInterface +{ +public: + FitInterface( + float const * data, + float const * weights, + std::size_t n_fits, + int n_points, + float tolerance, + int max_n_iterations, + int estimator_id, + float const * initial_parameters, + int const * parameters_to_fit, + char * user_info, + std::size_t user_info_size, + float * output_parameters, + int * output_states, + float * output_chi_squares, + int * output_n_iterations); + + virtual ~FitInterface(); + + void fit(int const model_id); + +private: + void set_number_of_parameters(int const model_id); + void check_sizes(); + void configure_info(Info & info, int const model_id); + +public: + +private: + int n_parameters_; + float const * const data_; + float const * const weight_; + std::size_t const n_fits_; + int const n_points_; + float const tolerance_; + int const max_n_iterations_; + int const estimator_id_; + float const * const initial_parameters_; + int const * const parameters_to_fit_; + char * const user_info_; + std::size_t const user_info_size_; + + float * output_parameters_; + int * output_states_; + float * output_chi_squares_; + int * output_n_iterations_; +}; + +#endif diff --git a/Cpufit/lm_fit.cpp b/Cpufit/lm_fit.cpp new file mode 100644 index 0000000..e6fa64f --- /dev/null +++ b/Cpufit/lm_fit.cpp @@ -0,0 +1,57 @@ +#include "lm_fit.h" +#include +#include +#include +#include +#include +#include + +LMFit::LMFit( + float const * const data, + float const * const weights, + Info const & info, + float const * const initial_parameters, + int const * const parameters_to_fit, + char * const user_info, + float * output_parameters, + int * output_states, + float * output_chi_squares, + int * output_n_iterations + ) : + data_(data), + weights_(weights), + initial_parameters_(initial_parameters), + parameters_to_fit_(parameters_to_fit), + user_info_(user_info), + output_parameters_(output_parameters), + output_states_(output_states), + output_chi_squares_(output_chi_squares), + output_n_iterations_(output_n_iterations), + info_(info) +{} + +LMFit::~LMFit() +{ +} + +void LMFit::run(float const tolerance) +{ + for (std::size_t fit_index = 0; fit_index < info_.n_fits_; fit_index++) + { + LMFitCPP gf_cpp( + tolerance, + fit_index, + data_ + fit_index*info_.n_points_, + weights_ ? weights_ + fit_index*info_.n_points_ : 0, + info_, + initial_parameters_ + fit_index*info_.n_parameters_, + parameters_to_fit_, + user_info_, + output_parameters_ + fit_index*info_.n_parameters_, + output_states_ + fit_index, + output_chi_squares_ + fit_index, + output_n_iterations_ + fit_index); + + gf_cpp.run(); + } +} \ No newline at end of file diff --git a/Cpufit/lm_fit.h b/Cpufit/lm_fit.h new file mode 100644 index 0000000..a5fd96d --- /dev/null +++ b/Cpufit/lm_fit.h @@ -0,0 +1,137 @@ +#ifndef CPUFIT_GAUSS_FIT_H_INCLUDED +#define CPUFIT_GAUSS_FIT_H_INCLUDED + +#include "info.h" + +class LMFitCPP; + +class LMFit +{ +public: + LMFit( + float const * data, + float const * weights, + Info const& info, + float const * initial_parameters, + int const * parameters_to_fit, + char * user_info, + float * output_parameters, + int * output_states, + float * output_chi_squares, + int * output_n_iterations); + + virtual ~LMFit(); + + void run(float const tolerance); + +private: + float const * const data_; + float const * const weights_; + float const * const initial_parameters_; + int const * const parameters_to_fit_; + char * const user_info_; + + float * output_parameters_; + int * output_states_; + float * output_chi_squares_; + int * output_n_iterations_; + + Info const & info_; +}; + +class LMFitCPP +{ +public: + LMFitCPP( + float const tolerance, + std::size_t const fit_index, + float const * data, + float const * weight, + Info const & info, + float const * initial_parameters, + int const * parameters_to_fit, + char * user_info, + float * output_parameters, + int * output_states, + float * output_chi_squares, + int * output_n_iterations); + + virtual ~LMFitCPP() + {}; + + void run(); + +private: + void calc_curve_values(); + void calc_coefficients(); + + void calc_curve_values(std::vector& curve, std::vector& derivatives); + + void calc_values_gauss2d(std::vector& gaussian); + void calc_derivatives_gauss2d(std::vector & derivatives); + + void calc_values_gauss2delliptic(std::vector& gaussian); + void calc_derivatives_gauss2delliptic(std::vector & derivatives); + + void calc_values_gauss2drotated(std::vector& gaussian); + void calc_derivatives_gauss2drotated(std::vector & derivatives); + + void calc_values_gauss1d(std::vector& gaussian); + void calc_derivatives_gauss1d(std::vector & derivatives); + + void calc_values_cauchy2delliptic(std::vector& cauchy); + void calc_derivatives_cauchy2delliptic(std::vector & derivatives); + + void calc_values_linear1d(std::vector& line); + void calc_derivatives_linear1d(std::vector & derivatives); + + void calculate_hessian(std::vector const & derivatives, + std::vector const & curve); + + void calc_gradient(std::vector const & derivatives, + std::vector const & curve); + + void calc_chi_square( + std::vector const & curve); + + void modify_step_width(); + void gauss_jordan(); + void update_parameters(); + + bool check_for_convergence(); + void evaluate_iteration(int const iteration); + void prepare_next_iteration(); + +public: + +private: + + std::size_t const fit_index_; + float const * const data_; + float const * const weight_; + float const * const initial_parameters_; + int const * const parameters_to_fit_; + + bool converged_; + float * parameters_; + int * state_; + float * chi_square_; + int * n_iterations_; + + std::vector prev_parameters_; + Info const & info_; + + float lambda_; + std::vector curve_; + std::vector derivatives_; + std::vector hessian_; + std::vector modified_hessian_; + std::vector gradient_; + std::vector delta_; + float prev_chi_square_; + float const tolerance_; + + char * const user_info_; +}; + +#endif \ No newline at end of file diff --git a/Cpufit/lm_fit_cpp.cpp b/Cpufit/lm_fit_cpp.cpp new file mode 100644 index 0000000..7eaae9d --- /dev/null +++ b/Cpufit/lm_fit_cpp.cpp @@ -0,0 +1,711 @@ +#include "cpufit.h" +#include "lm_fit.h" + +#include +#include +#include + +LMFitCPP::LMFitCPP( + float const tolerance, + std::size_t const fit_index, + float const * data, + float const * weight, + Info const & info, + float const * initial_parameters, + int const * parameters_to_fit, + char * user_info, + float * output_parameters, + int * output_state, + float * output_chi_square, + int * output_n_iterations + ) : + fit_index_(fit_index), + data_(data), + weight_(weight), + initial_parameters_(initial_parameters), + tolerance_(tolerance), + converged_(false), + info_(info), + parameters_to_fit_(parameters_to_fit), + curve_(info.n_points_), + derivatives_(info.n_points_*info.n_parameters_), + hessian_(info.n_parameters_to_fit_*info.n_parameters_to_fit_), + modified_hessian_(info.n_parameters_to_fit_*info.n_parameters_to_fit_), + gradient_(info.n_parameters_to_fit_), + delta_(info.n_parameters_to_fit_), + prev_chi_square_(0), + lambda_(0.001f), + prev_parameters_(info.n_parameters_to_fit_), + user_info_(user_info), + parameters_(output_parameters), + state_(output_state), + chi_square_(output_chi_square), + n_iterations_(output_n_iterations) +{} + +void LMFitCPP::calc_derivatives_gauss2d( + std::vector & derivatives) +{ + std::size_t const fit_size_x = std::size_t(std::sqrt(info_.n_points_)); + + for (std::size_t y = 0; y < fit_size_x; y++) + for (std::size_t x = 0; x < fit_size_x; x++) + { + float const argx = (x - parameters_[1]) * (x - parameters_[1]) / (2 * parameters_[3] * parameters_[3]); + float const argy = (y - parameters_[2]) * (y - parameters_[2]) / (2 * parameters_[3] * parameters_[3]); + float const ex = exp(-(argx + argy)); + + derivatives[0 * info_.n_points_ + y*fit_size_x + x] + = ex; + derivatives[1 * info_.n_points_ + y*fit_size_x + x] + = (parameters_[0] * (x - parameters_[1])*ex) / (parameters_[3] * parameters_[3]); + derivatives[2 * info_.n_points_ + y*fit_size_x + x] + = (parameters_[0] * (y - parameters_[2])*ex) / (parameters_[3] * parameters_[3]); + derivatives[3 * info_.n_points_ + y*fit_size_x + x] + = (parameters_[0] + * ((x - parameters_[1])*(x - parameters_[1]) + + (y - parameters_[2])*(y - parameters_[2]))*ex) + / (parameters_[3] * parameters_[3] * parameters_[3]); + derivatives[4 * info_.n_points_ + y*fit_size_x + x] + = 1; + } +} + +void LMFitCPP::calc_derivatives_gauss2delliptic( + std::vector & derivatives) +{ + std::size_t const fit_size_x = std::size_t(std::sqrt(info_.n_points_)); + + for (std::size_t y = 0; y < fit_size_x; y++) + for (std::size_t x = 0; x < fit_size_x; x++) + { + float const argx = (x - parameters_[1]) * (x - parameters_[1]) / (2 * parameters_[3] * parameters_[3]); + float const argy = (y - parameters_[2]) * (y - parameters_[2]) / (2 * parameters_[4] * parameters_[4]); + float const ex = exp(-(argx +argy)); + + derivatives[0 * info_.n_points_ + y*fit_size_x + x] + = ex; + derivatives[1 * info_.n_points_ + y*fit_size_x + x] + = (parameters_[0] * (x - parameters_[1])*ex) / (parameters_[3] * parameters_[3]); + derivatives[2 * info_.n_points_ + y*fit_size_x + x] + = (parameters_[0] * (y - parameters_[2])*ex) / (parameters_[4] * parameters_[4]); + derivatives[3 * info_.n_points_ + y*fit_size_x + x] + = (parameters_[0] * (x - parameters_[1])*(x - parameters_[1])*ex) / (parameters_[3] * parameters_[3] * parameters_[3]); + derivatives[4 * info_.n_points_ + y*fit_size_x + x] + = (parameters_[0] * (y - parameters_[2])*(y - parameters_[2])*ex) / (parameters_[4] * parameters_[4] * parameters_[4]); + derivatives[5 * info_.n_points_ + y*fit_size_x + x] + = 1; + } +} + +void LMFitCPP::calc_derivatives_gauss2drotated( + std::vector & derivatives) +{ + std::size_t const fit_size_x = std::size_t(std::sqrt(info_.n_points_)); + + float const amplitude = parameters_[0]; + float const x0 = parameters_[1]; + float const y0 = parameters_[2]; + float const sig_x = parameters_[3]; + float const sig_y = parameters_[4]; + float const background = parameters_[5]; + float const rot_sin = sin(parameters_[6]); + float const rot_cos = cos(parameters_[6]); + + for (std::size_t y = 0; y < fit_size_x; y++) + for (std::size_t x = 0; x < fit_size_x; x++) + { + float const arga = ((x - x0) * rot_cos) - ((y - y0) * rot_sin); + float const argb = ((x - x0) * rot_sin) + ((y - y0) * rot_cos); + float const ex = exp((-0.5f) * (((arga / sig_x) * (arga / sig_x)) + ((argb / sig_y) * (argb / sig_y)))); + + derivatives[0 * info_.n_points_ + y*fit_size_x + x] + = ex; + derivatives[1 * info_.n_points_ + y*fit_size_x + x] + = ex * (amplitude * rot_cos * arga / (sig_x*sig_x) + amplitude * rot_sin *argb / (sig_y*sig_y)); + derivatives[2 * info_.n_points_ + y*fit_size_x + x] + = ex * (-amplitude * rot_sin * arga / (sig_x*sig_x) + amplitude * rot_cos *argb / (sig_y*sig_y)); + derivatives[3 * info_.n_points_ + y*fit_size_x + x] + = ex * amplitude * arga * arga / (sig_x*sig_x*sig_x); + derivatives[4 * info_.n_points_ + y*fit_size_x + x] + = ex * amplitude * argb * argb / (sig_y*sig_y*sig_y); + derivatives[5 * info_.n_points_ + y*fit_size_x + x] + = 1; + derivatives[6 * info_.n_points_ + y*fit_size_x + x] + = ex * amplitude * arga * argb * (1.0f / (sig_x*sig_x) - 1.0f / (sig_y*sig_y)); + } +} + +void LMFitCPP::calc_derivatives_gauss1d( + std::vector & derivatives) +{ + for (std::size_t x = 0; x < info_.n_points_; x++) + { + float argx = ((x - parameters_[1])*(x - parameters_[1])) / (2 * parameters_[2] * parameters_[2]); + float ex = exp(-argx); + + derivatives[0 * info_.n_points_ + x] = ex; + derivatives[1 * info_.n_points_ + x] = (parameters_[0] * (x - parameters_[1])*ex) / (parameters_[2] * parameters_[2]); + derivatives[2 * info_.n_points_ + x] = (parameters_[0] * (x - parameters_[1])*(x - parameters_[1])*ex) / (parameters_[2] * parameters_[2] * parameters_[2]); + derivatives[3 * info_.n_points_ + x] = 1; + } +} + +void LMFitCPP::calc_derivatives_cauchy2delliptic( + std::vector & derivatives) +{ + std::size_t const fit_size_x = std::size_t(std::sqrt(info_.n_points_)); + + for (std::size_t y = 0; y < fit_size_x; y++) + for (std::size_t x = 0; x < fit_size_x; x++) + { + float const argx = + ((parameters_[1] - x) / parameters_[3]) + *((parameters_[1] - x) / parameters_[3]) + 1.f; + float const argy = + ((parameters_[2] - y) / parameters_[4]) + *((parameters_[2] - y) / parameters_[4]) + 1.f; + + derivatives[0 * info_.n_points_ + y*fit_size_x + x] + = 1.f / (argx*argy); + derivatives[1 * info_.n_points_ + y*fit_size_x + x] = + -2.f * parameters_[0] * (parameters_[1] - x) + / (parameters_[3] * parameters_[3] * argx*argx*argy); + derivatives[2 * info_.n_points_ + y*fit_size_x + x] = + -2.f * parameters_[0] * (parameters_[2] - y) + / (parameters_[4] * parameters_[4] * argy*argy*argx); + derivatives[3 * info_.n_points_ + y*fit_size_x + x] = + 2.f * parameters_[0] * (parameters_[1] - x) * (parameters_[1] - x) + / (parameters_[3] * parameters_[3] * parameters_[3] * argx*argx*argy); + derivatives[4 * info_.n_points_ + y*fit_size_x + x] = + 2.f * parameters_[0] * (parameters_[2] - y) * (parameters_[2] - y) + / (parameters_[4] * parameters_[4] * parameters_[4] * argy*argy*argx); + derivatives[5 * info_.n_points_ + y*fit_size_x + x] + = 1.f; + } +} + +void LMFitCPP::calc_derivatives_linear1d( + std::vector & derivatives) +{ + float * user_info_float = (float*)user_info_; + float x = 0.f; + + for (std::size_t point_index = 0; point_index < info_.n_points_; point_index++) + { + if (!user_info_float) + { + x = float(point_index); + } + else if (info_.user_info_size_ / sizeof(float) == info_.n_points_) + { + x = user_info_float[point_index]; + } + else if (info_.user_info_size_ / sizeof(float) > info_.n_points_) + { + std::size_t const fit_begin = fit_index_ * info_.n_points_; + x = user_info_float[fit_begin + point_index]; + } + + derivatives[0 * info_.n_points_ + point_index] = 1.f; + derivatives[1 * info_.n_points_ + point_index] = x; + } +} + +void LMFitCPP::calc_values_cauchy2delliptic(std::vector& cauchy) +{ + int const size_x = int(std::sqrt(float(info_.n_points_))); + int const size_y = size_x; + + for (int iy = 0; iy < size_y; iy++) + { + for (int ix = 0; ix < size_x; ix++) + { + float const argx = + ((parameters_[1] - ix) / parameters_[3]) + *((parameters_[1] - ix) / parameters_[3]) + 1.f; + float const argy = + ((parameters_[2] - iy) / parameters_[4]) + *((parameters_[2] - iy) / parameters_[4]) + 1.f; + + cauchy[iy*size_x + ix] = parameters_[0] / (argx * argy) + parameters_[5]; + } + } +} + +void LMFitCPP::calc_values_gauss2d(std::vector& gaussian) +{ + int const size_x = int(std::sqrt(float(info_.n_points_))); + int const size_y = size_x; + + for (int iy = 0; iy < size_y; iy++) + { + for (int ix = 0; ix < size_x; ix++) + { + float argx = (ix - parameters_[1]) * (ix - parameters_[1]) / (2 * parameters_[3] * parameters_[3]); + float argy = (iy - parameters_[2]) * (iy - parameters_[2]) / (2 * parameters_[3] * parameters_[3]); + float ex = exp(-(argx +argy)); + + gaussian[iy*size_x + ix] = parameters_[0] * ex + parameters_[4]; + } + } +} + +void LMFitCPP::calc_values_gauss2delliptic(std::vector& gaussian) +{ + int const size_x = int(std::sqrt(float(info_.n_points_))); + int const size_y = size_x; + for (int iy = 0; iy < size_y; iy++) + { + for (int ix = 0; ix < size_x; ix++) + { + float argx = (ix - parameters_[1]) * (ix - parameters_[1]) / (2 * parameters_[3] * parameters_[3]); + float argy = (iy - parameters_[2]) * (iy - parameters_[2]) / (2 * parameters_[4] * parameters_[4]); + float ex = exp(-(argx + argy)); + + gaussian[iy*size_x + ix] + = parameters_[0] * ex + parameters_[5]; + } + } +} + +void LMFitCPP::calc_values_gauss2drotated(std::vector& gaussian) +{ + int const size_x = int(std::sqrt(float(info_.n_points_))); + int const size_y = size_x; + + float amplitude = parameters_[0]; + float background = parameters_[5]; + float x0 = parameters_[1]; + float y0 = parameters_[2]; + float sig_x = parameters_[3]; + float sig_y = parameters_[4]; + float rot_sin = sin(parameters_[6]); + float rot_cos = cos(parameters_[6]); + + for (int iy = 0; iy < size_y; iy++) + { + for (int ix = 0; ix < size_x; ix++) + { + int const pixel_index = iy*size_x + ix; + + float arga = ((ix - x0) * rot_cos) - ((iy - y0) * rot_sin); + float argb = ((ix - x0) * rot_sin) + ((iy - y0) * rot_cos); + + float ex + = exp((-0.5f) * (((arga / sig_x) * (arga / sig_x)) + ((argb / sig_y) * (argb / sig_y)))); + + gaussian[pixel_index] = amplitude * ex + background; + } + } +} + +void LMFitCPP::calc_values_gauss1d(std::vector& gaussian) +{ + for (std::size_t ix = 0; ix < info_.n_points_; ix++) + { + float argx + = ((ix - parameters_[1])*(ix - parameters_[1])) + / (2 * parameters_[2] * parameters_[2]); + float ex = exp(-argx); + gaussian[ix] = parameters_[0] * ex + parameters_[3]; + } +} + +void LMFitCPP::calc_values_linear1d(std::vector& line) +{ + float * user_info_float = (float*)user_info_; + float x = 0.f; + for (std::size_t point_index = 0; point_index < info_.n_points_; point_index++) + { + if (!user_info_float) + { + x = float(point_index); + } + else if (info_.user_info_size_ / sizeof(float) == info_.n_points_) + { + x = user_info_float[point_index]; + } + else if (info_.user_info_size_ / sizeof(float) > info_.n_points_) + { + std::size_t const fit_begin = fit_index_ * info_.n_points_; + x = user_info_float[fit_begin + point_index]; + } + line[point_index] = parameters_[0] + parameters_[1] * x; + } +} + +void LMFitCPP::calc_curve_values(std::vector& curve, std::vector& derivatives) +{ + if (info_.model_id_ == GAUSS_1D) + { + calc_values_gauss1d(curve); + calc_derivatives_gauss1d(derivatives); + } + else if (info_.model_id_ == GAUSS_2D) + { + calc_values_gauss2d(curve); + calc_derivatives_gauss2d(derivatives); + } + else if (info_.model_id_ == GAUSS_2D_ELLIPTIC) + { + calc_values_gauss2delliptic(curve); + calc_derivatives_gauss2delliptic(derivatives); + } + else if (info_.model_id_ == GAUSS_2D_ROTATED) + { + calc_values_gauss2drotated(curve); + calc_derivatives_gauss2drotated(derivatives); + } + else if (info_.model_id_ == CAUCHY_2D_ELLIPTIC) + { + calc_values_cauchy2delliptic(curve); + calc_derivatives_cauchy2delliptic(derivatives); + } + else if (info_.model_id_ == LINEAR_1D) + { + calc_values_linear1d(curve); + calc_derivatives_linear1d(derivatives); + } +} + +void LMFitCPP::calculate_hessian( + std::vector const & derivatives, + std::vector const & curve) +{ + for (int jp = 0, jhessian = 0; jp < info_.n_parameters_; jp++) + { + if (parameters_to_fit_[jp]) + { + for (int ip = 0, ihessian = 0; ip < jp + 1; ip++) + { + if (parameters_to_fit_[ip]) + { + std::size_t const ijhessian + = ihessian * info_.n_parameters_to_fit_ + jhessian; + std::size_t const jihessian + = jhessian * info_.n_parameters_to_fit_ + ihessian; + std::size_t const derivatives_index_i = ip*info_.n_points_; + std::size_t const derivatives_index_j = jp*info_.n_points_; + + double sum = 0.0; + for (std::size_t pixel_index = 0; pixel_index < info_.n_points_; pixel_index++) + { + if (info_.estimator_id_ == LSE) + { + if (!weight_) + { + sum + += derivatives[derivatives_index_i + pixel_index] + * derivatives[derivatives_index_j + pixel_index]; + } + else + { + sum + += derivatives[derivatives_index_i + pixel_index] + * derivatives[derivatives_index_j + pixel_index] + * weight_[pixel_index]; + } + } + else if (info_.estimator_id_ == MLE) + { + sum + += data_[pixel_index] / (curve[pixel_index] * curve[pixel_index]) + * derivatives[derivatives_index_i + pixel_index] + * derivatives[derivatives_index_j + pixel_index]; + } + } + hessian_[ijhessian] = float(sum); + if (ijhessian != jihessian) + { + hessian_[jihessian] + = hessian_[ijhessian]; + } + ihessian++; + } + } + jhessian++; + } + } + +} + +void LMFitCPP::calc_gradient( + std::vector const & derivatives, + std::vector const & curve) +{ + + for (int ip = 0, gradient_index = 0; ip < info_.n_parameters_; ip++) + { + if (parameters_to_fit_[ip]) + { + std::size_t const derivatives_index = ip*info_.n_points_; + double sum = 0.0; + for (std::size_t pixel_index = 0; pixel_index < info_.n_points_; pixel_index++) + { + float deviant = data_[pixel_index] - curve[pixel_index]; + + if (info_.estimator_id_ == LSE) + { + if (!weight_) + { + sum + += deviant * derivatives[derivatives_index + pixel_index]; + } + else + { + sum + += deviant * derivatives[derivatives_index + pixel_index] * weight_[pixel_index]; + } + + } + else if (info_.estimator_id_ == MLE) + { + sum + += -derivatives[derivatives_index + pixel_index] * (1 - data_[pixel_index] / curve[pixel_index]); + } + } + gradient_[gradient_index] = float(sum); + gradient_index++; + } + } + +} + +void LMFitCPP::calc_chi_square( + std::vector const & values) +{ + double sum = 0.0; + for (size_t pixel_index = 0; pixel_index < values.size(); pixel_index++) + { + float deviant = values[pixel_index] - data_[pixel_index]; + if (info_.estimator_id_ == LSE) + { + if (!weight_) + { + sum += deviant * deviant; + } + else + { + sum += deviant * deviant * weight_[pixel_index]; + } + } + else if (info_.estimator_id_ == MLE) + { + if (values[pixel_index] <= 0.f) + { + (*state_) = 3; + return; + } + if (data_[pixel_index] != 0.f) + { + sum + += 2 * (deviant - data_[pixel_index] * logf(values[pixel_index] / data_[pixel_index])); + } + else + { + sum += 2 * deviant; + } + } + } + *chi_square_ = float(sum); +} + +void LMFitCPP::calc_curve_values() +{ + std::vector & curve = curve_; + std::vector & derivatives = derivatives_; + + calc_curve_values(curve, derivatives); +} + +void LMFitCPP::calc_coefficients() +{ + std::vector & curve = curve_; + std::vector & derivatives = derivatives_; + + calc_chi_square(curve); + + if ((*chi_square_) < prev_chi_square_ || prev_chi_square_ == 0) + { + calculate_hessian(derivatives, curve); + calc_gradient(derivatives, curve); + } +} + +void LMFitCPP::gauss_jordan() +{ + delta_ = gradient_; + + std::vector & alpha = modified_hessian_; + std::vector & beta = delta_; + + int icol, irow; + float big, dum, pivinv; + + std::vector indxc(info_.n_parameters_to_fit_, 0); + std::vector indxr(info_.n_parameters_to_fit_, 0); + std::vector ipiv(info_.n_parameters_to_fit_, 0); + + for (int kp = 0; kp < info_.n_parameters_to_fit_; kp++) + { + big = 0.0; + for (int jp = 0; jp < info_.n_parameters_to_fit_; jp++) + { + if (ipiv[jp] != 1) + { + for (int ip = 0; ip < info_.n_parameters_to_fit_; ip++) + { + if (ipiv[ip] == 0) + { + if (fabs(alpha[jp*info_.n_parameters_to_fit_ + ip]) >= big) + { + big = fabs(alpha[jp*info_.n_parameters_to_fit_ + ip]); + irow = jp; + icol = ip; + } + } + } + } + } + ++(ipiv[icol]); + + + if (irow != icol) + { + for (int ip = 0; ip < info_.n_parameters_to_fit_; ip++) + { + std::swap(alpha[irow*info_.n_parameters_to_fit_ + ip], alpha[icol*info_.n_parameters_to_fit_ + ip]); + } + std::swap(beta[irow], beta[icol]); + } + indxr[kp] = irow; + indxc[kp] = icol; + if (alpha[icol*info_.n_parameters_to_fit_ + icol] == 0.0) + { + (*state_) = 2; + break; + } + pivinv = 1.0f / alpha[icol*info_.n_parameters_to_fit_ + icol]; + alpha[icol*info_.n_parameters_to_fit_ + icol] = 1.0; + for (int ip = 0; ip < info_.n_parameters_to_fit_; ip++) + { + alpha[icol*info_.n_parameters_to_fit_ + ip] *= pivinv; + } + beta[icol] *= pivinv; + + for (int jp = 0; jp < info_.n_parameters_to_fit_; jp++) + { + if (jp != icol) + { + dum = alpha[jp*info_.n_parameters_to_fit_ + icol]; + alpha[jp*info_.n_parameters_to_fit_ + icol] = 0.0; + for (int ip = 0; ip < info_.n_parameters_to_fit_; ip++) + { + alpha[jp*info_.n_parameters_to_fit_ + ip] -= alpha[icol*info_.n_parameters_to_fit_ + ip] * dum; + } + beta[jp] -= beta[icol] * dum; + } + } + } +} + +void LMFitCPP::update_parameters() +{ + for (int parameter_index = 0, delta_index = 0; parameter_index < info_.n_parameters_; parameter_index++) + { + if (parameters_to_fit_[parameter_index]) + { + prev_parameters_[parameter_index] = parameters_[parameter_index]; + parameters_[parameter_index] = parameters_[parameter_index] + delta_[delta_index++]; + } + } +} + +bool LMFitCPP::check_for_convergence() +{ + bool const fit_found + = std::abs(*chi_square_ - prev_chi_square_) < std::max(tolerance_, tolerance_ * std::abs(*chi_square_)); + + return fit_found; +} + +void LMFitCPP::evaluate_iteration(int const iteration) +{ + bool const max_iterations_reached = iteration == info_.max_n_iterations_ - 1; + if (converged_ || max_iterations_reached) + { + (*n_iterations_) = iteration + 1; + if (!converged_) + { + (*state_) = 1; + } + } +} + +void LMFitCPP::prepare_next_iteration() +{ + if ((*chi_square_) < prev_chi_square_) + { + lambda_ *= 0.1f; + prev_chi_square_ = (*chi_square_); + } + else + { + lambda_ *= 10.f; + (*chi_square_) = prev_chi_square_; + for (int parameter_index = 0, delta_index = 0; parameter_index < info_.n_parameters_; parameter_index++) + { + if (parameters_to_fit_[parameter_index]) + { + parameters_[parameter_index] = prev_parameters_[parameter_index]; + } + } + } +} + +void LMFitCPP::modify_step_width() +{ + modified_hessian_ = hessian_; + size_t const n_parameters = (size_t)(sqrt((float)(hessian_.size()))); + for (size_t parameter_index = 0; parameter_index < n_parameters; parameter_index++) + { + modified_hessian_[parameter_index*n_parameters + parameter_index] + = modified_hessian_[parameter_index*n_parameters + parameter_index] + * (1.0f + (lambda_)); + } +} + +void LMFitCPP::run() +{ + for (int i = 0; i < info_.n_parameters_; i++) + parameters_[i] = initial_parameters_[i]; + + (*state_) = 0; + calc_curve_values(); + calc_coefficients(); + prev_chi_square_ = (*chi_square_); + + for (int iteration = 0; (*state_) == 0; iteration++) + { + modify_step_width(); + + gauss_jordan(); + + update_parameters(); + + calc_curve_values(); + calc_coefficients(); + + converged_ = check_for_convergence(); + + evaluate_iteration(iteration); + + prepare_next_iteration(); + + if (converged_ || (*state_) != 0) + { + break; + } + } +} diff --git a/Cpufit/matlab/CMakeLists.txt b/Cpufit/matlab/CMakeLists.txt new file mode 100644 index 0000000..46276bd --- /dev/null +++ b/Cpufit/matlab/CMakeLists.txt @@ -0,0 +1,62 @@ + +# MATLAB Cpufit binding + +find_package( Matlab COMPONENTS MX_LIBRARY ) + +if( NOT Matlab_FOUND ) + message( STATUS "Matlab and/or MX_Library NOT found - skipping Cpufit Matlab binding!" ) + return() +endif() + +# Matlab MEX FILE + +set( Headers + ) + +set( Sources + mex/CpufitMex.cpp + ) + +add_library( CpufitMex SHARED + ${Headers} + ${Sources} + ) +set_property( TARGET CpufitMex + PROPERTY SUFFIX .${Matlab_MEX_EXTENSION} ) +set_property( TARGET CpufitMex + PROPERTY RUNTIME_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}" ) + +target_include_directories( CpufitMex PRIVATE ${Matlab_INCLUDE_DIRS} ${PROJECT_SOURCE_DIR}) +target_link_libraries( CpufitMex Cpufit ${Matlab_LIBRARIES} ) + +if( WIN32 ) + SET(CMAKE_SHARED_LINKER_FLAGS "/export:mexFunction") +endif() + +add_matlab_launcher( CpufitMex "${CMAKE_CURRENT_SOURCE_DIR}" ) + +# MATLAB Cpufit + Gpufit PACKAGE + +set( build_directory "${CMAKE_BINARY_DIR}/${CMAKE_CFG_INTDIR}/matlab" ) +set( package_files + "${CMAKE_CURRENT_SOURCE_DIR}/cpufit.m" +) +set( binary_gpufit $ ) +set( binary_mex $ ) + +add_custom_target( MATLAB_CPUFIT_GPUFIT_PACKAGE ALL + COMMAND ${CMAKE_COMMAND} -E + make_directory ${build_directory} + COMMAND ${CMAKE_COMMAND} -E + copy_if_different ${package_files} ${build_directory} + COMMAND ${CMAKE_COMMAND} -E + copy_if_different ${binary_gpufit} ${build_directory} + COMMAND ${CMAKE_COMMAND} -E + copy_if_different ${binary_mex} ${build_directory} + COMMENT "Adding Cpufit to Matlab package" +) +set_property( TARGET MATLAB_CPUFIT_GPUFIT_PACKAGE PROPERTY FOLDER CMakePredefinedTargets ) +add_dependencies( MATLAB_CPUFIT_GPUFIT_PACKAGE MATLAB_GPUFIT_PACKAGE Cpufit CpufitMex ) + +# add launcher + diff --git a/Cpufit/matlab/README.md b/Cpufit/matlab/README.md new file mode 100644 index 0000000..a2dc84c --- /dev/null +++ b/Cpufit/matlab/README.md @@ -0,0 +1,3 @@ +Matlab binding for Cpufit, the control CPU implementation of +the [Gpufit library](https://github.com/gpufit/Gpufit) which +implements Levenberg Marquardt curve fitting in CUDA \ No newline at end of file diff --git a/Cpufit/matlab/cpufit.m b/Cpufit/matlab/cpufit.m new file mode 100644 index 0000000..243c654 --- /dev/null +++ b/Cpufit/matlab/cpufit.m @@ -0,0 +1,119 @@ +function [parameters, states, chi_squares, n_iterations, time]... + = cpufit(data, weights, model_id, initial_parameters, tolerance, max_n_iterations, parameters_to_fit, estimator_id, user_info) +% Wrapper around the Cpufit mex file. +% +% Optional arguments can be given as empty matrix []. +% +% Default values as specified + +%% size checks + +% number of input parameter (variable) +if nargin < 9 + user_info = []; + if nargin < 8 + estimator_id = []; + if nargin < 7 + parameters_to_fit = []; + if nargin < 6 + max_n_iterations = []; + if nargin < 5 + tolerance = []; + assert(nargin == 4, 'Not enough parameters'); + end + end + end + end +end + +% data is 2D and read number of points and fits +data_size = size(data); +assert(length(data_size) == 2, 'data is not two-dimensional'); +n_points = data_size(1); +n_fits = data_size(2); + +% consistency with weights (if given) +if ~isempty(weights) + assert(isequal(data_size, size(weights)), 'Dimension mismatch between data and weights') +end + +% initial parameters is 2D and read number of parameters +initial_parameters_size = size(initial_parameters); +assert(length(initial_parameters_size) == 2, 'initial_parameters is not two-dimensional'); +n_parameters = initial_parameters_size(1); +assert(n_fits == initial_parameters_size(2), 'Dimension mismatch in number of fits between data and initial_parameters'); + +% consistency with parameters_to_fit (if given) +if ~isempty(parameters_to_fit) + assert(size(parameters_to_fit, 1) == n_parameters, 'Dimension mismatch in number of parameters between initial_parameters and parameters_to_fit'); +end + +%% default values + +% tolerance +if isempty(tolerance) + tolerance = 1e-4; +end + +% max_n_iterations +if isempty(max_n_iterations) + max_n_iterations = 25; +end + +% estimator_id +if isempty(estimator_id) + estimator_id = EstimatorID.LSE; +end + +% parameters_to_fit +if isempty(parameters_to_fit) + parameters_to_fit = ones(n_parameters, 1, 'int32'); +end + +% now only weights and user_info could be not given (empty matrix) + +%% type checks + +% data, weights (if given), initial_parameters are all single +assert(isa(data, 'single'), 'Type of data is not single'); +if ~isempty(weights) + assert(isa(weights, 'single'), 'Type of weights is not single'); +end +assert(isa(initial_parameters, 'single'), 'Type of initial_parameters is not single'); + +% parameters_to_fit is int32 (cast to int32 if incorrect type) +if ~isa(parameters_to_fit, 'int32') + parameters_to_fit = int32(parameters_to_fit); +end + +% max_n_iterations must be int32 (cast if incorrect type) +if ~isa(max_n_iterations, 'int32') + max_n_iterations = int32(max_n_iterations); +end + +% tolerance must be single (cast if incorrect type) +if ~isa(tolerance, 'single') + tolerance = single(tolerance); +end + +% we don't check type of user_info, but we extract the size in bytes of it +if ~isempty(user_info) + user_info_info = whos('user_info'); + user_info_size = user_info_info.bytes; +else + user_info_size = 0; +end + + +%% run Cpufit taking the time +tic; +[parameters, states, chi_squares, n_iterations] ... + = CpufitMex(data, weights, n_fits, n_points, tolerance, max_n_iterations, estimator_id, initial_parameters, parameters_to_fit, model_id, n_parameters, user_info, user_info_size); + +time = toc; + +% reshape the output parameters array to have dimensions +% (n_parameters,n_fits) +parameters = reshape(parameters,n_parameters,n_fits); + +end diff --git a/Cpufit/matlab/examples/gauss2d.m b/Cpufit/matlab/examples/gauss2d.m new file mode 100644 index 0000000..3cb91f0 --- /dev/null +++ b/Cpufit/matlab/examples/gauss2d.m @@ -0,0 +1,182 @@ +function gauss2d() +% Example of the Matlab binding of the Gpufit library implementing +% Levenberg Marquardt curve fitting in C/C++ +% https://github.com/gpufit/Gpufit +% +% Multiple fits of a 2D Gaussian peak function with Poisson distributed noise +% http://gpufit.readthedocs.io/en/latest/bindings.html#matlab + +% perform some 2D Gaussian peak fits with a symmetrical Gaussian peak +fit_gauss2d(); + +% perform some 2D Gaussian peak fits with an asymmetrical, rotated Gaussian peak +fit_gauss2d_rotated(); + +end +function fit_gauss2d() + +%% number of fits and fit points +number_fits = 1e4; +size_x = 20; +number_parameters = 5; + +%% set input arguments + +% true parameters +true_parameters = single([20, 9.5, 9.5, 3, 10]); + +% initialize random number generator +rng(0); + +% initial parameters (randomized) +initial_parameters = repmat(single(true_parameters'), [1, number_fits]); +% randomize relative to width for positions +initial_parameters([2,3], :) = initial_parameters([2,3], :) + true_parameters(4) * (-0.2 + 0.4 * rand(2, number_fits)); +% randomize relative for other parameters +initial_parameters([1,4,5], :) = initial_parameters([1,4,5], :) .* (0.8 + 0.4 * rand(3, number_fits)); + +% generate x and y values +g = single(0 : size_x - 1); +[x, y] = ndgrid(g, g); + +% generate data with Poisson noise +data = gaussian_2d(x, y, true_parameters); +data = repmat(data(:), [1, number_fits]); +data = poissrnd(data); + +% tolerance +tolerance = 1e-3; + +% maximum number of iterations +max_n_iterations = 20; + +% estimator id +estimator_id = EstimatorID.MLE; + +% model ID +model_id = ModelID.GAUSS_2D; + +%% run Cpufit +[parameters, states, chi_squares, n_iterations, time] = cpufit(data, [], ... + model_id, initial_parameters, tolerance, max_n_iterations, [], estimator_id, []); + +%% displaying results +display_results('2D Gaussian peak', model_id, number_fits, number_parameters, size_x, time, true_parameters, parameters, states, chi_squares, n_iterations); + +end + +function fit_gauss2d_rotated() + +%% number of fits and fit points +number_fits = 1e4; +size_x = 20; +number_parameters = 7; + +%% set input arguments + +% true parameters +true_parameters = single([200, 9.5, 9.5, 3, 4, 10, 0.5]); + +% initialize random number generator +rng(0); + +% initial parameters (randomized) +initial_parameters = repmat(single(true_parameters'), [1, number_fits]); +% randomize relative to width for positions +initial_parameters(2, :) = initial_parameters(2, :) + true_parameters(4) * (-0.2 + 0.4 * rand(1, number_fits)); +initial_parameters(3, :) = initial_parameters(3, :) + true_parameters(5) * (-0.2 + 0.4 * rand(1, number_fits)); +% randomize relative for other parameters +initial_parameters([1,4,5,6,7], :) = initial_parameters([1,4,5,6,7], :) .* (0.8 + 0.4 * rand(5, number_fits)); + +% generate x and y values +g = single(0 : size_x - 1); +[x, y] = ndgrid(g, g); + +% generate data with Poisson noise +data = gaussian_2d_rotated(x, y, true_parameters); +data = repmat(data(:), [1, number_fits]); +data = poissrnd(data); + +% tolerance +tolerance = 1e-3; + +% maximum number of iterations +max_n_iterations = 20; + +% estimator id +estimator_id = EstimatorID.MLE; + +% model ID +model_id = ModelID.GAUSS_2D_ROTATED; + +%% run Cpufit +[parameters, states, chi_squares, n_iterations, time] = cpufit(data, [], ... + model_id, initial_parameters, tolerance, max_n_iterations, [], estimator_id, []); + +%% displaying results +display_results('2D rotated Gaussian peak', model_id, number_fits, number_parameters, size_x, time, true_parameters, parameters, states, chi_squares, n_iterations); + + +end + +function g = gaussian_2d(x, y, p) +% Generates a 2D Gaussian peak. +% http://gpufit.readthedocs.io/en/latest/api.html#gauss-2d +% +% x,y - x and y grid position values +% p - parameters (amplitude, x,y center position, width, offset) + +g = p(1) * exp(-((x - p(2)).^2 + (y - p(3)).^2) / (2 * p(4)^2)) + p(5); + +end + +function g = gaussian_2d_rotated(x, y, p) +% Generates a 2D rotated elliptic Gaussian peak. +% http://gpufit.readthedocs.io/en/latest/api.html#d-rotated-elliptic-gaussian-peak +% +% x,y - x and y grid position values +% p - parameters (amplitude, x,y center position, width, offset) + +% cosine and sine of rotation angle +cp = cos(p(7)); +sp = sin(p(7)); + +% Gaussian peak with two axes +arga = (x - p(2)) .* cp - (y - p(3)) .* sp; +argb = (x - p(2)) .* sp + (y - p(3)) .* cp; +ex = exp(-0.5 .* (((arga / p(4)) .* (arga / p(4))) + ((argb / p(5)) .* (argb / p(5))))); +g = p(1) .* ex + p(6); + +end + +function display_results(name, model_id, number_fits, number_parameters, size_x, time, true_parameters, parameters, states, chi_squares, n_iterations) + +%% displaying results +converged = states == 0; +fprintf('\nCpufit of %s\n', name); + +% print summary +fprintf('\nmodel ID: %d\n', model_id); +fprintf('number of fits: %d\n', number_fits); +fprintf('fit size: %d x %d\n', size_x, size_x); +fprintf('mean chi-square: %6.2f\n', mean(chi_squares(converged))); +fprintf('mean iterations: %6.2f\n', mean(n_iterations(converged))); +fprintf('time: %6.2f s\n', time); + +% get fit states +number_converged = sum(converged); +fprintf('\nratio converged %6.2f %%\n', number_converged / number_fits * 100); +fprintf('ratio max it. exceeded %6.2f %%\n', sum(states == 1) / number_fits * 100); +fprintf('ratio singular hessian %6.2f %%\n', sum(states == 2) / number_fits * 100); +fprintf('ratio neg curvature MLE %6.2f %%\n', sum(states == 3) / number_fits * 100); + +% mean and std of fitted parameters +converged_parameters = parameters(:, converged); +converged_parameters_mean = mean(converged_parameters, 2); +converged_parameters_std = std(converged_parameters, [], 2); +fprintf('\nparameters of %s\n', name); +for i = 1 : number_parameters + fprintf('p%d true %6.2f mean %6.2f std %6.2f\n', i, true_parameters(i), converged_parameters_mean(i), converged_parameters_std(i)); +end + +end \ No newline at end of file diff --git a/Cpufit/matlab/examples/gauss2d_plot.m b/Cpufit/matlab/examples/gauss2d_plot.m new file mode 100644 index 0000000..8d34707 --- /dev/null +++ b/Cpufit/matlab/examples/gauss2d_plot.m @@ -0,0 +1,117 @@ +function gauss2d_plot() +% Example of the Matlab binding of the Gpufit library implementing +% Levenberg Marquardt curve fitting in C/C++ +% https://github.com/gpufit/Gpufit +% +% Multiple fits of a 2D Gaussian peak function with Poisson distributed noise +% repeated for a different total number of fits each time and plotting the +% results +% http://gpufit.readthedocs.io/en/latest/bindings.html#matlab + +%% number of fit points +size_x = 5; +n_points = size_x * size_x; + +%% set input arguments + +% mean true parameters +mean_true_parameters = single([100, 3, 3, 1, 10]); + +% average noise level +average_noise_level = 10; + +% initialize random number generator +rng(0); + +% tolerance +tolerance = 1e-4; + +% max number of itetations +max_n_iterations = 10; + +% model id +model_id = ModelID.GAUSS_2D; + +%% loop over different number of fits +n_fits_all = round(logspace(2, 6, 20)); + +% generate x and y values +g = single(0 : size_x - 1); +[x, y] = ndgrid(g, g); + +% loop +speed = zeros(length(n_fits_all), 1); +for i = 1:length(n_fits_all) + n_fits = n_fits_all(i); + + % vary positions of 2D Gaussians peaks slightly + test_parameters = repmat(mean_true_parameters', [1, n_fits]); + test_parameters([2,3], :) = test_parameters([2,3], :) + mean_true_parameters(4) * (-0.2 + 0.4 * rand(2, n_fits)); + + % generate data + data = gaussians_2d(x, y, test_parameters); + data = reshape(data, [n_points, n_fits]); + + % add noise + data = data + average_noise_level * randn(size(data), 'single'); + + % initial parameters (randomized) + initial_parameters = repmat(mean_true_parameters', [1, n_fits]); + % randomize relative to width for positions + initial_parameters([2,3], :) = initial_parameters([2,3], :) + mean_true_parameters(4) * (-0.2 + 0.4 * rand(2, n_fits)); + % randomize relative for other parameters + initial_parameters([1,4,5], :) = initial_parameters([1,4,5], :) .* (0.8 + 0.4 * rand(3, n_fits)); + + % run Cpufit + [parameters, states, chi_squares, n_iterations, time] = cpufit(data, [], ... + model_id, initial_parameters, tolerance, max_n_iterations); + + % analyze result + converged = states == 0; + speed(i) = n_fits / time; + precision_x0 = std(parameters(2, converged) - test_parameters(2, converged)); + + % display result + fprintf(' iterations: %.2f | time: %.3f s | speed: %8.0f fits/s\n', ... + mean(n_iterations(converged)), time, speed(i)); +end + +%% plot +figure(); +semilogx(n_fits_all, speed, 'bo-') +xlabel('number of fits per function call') +ylabel('fits per second') +legend('Cpufit', 'Location', 'NorthWest') +grid on; +xlim(n_fits_all([1,end])); + +end + +function g = gaussians_2d(x, y, p) +% Generates many 2D Gaussians peaks for a given set of parameters + +n_fits = size(p, 2); +msg = sprintf('generating %d fits ', n_fits); +fprintf(msg); + +g = zeros([size(x), n_fits], 'single'); + +progress = 0; +L = 50; % length of progressbar +l = 0; +for i = 1 : n_fits + + pi = p(:, i); + g(:, :, i) = pi(1) * exp(-((x - pi(2)).^2 + (y - pi(3)).^2) / (2 * pi(4)^2)) + pi(5); + + progress = progress + 1; + if progress >= n_fits / L + progress = 0; + fprintf('|'); + l = l + 1; + end +end +fprintf(repmat('\b', [1, length(msg) + l])); +fprintf('%7d fits', n_fits); + +end diff --git a/Cpufit/matlab/mex/CpufitMex.cpp b/Cpufit/matlab/mex/CpufitMex.cpp new file mode 100644 index 0000000..3a10184 --- /dev/null +++ b/Cpufit/matlab/mex/CpufitMex.cpp @@ -0,0 +1,145 @@ +#include "Cpufit/cpufit.h" + +#include + +#include +#include + +/* + Get a arbitrary scalar (non complex) and check for class id. + https://www.mathworks.com/help/matlab/apiref/mxclassid.html +*/ +template inline bool get_scalar(const mxArray *p, T &v, const mxClassID id) +{ + if (mxIsNumeric(p) && !mxIsComplex(p) && mxGetNumberOfElements(p) == 1 && mxGetClassID(p) == id) + { + v = *static_cast(mxGetData(p)); + return true; + } + else { + return false; + } +} + +void mexFunction(int nlhs, mxArray *plhs[], int nrhs, mxArray const *prhs[]) +{ + int expected_nrhs = 0; + int expected_nlhs = 0; + bool wrong_nrhs = false; + bool wrong_nlhs = false; + + expected_nrhs = 13; + expected_nlhs = 4; + if (nrhs != expected_nrhs) + { + wrong_nrhs = true; + } + else if (nlhs != expected_nlhs) + { + wrong_nlhs = true; + } + + if (wrong_nrhs || wrong_nlhs) + { + if (nrhs != expected_nrhs) + { + char s1[50]; + _itoa_s(expected_nrhs, s1, 10); + char const s2[] = " input arguments required."; + size_t const string_length = strlen(s1) + 1 + strlen(s2); + strcat_s(s1, string_length, s2); + mexErrMsgIdAndTxt("Cpufit:Mex", s1); + } + else if (nlhs != expected_nlhs) + { + char s1[50]; + _itoa_s(expected_nlhs, s1, 10); + char const s2[] = " output arguments required."; + size_t const string_length = strlen(s1) + 1 + strlen(s2); + strcat_s(s1, string_length, s2); + mexErrMsgIdAndTxt("Cpufit:Mex", s1); + } + } + + // input parameters + float * data = (float*)mxGetPr(prhs[0]); + float * weights = (float*)mxGetPr(prhs[1]); + std::size_t n_fits = (std::size_t)*mxGetPr(prhs[2]); + std::size_t n_points = (std::size_t)*mxGetPr(prhs[3]); + + // tolerance + float tolerance = 0; + if (!get_scalar(prhs[4], tolerance, mxSINGLE_CLASS)) + { + mexErrMsgIdAndTxt("Cpufit:Mex", "tolerance is not a single"); + } + + // max_n_iterations + int max_n_iterations = 0; + if (!get_scalar(prhs[5], max_n_iterations, mxINT32_CLASS)) + { + mexErrMsgIdAndTxt("Cpufit:Mex", "max_n_iteration is not int32"); + } + + int estimator_id = (int)*mxGetPr(prhs[6]); + float * initial_parameters = (float*)mxGetPr(prhs[7]); + int * parameters_to_fit = (int*)mxGetPr(prhs[8]); + int model_id = (int)*mxGetPr(prhs[9]); + int n_parameters = (int)*mxGetPr(prhs[10]); + int * user_info = (int*)mxGetPr(prhs[11]); + std::size_t user_info_size = (std::size_t)*mxGetPr(prhs[12]); + + // output parameters + float * output_parameters; + mxArray * mx_parameters; + mx_parameters = mxCreateNumericMatrix(1, n_fits*n_parameters, mxSINGLE_CLASS, mxREAL); + output_parameters = (float*)mxGetData(mx_parameters); + plhs[0] = mx_parameters; + + int * output_states; + mxArray * mx_states; + mx_states = mxCreateNumericMatrix(1, n_fits, mxINT32_CLASS, mxREAL); + output_states = (int*)mxGetData(mx_states); + plhs[1] = mx_states; + + float * output_chi_squares; + mxArray * mx_chi_squares; + mx_chi_squares = mxCreateNumericMatrix(1, n_fits, mxSINGLE_CLASS, mxREAL); + output_chi_squares = (float*)mxGetData(mx_chi_squares); + plhs[2] = mx_chi_squares; + + int * output_n_iterations; + mxArray * mx_n_iterations; + mx_n_iterations = mxCreateNumericMatrix(1, n_fits, mxINT32_CLASS, mxREAL); + output_n_iterations = (int*)mxGetData(mx_n_iterations); + plhs[3] = mx_n_iterations; + + // call to gpufit + int const status + = cpufit + ( + n_fits, + n_points, + data, + weights, + model_id, + initial_parameters, + tolerance, + max_n_iterations, + parameters_to_fit, + estimator_id, + user_info_size, + reinterpret_cast< char * >( user_info ), + output_parameters, + output_states, + output_chi_squares, + output_n_iterations + ) ; + + // check status + if (status != STATUS_OK) + { + std::string const error = cpufit_get_last_error() ; + mexErrMsgIdAndTxt( "Cpufit:Mex", error.c_str() ) ; + } +} diff --git a/Gpufit/CMakeLists.txt b/Gpufit/CMakeLists.txt new file mode 100644 index 0000000..76da81e --- /dev/null +++ b/Gpufit/CMakeLists.txt @@ -0,0 +1,160 @@ + +# CUDA +# +# Uses the following variables: +# +# CUDA_ARCHITECTURES (Default All) +# -- Argument passed to CUDA_SELECT_NVCC_ARCH_FLAGS(...) +# resulting in code_generation_flags +# (see http://cmake.org/cmake/help/v3.7/module/FindCUDA.html). +# CUDA_ARCHITECTURES: Auto | Common | All | ARCH_AND_PTX ... +# Auto: Detects local machine GPU architecture. +# Common: Covers common subset of architectures. +# All: Covers all known architectures. +# ARCH_AND_PTX: NAME | NUM.NUM | NUM.NUM(NUM.NUM) | NUM.NUM+PTX +# NAME: Fermi Kepler Maxwell Kepler+Tegra Kepler+Tesla Maxwell+Tegra Pascal +# NUM: Any number. +# Only those pairs are currently accepted by NVCC though: +# 2.0 2.1 3.0 3.2 3.5 3.7 5.0 5.2 5.3 6.0 6.2 +# Examples: +# 2.1(2.0) results in +# -gencode;arch=compute_20,code=sm_21 +# Kepler+Tesla results in +# -gencode;arch=compute_37,code=sm_37 +# 6.2+PTX results in +# -gencode;arch=compute_62,code=sm_62;-gencode;arch=compute_62,code=compute_62 +# +# CUDA_NVCC_FLAGS (Default ${code_generation_flags}) +# -- Additional NVCC command line arguments +# (see http://cmake.org/cmake/help/v3.7/module/FindCUDA.html). +# NOTE that multiple arguments must be semi-colon delimited +# (e.g. --compiler-options;-Wall) +# +# Multiple CUDA versions installed, specify which version to use +# Set CUDA_BIN_PATH before running CMake or CUDA_TOOLKIT_ROOT_DIR after first configuration +# to installation folder of desired CUDA version + +find_package( CUDA 6.5 REQUIRED ) + +set( CUDA_ARCHITECTURES All CACHE STRING + "Auto | Common | All | ... see CUDA_SELECT_NVCC_ARCH_FLAGS(...)" ) + +if( CUDA_ARCHITECTURES STREQUAL Auto ) + set( file ${PROJECT_BINARY_DIR}/detect_cuda_architectures.cpp ) + file( WRITE ${file} "" + "#include \n" + "#include \n" + "int main()\n" + "{\n" + " int count = 0;\n" + " if (cudaSuccess != cudaGetDeviceCount(&count)) return -1;\n" + " if (count == 0) return -1;\n" + " for (int device = 0; device < count; ++device)\n" + " {\n" + " cudaDeviceProp prop;\n" + " if (cudaSuccess == cudaGetDeviceProperties(&prop, device))\n" + " std::printf(\"%d.%d \", prop.major, prop.minor);\n" + " }\n" + " return 0;\n" + "}\n" + ) + try_run( run_result compile_result ${PROJECT_BINARY_DIR} ${file} + CMAKE_FLAGS "-DINCLUDE_DIRECTORIES=${CUDA_INCLUDE_DIRS}" + LINK_LIBRARIES ${CUDA_LIBRARIES} + RUN_OUTPUT_VARIABLE architectures + ) + if( run_result EQUAL 0 ) + string( REPLACE "2.1" "2.1(2.0)" architectures "${architectures}" ) + if( CUDA_VERSION VERSION_LESS "7.0" ) + string( REGEX REPLACE "3\\.[27]|5\\.[23]|6\\.[01]" "5.2+PTX" architectures "${architectures}" ) + elseif( CUDA_VERSION VERSION_LESS "8.0" ) + string( REGEX REPLACE "5\\.3|6\\.[01]" "5.3+PTX" architectures "${architectures}" ) + endif() + set( CUDA_ARCHITECTURES "${architectures}" ) + endif() +elseif( CUDA_ARCHITECTURES STREQUAL All ) +# All does not include the latest PTX! + set( CUDA_ARCHITECTURES "2.1(2.0)" "3.0" "3.5" "5.0" "5.2" ) + if( CUDA_VERSION VERSION_GREATER "6.5" ) + list( APPEND CUDA_ARCHITECTURES "3.2" "3.7" "5.3" ) + endif() + if( CUDA_VERSION VERSION_GREATER "7.5" ) + list( APPEND CUDA_ARCHITECTURES "6.0" "6.1" ) + endif() + string( APPEND CUDA_ARCHITECTURES "+PTX" ) +endif() +CUDA_SELECT_NVCC_ARCH_FLAGS( code_generation_flags "${CUDA_ARCHITECTURES}" ) +list( APPEND CUDA_NVCC_FLAGS ${code_generation_flags} ) +message( STATUS "CUDA_NVCC_FLAGS=${code_generation_flags}" ) + +# Gpufit + +set( GpuHeaders + gpufit.h + definitions.h + info.h + lm_fit.h + interface.h +) + +set( GpuSources + gpufit.cpp + info.cpp + lm_fit.cpp + lm_fit_cuda.cpp + interface.cpp + gpufit.def +) + +set( GpuCudaHeaders + linear_1d.cuh + gauss_1d.cuh + gauss_2d.cuh + gauss_2d_rotated.cuh + gauss_2d_elliptic.cuh + cauchy_2d_elliptic.cuh + lse.cuh + mle.cuh + cuda_gaussjordan.cuh + cuda_kernels.cuh + gpu_data.cuh +) + +set( GpuCudaSources + lm_fit_cuda.cu + cuda_gaussjordan.cu + cuda_kernels.cu + info.cu + gpu_data.cu +) + +source_group("CUDA Source Files" FILES ${GpuCudaSources}) +source_group("CUDA Header Files" FILES ${GpuCudaHeaders}) + +cuda_add_library( Gpufit SHARED + ${GpuHeaders} + ${GpuSources} + ${GpuCudaHeaders} + ${GpuCudaSources} +) + +set_property( TARGET Gpufit + PROPERTY RUNTIME_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}" ) + +#install( TARGETS Gpufit RUNTIME DESTINATION bin ) + +# Examples + +add_subdirectory( examples ) + +# Tests + +if( BUILD_TESTING ) + add_subdirectory( tests ) +endif() + +# Bindings + +add_subdirectory( matlab ) +add_subdirectory( python ) + diff --git a/Gpufit/Gpufit.def b/Gpufit/Gpufit.def new file mode 100644 index 0000000..0e3b9db --- /dev/null +++ b/Gpufit/Gpufit.def @@ -0,0 +1,7 @@ +LIBRARY "Gpufit" +EXPORTS + gpufit @1 + gpufit_get_last_error @2 + gpufit_get_cuda_version @3 + gpufit_cuda_available @4 + gpufit_portable_interface @5 \ No newline at end of file diff --git a/Gpufit/cauchy_2d_elliptic.cuh b/Gpufit/cauchy_2d_elliptic.cuh new file mode 100644 index 0000000..b1c2a4e --- /dev/null +++ b/Gpufit/cauchy_2d_elliptic.cuh @@ -0,0 +1,107 @@ +#ifndef GPUFIT_CAUCHY2DELLIPTIC_CUH_INCLUDED +#define GPUFIT_CAUCHY2DELLIPTIC_CUH_INCLUDED + +/* Description of the calculate_cauchy2delliptic function +* ======================================================= +* +* This function calculates the values of two-dimensional elliptic cauchy model +* functions and their partial derivatives with respect to the model parameters. +* +* No independent variables are passed to this model function. Hence, the +* (X, Y) coordinate of the first data value is assumed to be (0.0, 0.0). For +* a fit size of M x N data points, the (X, Y) coordinates of the data are +* simply the corresponding array index values of the data array, starting from +* zero. +* +* Parameters: +* +* parameters: An input vector of concatenated sets of model parameters. +* p[0]: amplitude +* p[1]: center coordinate x +* p[2]: center coordinate y +* p[3]: width x (standard deviation) +* p[4]: width y (standard deviation) +* p[5]: offset +* +* n_fits: The number of fits. (not used) +* +* n_points: The number of data points per fit. +* +* n_parameters: The number of model parameters. +* +* values: An output vector of concatenated sets of model function values. +* +* derivatives: An output vector of concatenated sets of model function partial +* derivatives. +* +* chunk_index: The chunk index. (not used) +* +* user_info: An input vector containing user information. (not used) +* +* user_info_size: The number of elements in user_info. (not used) +* +* Calling the calculate_cauchy2delliptic function +* =============================================== +* +* This __device__ function can be only called from a __global__ function or an other +* __device__ function. When calling the function, the blocks and threads of the __global__ +* function must be set up correctly, as shown in the following example code. +* +* dim3 threads(1, 1, 1); +* dim3 blocks(1, 1, 1); +* +* threads.x = n_points * n_fits_per_block; +* blocks.x = n_fits / n_fits_per_block; +* +* global_function<<< blocks,threads >>>(parameter1, ...); +* +*/ + +__device__ void calculate_cauchy2delliptic( + float const * parameters, + int const n_fits, + int const n_points, + int const n_parameters, + float * values, + float * derivatives, + int const chunk_index, + char * user_info, + std::size_t const user_info_size) +{ + int const n_points_x = sqrt((float)n_points); + int const n_fits_per_block = blockDim.x / n_points; + int const fit_in_block = threadIdx.x / n_points; + int const point_index = threadIdx.x - (fit_in_block*n_points); + int const fit_index = blockIdx.x*n_fits_per_block + fit_in_block; + + int const point_index_y = point_index / n_points_x; + int const point_index_x = point_index - (point_index_y*n_points_x); + + float* current_value = &values[fit_index*n_points]; + float const * p = ¶meters[fit_index*n_parameters]; + + float const argx = ((p[1] - point_index_x) / p[3]) *((p[1] - point_index_x) / p[3]) + 1; + float const argy = ((p[2] - point_index_y) / p[4]) *((p[2] - point_index_y) / p[4]) + 1; + current_value[point_index] = p[0] * 1 / argx * 1 / argy + p[5]; + + ////////////////////////////////////////////////////////////////////////////// + + float * current_derivative = &derivatives[fit_index * n_points*n_parameters]; + + current_derivative[0 * n_points + point_index] + = 1 / (argx*argy); + current_derivative[1 * n_points + point_index] + = -2 * p[0] * (p[1] - point_index_x) * 1 / (p[3] * p[3] * argx*argx*argy); + current_derivative[2 * n_points + point_index] + = -2 * p[0] * (p[2] - point_index_y) * 1 / (p[4] * p[4] * argy*argy*argx); + current_derivative[3 * n_points + point_index] + = 2 * p[0] * (p[1] - point_index_x) * (p[1] - point_index_x) + / (p[3] * p[3] * p[3] * argx * argx * argy); + current_derivative[4 * n_points + point_index] + = 2 * p[0] * (p[2] - point_index_y) * (p[2] - point_index_y) + / (p[4] * p[4] * p[4] * argy * argy * argx); + current_derivative[5 * n_points + point_index] + = 1; +} + +#endif diff --git a/Gpufit/cuda_gaussjordan.cu b/Gpufit/cuda_gaussjordan.cu new file mode 100644 index 0000000..c6519bc --- /dev/null +++ b/Gpufit/cuda_gaussjordan.cu @@ -0,0 +1,279 @@ +/* CUDA implementation of Gauss-Jordan elimination algorithm. +* +* Gauss-Jordan elimination method +* =============================== +* +* This function solves a set of linear equations using the Gauss-Jordan elimination method. +* Considering a set of N equations with N unknowns, this can be written in matrix form as +* an NxN matrix of coefficients and a Nx1 column vector of right-hand side values. +* +* For example, consider the following problem with 3 equations and 3 unknowns (N=3): +* +* A x + B y + C z = MM +* D x + E y + F z = NN +* G x + H y + J z = PP +* +* We can write this as follows in matrix form: +* +* [ A B C ] [ x ] = [ MM ] +* [ D E F ] [ y ] = [ NN ] +* [ G H I ] [ z ] = [ PP ] +* +* or, [A]*[X] = [B] where [A] is the matrix of coefficients and [B] is the vector of +* right-hand side values. +* +* The Gauss Jordan elimiation method solves the system of equations in the following +* manner. First, we form the augmented matrix (A|B): +* +* [ A B C | MM ] +* [ D E F | NN ] +* [ G H I | PP ] +* +* and then the augmented matrix is manipulated until its left side has the reduced +* row-echelon form. That is to say that any individual row may be multiplied +* by a scalar factor, and any linear combination of rows may be added to another +* row. Finally, two rows may be swapped without affecting the solution. +* +* When the manipulations are complete and the left side of the matrix has the desired +* form, the right side then corresponds to the solution of the system. +* +* +* Description of the cuda_gaussjordan function +* ============================================ +* +* This algorithm is designed to perform many solutions of the Gauss Jordan elimination +* method in parallel. One limitation of the algorithm implemented here is that for +* each solution the number of equations and unknowns (N) must be identical. +* +* Parameters: +* +* alpha: Coefficients matrices. The matrix of coefficients for a single solution is +* a vector of NxN, where N is the number of equations. This array stores the +* coefficients for the entire set of M input problems, concatenated end to end, +* and hence the total size of the array is MxNxN. +* +* beta: Vector of right hand side values, concatenated together for all input problems. +* For a set of M inputs, the size of the vector is MxN. Upon completion, this +* vector contains the results vector X for each solution. +* +* skip_calculation: An input vector which allows the calculation to be skipped for +* a particular solution. For a set of M inputs, the size of this +* vector is M. +* +* singular: An output vector used to report whether a given solution is singular. For +* a set of M inputs, this vector has size M. Memory needs to be allocated +* by the calling the function. +* +* n_equations: The number of equations and unknowns for a single solution. This is +* equal to the size N. +* +* n_equations_pow2: The next highest power of 2 greater than n_equations. +* +* +* Calling the cuda_gaussjordan function +* ===================================== +* +* When calling the function, the blocks and threads must be set up correctly, as well +* as the shared memory space, as shown in the following example code. +* +* dim3 threads(1, 1, 1); +* dim3 blocks(1, 1, 1); +* +* threads.x = n_equations + 1; +* threads.y = n_equations; +* blocks.x = n_solutions; +* blocks.y = 1; +* +* int const shared_size = sizeof(float) * +* ( (threads.x * threads.y) + n_parameters_pow2 + n_parameters_pow2 ); +* +* int * singular; +* CUDA_CHECK_STATUS(cudaMalloc((void**)&singular, n_solutions * sizeof(int))); +* +* cuda_gaussjordan<<< blocks, threads, shared_size >>>( +* alpha, +* beta, +* skip_calculation, +* singular, +* n_equations, +* n_equations_pow2); +* +*/ + +#include "cuda_gaussjordan.cuh" + +__global__ void cuda_gaussjordan( + float * delta, + float const * beta, + float const * alpha, + int const * skip_calculation, + int * singular, + std::size_t const n_equations, + std::size_t const n_equations_pow2) +{ + extern __shared__ float extern_array[]; //shared memory between threads of a single block, + //used for storing the calculation_matrix, the + //abs_row vector, and the abs_row_index vector + + // In this routine we will store the augmented matrix (A|B), referred to here + // as the calculation matrix in a shared memory space which is visible to all + // threads within a block. Also stored in shared memory are two vectors which + // are used to find the largest element in each row (the pivot). These vectors + // are called abs_row and abs_row_index. + // + // Sizes of data stored in shared memory: + // + // calculation_matrix: n_equations * (n_equations+1) + // abs_row: n_equations_pow2 + // abs_row_index: n_equations_pow2 + // + // Note that each thread represents an element of the augmented matrix, with + // the column and row indicated by the x and y index of the thread. Each + // solution is calculated within one block, and the solution index is the + // block index x value. + + int const col_index = threadIdx.x; //column index in the calculation_matrix + int const row_index = threadIdx.y; //row index in the calculation_matrix + int const solution_index = blockIdx.x; + + int const n_col = blockDim.x; //number of columns in calculation matrix (=threads.x) + int const n_row = blockDim.y; //number of rows in calculation matrix (=threads.y) + int const alpha_size = blockDim.y * blockDim.y; //number of entries in alpha matrix for one solution (NxN) + + if (skip_calculation[solution_index]) + return; + + float p; //local variable used in pivot calculation + + float * calculation_matrix = extern_array; //point to the shared memory + + float * abs_row = extern_array + n_equations * (n_equations + 1); //abs_row is located after the calculation_matrix + //within the shared memory + + int * abs_row_index = (int *)abs_row + n_equations_pow2; //abs_row_index is located after abs_row + // + //note that although the shared memory is defined as + //float, we are storing data of type int in this + //part of the shared memory + + //initialize the singular vector + if (col_index == 0 && row_index == 0) + { + singular[solution_index] = 0; + } + + //initialize abs_row and abs_row_index, using only the threads on the diagonal + if (col_index == row_index) + { + abs_row[col_index + (n_equations_pow2 - n_equations)] = 0.0f; + abs_row_index[col_index + (n_equations_pow2 - n_equations)] = col_index + (n_equations_pow2 - n_equations); + } + + //initialize the calculation_matrix (alpha and beta, concatenated, for one solution) + if (col_index != n_equations) + calculation_matrix[row_index*n_col + col_index] = alpha[solution_index * alpha_size + row_index * n_equations + col_index]; + else + calculation_matrix[row_index*n_col + col_index] = beta[solution_index * n_equations + row_index]; + + //wait for thread synchronization + + __syncthreads(); + + //start of main outer loop over the rows of the calculation matrix + + for (int current_row = 0; current_row < n_equations; current_row++) + { + + // work in only one row, skipping the last column + if (row_index == current_row && col_index != n_equations) + { + + //save the absolute values of the current row + abs_row[col_index] = abs(calculation_matrix[row_index * n_col + col_index]); + + //save the column indices + abs_row_index[col_index] = col_index; + + __threadfence(); + + //find the largest absolute value in the current row and write its index in abs_row_index[0] + for (int n = 2; n <= n_equations_pow2; n = n * 2) + { + if (col_index < (n_equations_pow2 / n)) + { + if (abs_row[abs_row_index[col_index]] < abs_row[abs_row_index[col_index + (n_equations_pow2 / n)]]) + { + abs_row_index[col_index] = abs_row_index[col_index + (n_equations_pow2 / n)]; + } + } + } + } + + __syncthreads(); + + //singularity check - if all values in the row are zero, no solution exists + if (row_index == current_row && col_index != n_equations) + { + if (abs_row[abs_row_index[0]] == 0.0f) + { + singular[solution_index] = 1; + } + } + + //devide the row by the biggest value in the row + if (row_index == current_row) + { + calculation_matrix[row_index * n_col + col_index] + = calculation_matrix[row_index * n_col + col_index] / calculation_matrix[row_index * n_col + abs_row_index[0]]; + } + + __syncthreads(); + + //The value of the largest element of the current row was found, and then current + //row was divided by this value such that the largest value of the current row + //is equal to one. + // + //Next, the matrix is manipulated to reduce to zero all other entries in the column + //in which the largest value was found. To do this, the values in the current row + //are scaled appropriately and substracted from the other rows of the matrix. + // + //For each element of the matrix that is not in the current row, calculate the value + //to be subtracted and let each thread store this value in the scalar variable p. + + p = calculation_matrix[current_row * n_col + col_index] * calculation_matrix[row_index * n_col + abs_row_index[0]]; + __syncthreads(); + + if (row_index != current_row) + { + calculation_matrix[row_index * n_col + col_index] = calculation_matrix[row_index * n_col + col_index] - p; + } + __syncthreads(); + + } + + //At this point, if the solution exists, the calculation matrix has been reduced to the + //identity matrix on the left side, and the solution vector on the right side. However + //we have not swapped rows during the procedure, so the identity matrix is out of order. + // + //For example, starting with the following augmented matrix as input: + // + // [ 3 2 -4 | 4 ] + // [ 2 3 3 | 15 ] + // [ 5 -3 1 | 14 ] + // + //we will obtain: + // + // [ 0 0 1 | 2 ] + // [ 0 1 0 | 1 ] + // [ 1 0 0 | 3 ] + // + //Which needs to be re-arranged to obtain the correct solution vector. In the final + //step, each thread checks to see if its value equals 1, and if so it assigns the value + //in its rightmost column to the appropriate entry in the beta vector. The solution is + //stored in beta upon completetion. + + if (col_index != n_equations && calculation_matrix[row_index * n_col + col_index] == 1) + delta[n_row * solution_index + col_index] = calculation_matrix[row_index * n_col + n_equations]; + + __syncthreads(); +} diff --git a/Gpufit/cuda_gaussjordan.cuh b/Gpufit/cuda_gaussjordan.cuh new file mode 100644 index 0000000..2d41cda --- /dev/null +++ b/Gpufit/cuda_gaussjordan.cuh @@ -0,0 +1,15 @@ +#ifndef GPUFIT_CUDA_GAUSS_JORDAN_CUH_INCLUDED +#define GPUFIT_CUDA_GAUSS_JORDAN_CUH_INCLUDED + +#include + +extern __global__ void cuda_gaussjordan( + float * delta, + float const * beta, + float const * alpha, + int const * skip_calculation, + int * singular, + std::size_t const n_equations, + std::size_t const n_equations_pow2); + +#endif \ No newline at end of file diff --git a/Gpufit/cuda_kernels.cu b/Gpufit/cuda_kernels.cu new file mode 100644 index 0000000..2661a7e --- /dev/null +++ b/Gpufit/cuda_kernels.cu @@ -0,0 +1,1081 @@ +#include "gpufit.h" +#include "cuda_kernels.cuh" +#include "definitions.h" +#include "linear_1d.cuh" +#include "gauss_1d.cuh" +#include "gauss_2d.cuh" +#include "gauss_2d_elliptic.cuh" +#include "gauss_2d_rotated.cuh" +#include "cauchy_2d_elliptic.cuh" +#include "lse.cuh" +#include "mle.cuh" + +/* Description of the cuda_calc_curve_values function +* =================================================== +* +* This function calls one of the fitting curve functions depending on the input +* parameter model_id. The fitting curve function calculates the values of +* the fitting curves and its partial derivatives with respect to the fitting +* curve parameters. Multiple fits are calculated in parallel. +* +* Parameters: +* +* parameters: An input vector of concatenated sets of model parameters. +* +* n_fits: The number of fits. +* +* n_points: The number of data points per fit. +* +* n_parameters: The number of curve parameters. +* +* finished: An input vector which allows the calculation to be skipped for single +* fits. +* +* values: An output vector of concatenated sets of model function values. +* +* derivatives: An output vector of concatenated sets of model function partial +* derivatives. +* +* n_fits_per_block: The number of fits calculated by each threadblock. +* +* model_id: The fitting model ID. +* +* chunk_index: The chunk index. +* +* user_info: An input vector containing user information. +* +* user_info_size: The number of elements in user_info. +* +* Calling the cuda_calc_curve_values function +* =========================================== +* +* When calling the function, the blocks and threads must be set up correctly, +* as shown in the following example code. +* +* dim3 threads(1, 1, 1); +* dim3 blocks(1, 1, 1); +* +* threads.x = n_points * n_fits_per_block; +* blocks.x = n_fits / n_fits_per_block; +* +* cuda_calc_curve_values<<< blocks, threads >>>( +* parameters, +* n_points, +* n_parameters, +* finished, +* values, +* derivatives, +* n_fits_per_block, +* model_id, +* chunk_index, +* user_info, +* user_info_size); +* +*/ + +__global__ void cuda_calc_curve_values( + float const * parameters, + int const n_fits, + int const n_points, + int const n_parameters, + int const * finished, + float * values, + float * derivatives, + int const n_fits_per_block, + int const model_id, + int const chunk_index, + char * user_info, + std::size_t const user_info_size) +{ + int const fit_in_block = threadIdx.x / n_points; + int const point_index = threadIdx.x - fit_in_block * n_points; + int const fit_index = blockIdx.x * n_fits_per_block + fit_in_block; + + if (finished[fit_index]) + return; + if (point_index >= n_points) + return; + + if (model_id == GAUSS_1D) + calculate_gauss1d(parameters, n_fits, n_points, n_parameters, values, derivatives, chunk_index, user_info, user_info_size); + else if (model_id == GAUSS_2D) + calculate_gauss2d(parameters, n_fits, n_points, n_parameters, values, derivatives, chunk_index, user_info, user_info_size); + else if (model_id == GAUSS_2D_ELLIPTIC) + calculate_gauss2delliptic(parameters, n_fits, n_points, n_parameters, values, derivatives, chunk_index, user_info, user_info_size); + else if (model_id == GAUSS_2D_ROTATED) + calculate_gauss2drotated(parameters, n_fits, n_points, n_parameters, values, derivatives, chunk_index, user_info, user_info_size); + else if (model_id == CAUCHY_2D_ELLIPTIC) + calculate_cauchy2delliptic(parameters, n_fits, n_points, n_parameters, values, derivatives, chunk_index, user_info, user_info_size); + else if (model_id == LINEAR_1D) + calculate_linear1d(parameters, n_fits, n_points, n_parameters, values, derivatives, chunk_index, user_info, user_info_size); +} + +/* Description of the sum_up_floats function +* ========================================== +* +* This function sums up a vector of float values and stores the result at the +* first place of the vector. +* +* Parameters: +* +* shared_array: An input vector of float values. The vector must be stored +* on the shared memory of the GPU. The size of this vector must be a +* power of two. Use zero padding to extend it to the next highest +* power of 2 greater than the number of elements. +* +* size: The number of elements in the input vector considering zero padding. +* +* Calling the sum_up_floats function +* ================================== +* +* This __device__ function can be only called from a __global__ function or +* an other __device__ function. When calling the function, the blocks and threads +* of the __global__ function must be set up correctly, as shown in the following +* example code. +* +* dim3 threads(1, 1, 1); +* dim3 blocks(1, 1, 1); +* +* threads.x = size * vectors_per_block; +* blocks.x = n_vectors / vectors_per_block; +* +* global_function<<< blocks,threads >>>(parameter1, ...); +* +*/ + +__device__ void sum_up_floats(volatile float* shared_array, int const size) +{ + int const fit_in_block = threadIdx.x / size; + int const point_index = threadIdx.x - (fit_in_block*size); + + int current_n_points = size >> 1; + __syncthreads(); + while (current_n_points) + { + if (point_index < current_n_points) + { + shared_array[point_index] += shared_array[point_index + current_n_points]; + } + current_n_points >>= 1; + __syncthreads(); + } +} + +/* Description of the cuda_calculate_chi_squares function +* ======================================================== +* +* This function calculates the chi-square values calling a __device__ function. +* The calcluation is performed for multiple fits in parallel. +* +* Parameters: +* +* chi_squares: An output vector of concatenated chi-square values. +* +* states: An output vector of values which indicate whether the fitting process +* was carreid out correctly or which problem occurred. In this function +* it is only used for MLE. It is set to 3 if a fitting curve value is +* negative. This vector includes the states for multiple fits. +* +* iteration_falied: An output vector which indicates whether the chi-square values +* calculated by the current iteration decreased compared to the +* previous iteration. +* +* prev_chi_squares: An input vector of concatenated chi-square values calculated +* by the previous iteration. +* +* data: An input vector of data for multiple fits +* +* values: An input vector of concatenated sets of model function values. +* +* weight: An input vector of values for weighting chi-square, gradient and hessian, +* while using LSE +* +* n_points: The number of data points per fit. +* +* estimator_id: The estimator ID. +* +* finished: An input vector which allows the calculation to be skipped for single +* fits. +* +* n_fits_per_block: The number of fits calculated by each thread block. +* +* user_info: An input vector containing user information. +* +* user_info_size: The number of elements in user_info. +* +* Calling the cuda_calculate_chi_squares function +* ================================================ +* +* When calling the function, the blocks and threads must be set up correctly, +* as shown in the following example code. +* +* dim3 threads(1, 1, 1); +* dim3 blocks(1, 1, 1); +* +* threads.x = power_of_two_n_points * n_fits_per_block; +* blocks.x = n_fits / n_fits_per_block; +* +* cuda_calculate_chi_squares<<< blocks, threads >>>( +* chi_squares, +* states, +* iteration_falied, +* prev_chi_squares, +* data, +* values, +* weight, +* n_points, +* estimator_id, +* finished, +* n_fits_per_block, +* user_info, +* user_info_size); +* +*/ + +__global__ void cuda_calculate_chi_squares( + float * chi_squares, + int * states, + int * iteration_falied, + float const * prev_chi_squares, + float const * data, + float const * values, + float const * weights, + int const n_points, + int const estimator_id, + int const * finished, + int const n_fits_per_block, + char * user_info, + std::size_t const user_info_size) +{ + int const shared_size = blockDim.x / n_fits_per_block; + int const fit_in_block = threadIdx.x / shared_size; + int const fit_index = blockIdx.x * n_fits_per_block + fit_in_block; + int const point_index = threadIdx.x - fit_in_block * shared_size; + int const first_point = fit_index * n_points; + + if (finished[fit_index]) + { + return; + } + + float const * current_data = &data[first_point]; + float const * current_weight = weights ? &weights[first_point] : NULL; + float const * current_value = &values[first_point]; + int * current_state = &states[fit_index]; + + extern __shared__ float extern_array[]; + + volatile float * shared_chi_square = &extern_array[fit_in_block*shared_size]; + + if (point_index >= n_points) + { + shared_chi_square[point_index] = 0.f; + } + + if (point_index < n_points) + { + if (estimator_id == LSE) + { + calculate_chi_square_lse( + shared_chi_square, + point_index, + current_data, + current_value, + current_weight, + current_state, + user_info, + user_info_size); + } + else if (estimator_id == MLE) + { + calculate_chi_square_mle( + shared_chi_square, + point_index, + current_data, + current_value, + current_weight, + current_state, + user_info, + user_info_size); + } + } + sum_up_floats(shared_chi_square, shared_size); + chi_squares[fit_index] = shared_chi_square[0]; + + + bool const prev_chi_squares_initialized = prev_chi_squares[fit_index] != 0; + bool const chi_square_increased = (chi_squares[fit_index] >= prev_chi_squares[fit_index]); + if (prev_chi_squares_initialized && chi_square_increased) + { + iteration_falied[fit_index] = 1; + } + else + { + iteration_falied[fit_index] = 0; + } +} + +/* Description of the cuda_calculate_gradients function +* ======================================================== +* +* This function calculates the gradient values of the chi-square function calling +* a __device__ function. The calcluation is performed for multiple fits in parallel. +* +* Parameters: +* +* gradients: An output vector of concatenated sets of gradient vector values. +* +* data: An input vector of data for multiple fits +* +* values: An input vector of concatenated sets of model function values. +* +* derivatives: An input vector of concatenated sets of model function partial +* derivatives. +* +* weight: An input vector of values for weighting chi-square, gradient and hessian, +* while using LSE +* +* n_points: The number of data points per fit. +* +* n_parameters: The number of fitting curve parameters. +* +* n_parameters_to_fit: The number of fitting curve parameters, that are not held +* fixed. +* +* parameters_to_fit_indices: An input vector of indices of fitting curve parameters, +* that are not held fixed. +* +* estimator_id: The estimator ID. +* +* finished: An input vector which allows the calculation to be skipped for single +* fits. +* +* skip: An input vector which allows the calculation to be skipped for single fits. +* +* n_fits_per_block: The number of fits calculated by each thread block. +* +* user_info: An input vector containing user information. +* +* user_info_size: The number of elements in user_info. +* +* Calling the cuda_calculate_gradients function +* ================================================ +* +* When calling the function, the blocks and threads must be set up correctly, +* as shown in the following example code. +* +* dim3 threads(1, 1, 1); +* dim3 blocks(1, 1, 1); +* +* threads.x = power_of_two_n_points * n_fits_per_block; +* blocks.x = n_fits / n_fits_per_block; +* +* cuda_calculate_gradients<<< blocks, threads >>>( +* gradients, +* data, +* values, +* derivatives, +* weight, +* n_points, +* n_parameters, +* n_parameters_to_fit, +* parameters_to_fit_indices, +* estimator_id, +* finished, +* skip, +* n_fits_per_block, +* user_info, +* user_info_size); +* +*/ + +__global__ void cuda_calculate_gradients( + float * gradients, + float const * data, + float const * values, + float const * derivatives, + float const * weights, + int const n_points, + int const n_parameters, + int const n_parameters_to_fit, + int const * parameters_to_fit_indices, + int const estimator_id, + int const * finished, + int const * skip, + int const n_fits_per_block, + char * user_info, + std::size_t const user_info_size) +{ + int const shared_size = blockDim.x / n_fits_per_block; + int const fit_in_block = threadIdx.x / shared_size; + int const fit_index = blockIdx.x * n_fits_per_block + fit_in_block; + int const point_index = threadIdx.x - fit_in_block * shared_size; + int const first_point = fit_index * n_points; + + if (finished[fit_index] || skip[fit_index]) + { + return; + } + + float const * current_data = &data[first_point]; + float const * current_weight = weights ? &weights[first_point] : NULL; + float const * current_derivative = &derivatives[first_point * n_parameters]; + float const * current_value = &values[first_point]; + + extern __shared__ float extern_array[]; + + volatile float * shared_gradient = &extern_array[fit_in_block * shared_size]; + + if (point_index >= n_points) + { + shared_gradient[point_index] = 0.f; + } + + for (int parameter_index = 0; parameter_index < n_parameters_to_fit; parameter_index++) + { + if (point_index < n_points) + { + int const derivative_index = parameters_to_fit_indices[parameter_index] * n_points + point_index; + + if (estimator_id == LSE) + { + calculate_gradient_lse( + shared_gradient, + point_index, + derivative_index, + current_data, + current_value, + current_derivative, + current_weight, + user_info, + user_info_size); + } + else if (estimator_id == MLE) + { + calculate_gradient_mle( + shared_gradient, + point_index, + derivative_index, + current_data, + current_value, + current_derivative, + current_weight, + user_info, + user_info_size); + } + } + sum_up_floats(shared_gradient, shared_size); + gradients[fit_index * n_parameters_to_fit + parameter_index] = shared_gradient[0]; + } +} + +/* Description of the cuda_calculate_hessians function +* ======================================================== +* +* This function calculates the hessian matrix values of the chi-square function +* calling a __device__ functions. The calcluation is performed for multiple fits +* in parallel. +* +* Parameters: +* +* hessians: An output vector of concatenated sets of hessian matrix values. +* +* data: An input vector of data for multiple fits +* +* values: An input vector of concatenated sets of model function values. +* +* derivatives: An input vector of concatenated sets of model function partial +* derivatives. +* +* weight: An input vector of values for weighting chi-square, gradient and hessian, +* while using LSE +* +* n_points: The number of data points per fit. +* +* n_parameters: The number of fitting curve parameters. +* +* n_parameters_to_fit: The number of fitting curve parameters, that are not held +* fixed. +* +* parameters_to_fit_indices: An input vector of indices of fitting curve parameters, +* that are not held fixed. +* +* estimator_id: The estimator ID. +* +* skip: An input vector which allows the calculation to be skipped for single fits. +* +* finished: An input vector which allows the calculation to be skipped for single +* fits. +* +* user_info: An input vector containing user information. +* +* user_info_size: The number of elements in user_info. +* +* Calling the cuda_calculate_hessians function +* ================================================ +* +* When calling the function, the blocks and threads must be set up correctly, +* as shown in the following example code. +* +* dim3 threads(1, 1, 1); +* dim3 blocks(1, 1, 1); +* +* threads.x = n_parameters_to_fit; +* threads.y = n_parameters_to_fit; +* blocks.x = n_fits; +* +* cuda_calculate_hessians<<< blocks, threads >>>( +* hessians, +* data, +* values, +* derivatives, +* weight, +* n_points, +* n_parameters, +* n_parameters_to_fit, +* parameters_to_fit_indices, +* estimator_id, +* skip, +* finished, +* user_info, +* user_info_size); +* +*/ + +__global__ void cuda_calculate_hessians( + float * hessians, + float const * data, + float const * values, + float const * derivatives, + float const * weights, + int const n_points, + int const n_parameters, + int const n_parameters_to_fit, + int const * parameters_to_fit_indices, + int const estimator_id, + int const * skip, + int const * finished, + char * user_info, + std::size_t const user_info_size) +{ + int const fit_index = blockIdx.x; + int const first_point = fit_index * n_points; + + int const parameter_index_i = threadIdx.x; + int const parameter_index_j = threadIdx.y; + + if (finished[fit_index] || skip[fit_index]) + { + return; + } + + float * current_hessian = &hessians[fit_index * n_parameters_to_fit * n_parameters_to_fit]; + float const * current_data = &data[first_point]; + float const * current_weight = weights ? &weights[first_point] : NULL; + float const * current_derivative = &derivatives[first_point*n_parameters]; + float const * current_value = &values[first_point]; + + int const hessian_index_ij = parameter_index_i * n_parameters_to_fit + parameter_index_j; + int const derivative_index_i = parameters_to_fit_indices[parameter_index_i] * n_points; + int const derivative_index_j = parameters_to_fit_indices[parameter_index_j] * n_points; + + double sum = 0.0; + for (int point_index = 0; point_index < n_points; point_index++) + { + if (estimator_id == LSE) + { + calculate_hessian_lse( + &sum, + point_index, + derivative_index_i + point_index, + derivative_index_j + point_index, + current_data, + current_value, + current_derivative, + current_weight, + user_info, + user_info_size); + } + else if (estimator_id == MLE) + { + calculate_hessian_mle( + &sum, + point_index, + derivative_index_i + point_index, + derivative_index_j + point_index, + current_data, + current_value, + current_derivative, + current_weight, + user_info, + user_info_size); + } + } + current_hessian[hessian_index_ij] = sum; +} + +/* Description of the cuda_modify_step_widths function +* ==================================================== +* +* This function midifies the diagonal elements of the hessian matrices by multiplying +* them by the factor (1+ lambda). This operation controls the step widths of the +* iteration. If the last iteration failed, befor modifying the hessian, the diagonal +* elements of the hessian are calculated back to represent unmodified values. +* +* hessians: An input and output vector of hessian matrices, which are modified by +* the lambda values. +* +* lambdas: An input vector of values for modifying the hessians. +* +* n_parameters: The number of fitting curve parameters. +* +* iteration_falied: An input vector which indicates whether the previous iteration +* failed. +* +* finished: An input vector which allows the calculation to be skipped for single fits. +* +* n_fits_per_block: The number of fits calculated by each thread block. +* +* Calling the cuda_modify_step_widths function +* ============================================ +* +* When calling the function, the blocks and threads must be set up correctly, +* as shown in the following example code. +* +* dim3 threads(1, 1, 1); +* dim3 blocks(1, 1, 1); +* +* threads.x = n_parameters_to_fit * n_fits_per_block; +* blocks.x = n_fits / n_fits_per_block; +* +* cuda_modify_step_width<<< blocks, threads >>>( +* hessians, +* lambdas, +* n_parameters, +* iteration_failed, +* finished, +* n_fits_per_block); +* +*/ + +__global__ void cuda_modify_step_widths( + float * hessians, + float const * lambdas, + unsigned int const n_parameters, + int const * iteration_failed, + int const * finished, + int const n_fits_per_block) +{ + int const shared_size = blockDim.x / n_fits_per_block; + int const fit_in_block = threadIdx.x / shared_size; + int const parameter_index = threadIdx.x - fit_in_block * shared_size; + int const fit_index = blockIdx.x * n_fits_per_block + fit_in_block; + + if (finished[fit_index]) + { + return; + } + + float * current_hessian = &hessians[fit_index * n_parameters * n_parameters]; + + if (iteration_failed[fit_index]) + { + current_hessian[parameter_index * n_parameters + parameter_index] + = current_hessian[parameter_index * n_parameters + parameter_index] + / (1.0f + lambdas[fit_index] / 10.f); + } + + current_hessian[parameter_index * n_parameters + parameter_index] + = current_hessian[parameter_index * n_parameters + parameter_index] + * (1.0f + lambdas[fit_index]); +} + +/* Description of the cuda_update_parameters function +* =================================================== +* +* This function stores the fitting curve parameter values in prev_parameters and +* updates them after each iteration. +* +* Parameters: +* +* deltas: An input vector of concatenated delta values, which are added to the +* model parameters. +* +* parameters: An input and output vector of concatenated sets of model +* parameters. +* +* n_parameters_to_fit: The number of fitted curve parameters. +* +* parameters_to_fit_indices: The indices of fitted curve parameters. +* +* finished: An input vector which allows the calculation to be skipped for single fits. +* +* n_fits_per_block: The number of fits calculated by each threadblock. +* +* Calling the cuda_update_parameters function +* =========================================== +* +* When calling the function, the blocks and threads must be set up correctly, +* as shown in the following example code. +* +* dim3 threads(1, 1, 1); +* dim3 blocks(1, 1, 1); +* +* threads.x = n_parameters * n_fits_per_block; +* blocks.x = n_fits / n_fits_per_block; +* +* cuda_update_parameters<<< blocks, threads >>>( +* deltas, +* parameters, +* n_parameters_to_fit, +* parameters_to_fit_indices, +* finished, +* n_fits_per_block); +* +*/ + +__global__ void cuda_update_parameters( + float * parameters, + float * prev_parameters, + float const * deltas, + int const n_parameters_to_fit, + int const * parameters_to_fit_indices, + int const * finished, + int const n_fits_per_block) +{ + int const n_parameters = blockDim.x / n_fits_per_block; + int const fit_in_block = threadIdx.x / n_parameters; + int const parameter_index = threadIdx.x - fit_in_block * n_parameters; + int const fit_index = blockIdx.x * n_fits_per_block + fit_in_block; + + float * current_parameters = ¶meters[fit_index * n_parameters]; + float * current_prev_parameters = &prev_parameters[fit_index * n_parameters]; + + current_prev_parameters[parameter_index] = current_parameters[parameter_index]; + + if (finished[fit_index]) + { + return; + } + + if (parameter_index >= n_parameters_to_fit) + { + return; + } + + float const * current_deltas = &deltas[fit_index * n_parameters_to_fit]; + + current_parameters[parameters_to_fit_indices[parameter_index]] += current_deltas[parameter_index]; +} + +/* Description of the cuda_update_state_after_gaussjordan function +* ================================================================ +* +* This function interprets the singular flag vector of the Gauss Jordan function +* according to this LM implementation. +* +* Parameters: +* +* n_fits: The number of fits. +* +* singular_checks: An input vector used to report whether a fit is singular. +* +* states: An output vector of values which indicate whether the fitting process +* was carreid out correctly or which problem occurred. If a hessian +* matrix of a fit is singular, it is set to 2. +* +* Calling the cuda_update_state_after_gaussjordan function +* ======================================================== +* +* When calling the function, the blocks and threads must be set up correctly, +* as shown in the following example code. +* +* dim3 threads(1, 1, 1); +* dim3 blocks(1, 1, 1); +* +* int const example_value = 256; +* +* threads.x = min(n_fits, example_value); +* blocks.x = int(ceil(float(n_fits) / float(threads.x))); +* +* cuda_update_state_after_gaussjordan<<< blocks, threads >>>( +* n_fits, +* singular_checks, +* states); +* +*/ + + +__global__ void cuda_update_state_after_gaussjordan( + int const n_fits, + int const * singular_checks, + int * states) +{ + int const fit_index = blockIdx.x * blockDim.x + threadIdx.x; + + if (fit_index >= n_fits) + { + return; + } + + if (singular_checks[fit_index] == 1) + { + states[fit_index] = STATE_SINGULAR_HESSIAN; + } + +} + +/* Description of the cuda_check_for_convergence function +* ======================================================= +* +* This function checks after each iteration whether the fits are converged or not. +* It also checks whether the set maximum number of iterations is reached. +* +* Parameters: +* +* finished: An input and output vector which allows the calculation to be skipped +* for single fits. +* +* tolerance: The tolerance value for the convergence set by user. +* +* states: An output vector of values which indicate whether the fitting process +* was carreid out correctly or which problem occurred. If the maximum +* number of iterationsis reached without converging, it is set to 1. If +* the fit converged it keeps its initial value of 0. +* +* chi_squares: An input vector of chi-square values for multiple fits. Used for the +* convergence check. +* +* prev_chi_squares: An input vector of chi-square values for multiple fits calculated +* in the previous iteration. Used for the convergence check. +* +* iteration: The value of the current iteration. It is compared to the value +* of the maximum number of iteration set by user. +* +* max_n_iterations: The maximum number of iterations set by user. +* +* n_fits: The number of fits. +* +* Calling the cuda_check_for_convergence function +* =============================================== +* +* When calling the function, the blocks and threads must be set up correctly, +* as shown in the following example code. +* +* dim3 threads(1, 1, 1); +* dim3 blocks(1, 1, 1); +* +* int const example_value = 256; +* +* threads.x = min(n_fits, example_value); +* blocks.x = int(ceil(float(n_fits) / float(threads.x))); +* +* cuda_check_for_convergence<<< blocks, threads >>>( +* finished, +* tolerance, +* states, +* chi_squares, +* prev_chi_squares, +* iteration, +* max_n_iterations, +* n_fits); +* +*/ + +__global__ void cuda_check_for_convergence( + int * finished, + float const tolerance, + int * states, + float const * chi_squares, + float const * prev_chi_squares, + int const iteration, + int const max_n_iterations, + int const n_fits) +{ + int const fit_index = blockIdx.x * blockDim.x + threadIdx.x; + + if (fit_index >= n_fits) + { + return; + } + + if (finished[fit_index]) + { + return; + } + + int const fit_found = abs(chi_squares[fit_index] - prev_chi_squares[fit_index]) < tolerance * fmaxf(1, chi_squares[fit_index]); + + int const max_n_iterations_reached = iteration == max_n_iterations - 1; + + if (fit_found) + { + finished[fit_index] = 1; + } + else if (max_n_iterations_reached) + { + states[fit_index] = STATE_MAX_ITERATION; + } +} + +/* Description of the cuda_evaluate_iteration function +* ==================================================== +* +* This function evaluates the current iteration. +* - It marks a fit as finished if a problem occured. +* - It saves the needed number of iterations if a fit finished. +* - It checks if all fits finished +* +* Parameters: +* +* all_finished: An output flag, that indicates whether all fits finished. +* +* n_iterations: An output vector of needed iterations for each fit. +* +* finished: An input and output vector which allows the evaluation to be skipped +* for single fits +* +* iteration: The values of the current iteration. +* +* states: An input vector of values which indicate whether the fitting process +* was carreid out correctly or which problem occurred. +* +* n_fits: The number of fits. +* +* Calling the cuda_evaluate_iteration function +* ============================================ +* +* When calling the function, the blocks and threads must be set up correctly, +* as shown in the following example code. +* +* dim3 threads(1, 1, 1); +* dim3 blocks(1, 1, 1); +* +* int const example_value = 256; +* +* threads.x = min(n_fits, example_value); +* blocks.x = int(ceil(float(n_fits) / float(threads.x))); +* +* cuda_evaluate_iteration<<< blocks, threads >>>( +* all_finished, +* n_iterations, +* finished, +* iteration, +* states, +* n_fits) +* +*/ + +__global__ void cuda_evaluate_iteration( + int * all_finished, + int * n_iterations, + int * finished, + int const iteration, + int const * states, + int const n_fits) +{ + int const fit_index = blockIdx.x * blockDim.x + threadIdx.x; + + if (fit_index >= n_fits) + { + return; + } + + if (states[fit_index] != STATE_CONVERGED) + { + finished[fit_index] = 1; + } + + if (finished[fit_index] && n_iterations[fit_index] == 0) + { + n_iterations[fit_index] = iteration + 1; + } + + if (!finished[fit_index]) + { + * all_finished = 0; + } +} + +/* Description of the cuda_prepare_next_iteration function +* ======================================================== +* +* This function prepares the next iteration. It either updates chi-square values +* or sets chi-squares and curve parameters to previous values. This function also +* updates lambda values. +* +* Parameters: +* +* lambdas: An output vector of values which control the step width by modifying +* the diagonal elements of the hessian matrices. +* +* chi_squares: An input vector of chi-square values for multiple fits. +* +* prev_chi_squares: An input vector of chi-square values for multiple fits calculated +* in the previous iteration. +* +* parameters: An output vector of concatenated sets of model parameters. +* +* prev_parameters: An input vector of concatenated sets of model parameters +* calculated in the previous iteration. +* +* n_fits: The number of fits. +* +* n_parameters: The number of fitting curve parameters. +* +* Calling the cuda_prepare_next_iteration function +* ================================================ +* +* When calling the function, the blocks and threads must be set up correctly, +* as shown in the following example code. +* +* dim3 threads(1, 1, 1); +* dim3 blocks(1, 1, 1); +* +* int const example_value = 256; +* +* threads.x = min(n_fits, example_value); +* blocks.x = int(ceil(float(n_fits) / float(threads.x))); +* +* cuda_prepare_next_iteration<<< blocks, threads >>>( +* lambdas, +* chi_squares, +* prev_chi_squares, +* parameters, +* prev_parameters, +* n_fits, +* n_parameters); +* +*/ + +__global__ void cuda_prepare_next_iteration( + float * lambdas, + float * chi_squares, + float * prev_chi_squares, + float * parameters, + float const * prev_parameters, + int const n_fits, + int const n_parameters) +{ + int const fit_index = blockIdx.x * blockDim.x + threadIdx.x; + + if (fit_index >= n_fits) + { + return; + } + + if (chi_squares[fit_index] < prev_chi_squares[fit_index]) + { + lambdas[fit_index] *= 0.1f; + prev_chi_squares[fit_index] = chi_squares[fit_index]; + } + else + { + lambdas[fit_index] *= 10.f; + chi_squares[fit_index] = prev_chi_squares[fit_index]; + for (int iparameter = 0; iparameter < n_parameters; iparameter++) + { + parameters[fit_index * n_parameters + iparameter] = prev_parameters[fit_index * n_parameters + iparameter]; + } + } +} diff --git a/Gpufit/cuda_kernels.cuh b/Gpufit/cuda_kernels.cuh new file mode 100644 index 0000000..6836480 --- /dev/null +++ b/Gpufit/cuda_kernels.cuh @@ -0,0 +1,108 @@ +#ifndef GPUFIT_CUDA_KERNELS_CUH_INCLUDED +#define GPUFIT_CUDA_KERNELS_CUH_INCLUDED + +#include + +extern __global__ void cuda_calculate_chi_squares( + float * chi_squares, + int * states, + int * iteration_falied, + float const * prev_chi_squares, + float const * data, + float const * values, + float const * weights, + int const n_points, + int const estimator_id, + int const * finished, + int const n_fits_per_block, + char * user_info, + std::size_t const user_info_size); +extern __global__ void cuda_calculate_gradients( + float * gradients, + float const * data, + float const * values, + float const * derivatives, + float const * weights, + int const n_points, + int const n_parameters, + int const n_parameters_to_fit, + int const * parameters_to_fit_indices, + int const estimator_id, + int const * finished, + int const * skip, + int const n_fits_per_block, + char * user_info, + std::size_t const user_info_size); +extern __global__ void cuda_calculate_hessians( + float * hessians, + float const * data, + float const * values, + float const * derivatives, + float const * weights, + int const n_points, + int const n_parameters, + int const n_parameters_to_fit, + int const * parameters_to_fit_indices, + int const estimator_id, + int const * skip, + int const * finished, + char * user_info, + std::size_t const user_info_size); +extern __global__ void cuda_modify_step_widths( + float * hessians, + float const * lambdas, + unsigned int const n_parameters, + int const * iteration_failed, + int const * finished, + int const n_fits_per_block); +extern __global__ void cuda_calc_curve_values( + float const * parameters, + int const n_fits, + int const n_points, + int const n_parameters, + int const * finished, + float * values, + float * derivatives, + int const n_fits_per_block, + int const model_id, + int const chunk_index, + char * user_info, + std::size_t const user_info_size); +extern __global__ void cuda_update_parameters( + float * parameters, + float * prev_parameters, + float const * deltas, + int const n_parameters_to_fit, + int const * parameters_to_fit_indices, + int const * finished, + int const n_fits_per_block); +extern __global__ void cuda_check_for_convergence( + int * finished, + float const tolerance, + int * states, + float const * chi_squares, + float const * prev_chi_squares, + int const iteration, + int const max_n_iterations, + int const n_fits); +extern __global__ void cuda_evaluate_iteration( + int * all_finished, + int * n_iterations, + int * finished, + int const iteration, + int const * states, + int const n_fits); +extern __global__ void cuda_prepare_next_iteration( + float * lambdas, + float * chi_squares, + float * prev_chi_squares, + float * function_parameters, + float const * prev_parameters, + int const n_fits, + int const n_parameters); +extern __global__ void cuda_update_state_after_gaussjordan( + int const n_fits, + int const * singular_checks, + int * states); + +#endif diff --git a/Gpufit/definitions.h b/Gpufit/definitions.h new file mode 100644 index 0000000..348220d --- /dev/null +++ b/Gpufit/definitions.h @@ -0,0 +1,12 @@ +#ifndef GPUFIT_DEFINITIONS_H_INCLUDED +#define GPUFIT_DEFINITIONS_H_INCLUDED + + // Status +#include +#define CUDA_CHECK_STATUS( cuda_function_call ) \ + if (cudaError_t const status = cuda_function_call) \ + { \ + throw std::runtime_error( cudaGetErrorString( status ) ) ; \ + } + +#endif diff --git a/Gpufit/examples/CMakeLists.txt b/Gpufit/examples/CMakeLists.txt new file mode 100644 index 0000000..bb4902f --- /dev/null +++ b/Gpufit/examples/CMakeLists.txt @@ -0,0 +1,14 @@ + +function( add_example module name ) + add_executable( ${name} ${name}.cpp ) + target_link_libraries( ${name} ${module} ) + set_property( TARGET ${name} + PROPERTY RUNTIME_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}" ) + set_property( TARGET ${name} PROPERTY FOLDER GpufitExamples ) +endfunction() + +# Examples + +add_example( Gpufit Simple_Example ) +add_example( Gpufit Linear_Regression_Example ) +add_example( Gpufit Gauss_Fit_2D_Example ) diff --git a/Gpufit/examples/Gauss_Fit_2D_Example.cpp b/Gpufit/examples/Gauss_Fit_2D_Example.cpp new file mode 100644 index 0000000..8e628c7 --- /dev/null +++ b/Gpufit/examples/Gauss_Fit_2D_Example.cpp @@ -0,0 +1,260 @@ +#include "../gpufit.h" + +#include +#include +#include +#include +#include +#include + +void generate_gauss_2d( + std::vector const & x, + std::vector const & y, + std::vector & g, + std::vector const & p) +{ + // generates a Gaussian 2D peak function on a set of x and y values with some paramters p (size 5) + // we assume that x.size == y.size == g.size, no checks done + + // given x and y values and parameters p computes a model function g + for (size_t i = 0; i < x.size(); i++) + { + float arg = -((x[i] - p[1]) * (x[i] - p[1]) + (y[i] - p[2]) * (y[i] - p[2])) / (2 * p[3] * p[3]); + g[i] = p[0] * exp(arg) + p[4]; + } +} + +void gauss_fit_2d_example() +{ + /* + This example generates test data in form of 10000 two dimensional Gaussian + peaks with the size of 5x5 data points per peak. It is noised by Poisson + distributed noise. The initial guesses were randomized, within a specified + range of the true value. The GAUSS_2D model is fitted to the test data sets + using the MLE estimator. + + The console output shows + - the execution time, + - the ratio of converged fits including ratios of not converged fits for + different reasons, + - the values of the true parameters and the mean values of the fitted + parameters including their standard deviation, + - the mean chi square value + - and the mean number of iterations needed. + + True parameters and noise and number of fits is the same as for the Matlab/Python 2D Gaussian examples. + */ + + + // number of fits, fit points and parameters + size_t const number_fits = 10000; + size_t const size_x = 20; + size_t const number_points = size_x * size_x; + size_t const number_parameters = 5; + + // true parameters (amplitude, center x position, center y position, width, offset) + std::vector< float > true_parameters{ 10.f, 9.5f, 9.5f, 3.f, 10.f}; + + // initialize random number generator + std::mt19937 rng; + rng.seed(0); + std::uniform_real_distribution< float> uniform_dist(0, 1); + + // initial parameters (randomized) + std::vector< float > initial_parameters(number_fits * number_parameters); + for (size_t i = 0; i < number_fits; i++) + { + for (size_t j = 0; j < number_parameters; j++) + { + if (j == 1 || j == 2) + { + initial_parameters[i * number_parameters + j] + = true_parameters[j] + true_parameters[3] + * (-0.2f + 0.4f * uniform_dist(rng)); + } + else + { + initial_parameters[i * number_parameters + j] + = true_parameters[j] * (0.8f + 0.4f * uniform_dist(rng)); + } + } + } + + // generate x and y values + std::vector< float > x(number_points); + std::vector< float > y(number_points); + for (size_t i = 0; i < size_x; i++) + { + for (size_t j = 0; j < size_x; j++) { + x[i * size_x + j] = static_cast(j); + y[i * size_x + j] = static_cast(i); + } + } + + // generate test data with Poisson noise + std::vector< float > temp(number_points); + generate_gauss_2d(x, y, temp, true_parameters); + + std::vector< float > data(number_fits * number_points); + for (size_t i = 0; i < number_fits; i++) + { + for (size_t j = 0; j < number_points; j++) + { + std::poisson_distribution< int > poisson_dist(temp[j]); + data[i * number_points + j] = static_cast(poisson_dist(rng)); + } + } + + // tolerance + float const tolerance = 0.001f; + + // maximal number of iterations + int const max_number_iterations = 20; + + // estimator ID + int const estimator_id = MLE; + + // model ID + int const model_id = GAUSS_2D; + + // parameters to fit (all of them) + std::vector< int > parameters_to_fit(number_parameters, 1); + + // output parameters + std::vector< float > output_parameters(number_fits * number_parameters); + std::vector< int > output_states(number_fits); + std::vector< float > output_chi_square(number_fits); + std::vector< int > output_number_iterations(number_fits); + + // call to gpufit (C interface) + std::chrono::high_resolution_clock::time_point time_0 = std::chrono::high_resolution_clock::now(); + int const status = gpufit + ( + number_fits, + number_points, + data.data(), + 0, + model_id, + initial_parameters.data(), + tolerance, + max_number_iterations, + parameters_to_fit.data(), + estimator_id, + 0, + 0, + output_parameters.data(), + output_states.data(), + output_chi_square.data(), + output_number_iterations.data() + ); + std::chrono::high_resolution_clock::time_point time_1 = std::chrono::high_resolution_clock::now(); + + // check status + if (status != STATUS_OK) + { + throw std::runtime_error(gpufit_get_last_error()); + } + + // print execution time + std::cout + << "execution time " + << std::chrono::duration_cast(time_1 - time_0).count() << " ms\n"; + + // get fit states + std::vector< int > output_states_histogram(5, 0); + for (std::vector< int >::iterator it = output_states.begin(); it != output_states.end(); ++it) + { + output_states_histogram[*it]++; + } + + std::cout << "ratio converged " << (float)output_states_histogram[0] / number_fits << "\n"; + std::cout << "ratio max iteration exceeded " << (float)output_states_histogram[1] / number_fits << "\n"; + std::cout << "ratio singular hessian " << (float)output_states_histogram[2] / number_fits << "\n"; + std::cout << "ratio neg curvature MLE " << (float)output_states_histogram[3] / number_fits << "\n"; + std::cout << "ratio gpu not read " << (float)output_states_histogram[4] / number_fits << "\n"; + + // compute mean of fitted parameters for converged fits + std::vector< float > output_parameters_mean(number_parameters, 0); + for (size_t i = 0; i != number_fits; i++) + { + if (output_states[i] == STATE_CONVERGED) + { + for (size_t j = 0; j < number_parameters; j++) + { + output_parameters_mean[j] += output_parameters[i * number_parameters + j]; + } + } + } + // normalize + for (size_t j = 0; j < number_parameters; j++) + { + output_parameters_mean[j] /= output_states_histogram[0]; + } + + // compute std of fitted parameters for converged fits + std::vector< float > output_parameters_std(number_parameters, 0); + for (size_t i = 0; i != number_fits; i++) + { + if (output_states[i] == STATE_CONVERGED) + { + for (size_t j = 0; j < number_parameters; j++) + { + output_parameters_std[j] + += (output_parameters[i * number_parameters + j] - output_parameters_mean[j]) + * (output_parameters[i * number_parameters + j] - output_parameters_mean[j]); + } + } + } + // normalize and take square root + for (size_t j = 0; j < number_parameters; j++) + { + output_parameters_std[j] = sqrt(output_parameters_std[j] / output_states_histogram[0]); + } + + // print true value, fitted mean and std for every parameter + for (size_t j = 0; j < number_parameters; j++) + { + std::cout + << "parameter " << j + << " true " << true_parameters[j] + << " fitted mean " << output_parameters_mean[j] + << " std " << output_parameters_std[j] << "\n"; + } + + // compute mean chi-square for those converged + float output_chi_square_mean = 0; + for (size_t i = 0; i != number_fits; i++) + { + if (output_states[i] == STATE_CONVERGED) + { + output_chi_square_mean += output_chi_square[i]; + } + } + output_chi_square_mean /= static_cast(output_states_histogram[0]); + std::cout << "mean chi square " << output_chi_square_mean << "\n"; + + // compute mean number of iterations for those converged + float output_number_iterations_mean = 0; + for (size_t i = 0; i != number_fits; i++) + { + if (output_states[i] == STATE_CONVERGED) + { + output_number_iterations_mean += static_cast(output_number_iterations[i]); + } + } + // normalize + output_number_iterations_mean /= static_cast(output_states_histogram[0]); + std::cout << "mean number of iterations " << output_number_iterations_mean << "\n"; + +} + +int main(int argc, char *argv[]) +{ + gauss_fit_2d_example(); + + std::cout << std::endl << "Example completed!" << std::endl; + std::cout << "Press ENTER to exit" << std::endl; + std::getchar(); + + return 0; +} diff --git a/Gpufit/examples/Linear_Regression_Example.cpp b/Gpufit/examples/Linear_Regression_Example.cpp new file mode 100644 index 0000000..e70e05d --- /dev/null +++ b/Gpufit/examples/Linear_Regression_Example.cpp @@ -0,0 +1,207 @@ +#include "../gpufit.h" + +#include +#include +#include +#include + +void linear_regression_example() +{ + /* + This example generates test data in form of 10000 one dimensional linear + curves with the size of 20 data points per curve. It is noised by normal + distributed noise. The initial guesses were randomized, within a specified + range of the true value. The LINEAR_1D model is fitted to the test data sets + using the LSE estimator. The optional parameter user_info is used to pass + custom x positions of the data sets. The same x position values are used for + every fit. + + The console output shows + - the ratio of converged fits including ratios of not converged fits for + different reasons, + - the values of the true parameters and the mean values of the fitted + parameters including their standard deviation, + - the mean chi square value + - and the mean number of iterations needed. + */ + + // number of fits, fit points and parameters + size_t const number_fits = 10000; + size_t const number_points = 20; + size_t const number_parameters = 2; + + // custom x positions for the data points of every fit, stored in user info + std::vector< float > user_info(number_points); + for (size_t i = 0; i < number_points; i++) + { + user_info[i] = static_cast(pow(2, i)); + } + + // size of user info in bytes + size_t const user_info_size = number_points * sizeof(float); + + // initialize random number generator + std::mt19937 rng; + rng.seed(0); + std::uniform_real_distribution< float > uniform_dist(0, 1); + std::normal_distribution< float > normal_dist(0, 1); + + // true parameters + std::vector< float > true_parameters { 5, 2 }; // offset, slope + + // initial parameters (randomized) + std::vector< float > initial_parameters(number_fits * number_parameters); + for (size_t i = 0; i != number_fits; i++) + { + // random offset + initial_parameters[i * number_parameters + 0] = true_parameters[0] * (0.8f + 0.4f * uniform_dist(rng)); + // random slope + initial_parameters[i * number_parameters + 1] = true_parameters[0] * (0.8f + 0.4f * uniform_dist(rng)); + } + + // generate data + std::vector< float > data(number_points * number_fits); + for (size_t i = 0; i != data.size(); i++) + { + size_t j = i / number_points; // the fit + size_t k = i % number_points; // the position within a fit + + float x = user_info[k]; + float y = true_parameters[0] + x * true_parameters[1]; + data[i] = y + normal_dist(rng); + } + + // tolerance + float const tolerance = 0.001f; + + // maximal number of iterations + int const max_number_iterations = 20; + + // estimator ID + int const estimator_id = LSE; + + // model ID + int const model_id = LINEAR_1D; + + // parameters to fit (all of them) + std::vector< int > parameters_to_fit(number_parameters, 1); + + // output parameters + std::vector< float > output_parameters(number_fits * number_parameters); + std::vector< int > output_states(number_fits); + std::vector< float > output_chi_square(number_fits); + std::vector< int > output_number_iterations(number_fits); + + // call to gpufit (C interface) + int const status = gpufit + ( + number_fits, + number_points, + data.data(), + 0, + model_id, + initial_parameters.data(), + tolerance, + max_number_iterations, + parameters_to_fit.data(), + estimator_id, + user_info_size, + reinterpret_cast< char * >( user_info.data() ), + output_parameters.data(), + output_states.data(), + output_chi_square.data(), + output_number_iterations.data() + ); + + // check status + if (status != STATUS_OK) + { + throw std::runtime_error(gpufit_get_last_error()); + } + + // get fit states + std::vector< int > output_states_histogram(5, 0); + for (std::vector< int >::iterator it = output_states.begin(); it != output_states.end(); ++it) + { + output_states_histogram[*it]++; + } + + std::cout << "ratio converged " << (float) output_states_histogram[0] / number_fits << "\n"; + std::cout << "ratio max iteration exceeded " << (float) output_states_histogram[1] / number_fits << "\n"; + std::cout << "ratio singular hessian " << (float) output_states_histogram[2] / number_fits << "\n"; + std::cout << "ratio neg curvature MLE " << (float) output_states_histogram[3] / number_fits << "\n"; + std::cout << "ratio gpu not read " << (float) output_states_histogram[4] / number_fits << "\n"; + + // compute mean fitted parameters for converged fits + std::vector< float > output_parameters_mean(number_parameters, 0); + for (size_t i = 0; i != number_fits; i++) + { + if (output_states[i] == STATE_CONVERGED) + { + // add offset + output_parameters_mean[0] += output_parameters[i * number_parameters + 0]; + // add slope + output_parameters_mean[1] += output_parameters[i * number_parameters + 1]; + } + } + output_parameters_mean[0] /= output_states_histogram[0]; + output_parameters_mean[1] /= output_states_histogram[0]; + + // compute std of fitted parameters for converged fits + std::vector< float > output_parameters_std(number_parameters, 0); + for (size_t i = 0; i != number_fits; i++) + { + if (output_states[i] == STATE_CONVERGED) + { + // add squared deviation for offset + output_parameters_std[0] += (output_parameters[i * number_parameters + 0] - output_parameters_mean[0]) * (output_parameters[i * number_parameters + 0] - output_parameters_mean[0]); + // add squared deviation for slope + output_parameters_std[1] += (output_parameters[i * number_parameters + 1] - output_parameters_mean[1]) * (output_parameters[i * number_parameters + 1] - output_parameters_mean[1]); + } + } + // divide and take square root + output_parameters_std[0] = sqrt(output_parameters_std[0] / output_states_histogram[0]); + output_parameters_std[1] = sqrt(output_parameters_std[1] / output_states_histogram[0]); + + // print mean and std + std::cout << "offset true " << true_parameters[0] << " mean " << output_parameters_mean[0] << " std " << output_parameters_std[0] << "\n"; + std::cout << "slope true " << true_parameters[1] << " mean " << output_parameters_mean[1] << " std " << output_parameters_std[1] << "\n"; + + // compute mean chi-square for those converged + float output_chi_square_mean = 0; + for (size_t i = 0; i != number_fits; i++) + { + if (output_states[i] == STATE_CONVERGED) + { + output_chi_square_mean += output_chi_square[i]; + } + } + output_chi_square_mean /= static_cast(output_states_histogram[0]); + std::cout << "mean chi square " << output_chi_square_mean << "\n"; + + // compute mean number of iterations for those converged + float output_number_iterations_mean = 0; + for (size_t i = 0; i != number_fits; i++) + { + if (output_states[i] == STATE_CONVERGED) + { + output_number_iterations_mean += static_cast(output_number_iterations[i]); + } + } + + // normalize + output_number_iterations_mean /= static_cast(output_states_histogram[0]); + std::cout << "mean number of iterations " << output_number_iterations_mean << "\n"; +} + + +int main(int argc, char *argv[]) +{ + linear_regression_example(); + + std::cout << std::endl << "Example completed!" << std::endl; + std::cout << "Press ENTER to exit" << std::endl; + std::getchar(); + + return 0; +} diff --git a/Gpufit/examples/Simple_Example.cpp b/Gpufit/examples/Simple_Example.cpp new file mode 100644 index 0000000..6d8ea91 --- /dev/null +++ b/Gpufit/examples/Simple_Example.cpp @@ -0,0 +1,94 @@ +#include "../gpufit.h" +#include +#include + +void simple_example() +{ + /* + Simple example demonstrating a minimal call of all needed parameters to + the C interface. It can be built and executed, but in this exeample + gpufit doesn't do anything useful and it doesn't yield meaningful + output. No test data is generated. The values of the input data vector + and the initial fit parameters vector are set to 0. + + This example can be devided in three parts: + - definition of input and output parameters + - call to gpufit + - status check + */ + + /*************** definition of input and output parameters ***************/ + + // number of fits, number of points per fit + size_t const number_fits = 10; + size_t const number_points = 10; + + // model ID and number of parameter + int const model_id = GAUSS_1D; + size_t const number_parameters = 4; + + // initial parameters + std::vector< float > initial_parameters(number_fits * number_parameters); + + // data + std::vector< float > data(number_points * number_fits); + + // tolerance + float const tolerance = 0.001f; + + // maximal number of iterations + int const max_number_iterations = 10; + + // estimator ID + int const estimator_id = LSE; + + // parameters to fit (all of them) + std::vector< int > parameters_to_fit(number_parameters, 1); + + // output parameters + std::vector< float > output_parameters(number_fits * number_parameters); + std::vector< int > output_states(number_fits); + std::vector< float > output_chi_square(number_fits); + std::vector< int > output_number_iterations(number_fits); + + /***************************** call to gpufit ****************************/ + + int const status = gpufit + ( + number_fits, + number_points, + data.data(), + 0, + model_id, + initial_parameters.data(), + tolerance, + max_number_iterations, + parameters_to_fit.data(), + estimator_id, + 0, + 0, + output_parameters.data(), + output_states.data(), + output_chi_square.data(), + output_number_iterations.data() + ); + + /****************************** status check *****************************/ + + if (status != STATUS_OK) + { + throw std::runtime_error(gpufit_get_last_error()); + } +} + + +int main(int argc, char *argv[]) +{ + simple_example(); + + std::cout << std::endl << "Example completed!" << std::endl; + std::cout << "Press ENTER to exit" << std::endl; + std::getchar(); + + return 0; +} diff --git a/Gpufit/gauss_1d.cuh b/Gpufit/gauss_1d.cuh new file mode 100644 index 0000000..5fefc55 --- /dev/null +++ b/Gpufit/gauss_1d.cuh @@ -0,0 +1,91 @@ +#ifndef GPUFIT_GAUSS1D_CUH_INCLUDED +#define GPUFIT_GAUSS1D_CUH_INCLUDED + +/* Description of the calculate_gauss1d function +* ============================================== +* +* This function calculates the values of one-dimensional gauss model functions +* and their partial derivatives with respect to the model parameters. +* +* No independent variables are passed to this model function. Hence, the +* (X) coordinate of the first data value is assumed to be (0.0). For +* a fit size of M data points, the (X) coordinates of the data are +* simply the corresponding array index values of the data array, starting from +* zero. +* +* Parameters: +* +* parameters: An input vector of concatenated sets of model parameters. +* p[0]: amplitude +* p[1]: center coordinate +* p[2]: width (standard deviation) +* p[3]: offset +* +* n_fits: The number of fits. (not used) +* +* n_points: The number of data points per fit. +* +* n_parameters: The number of model parameters. +* +* values: An output vector of concatenated sets of model function values. +* +* derivatives: An output vector of concatenated sets of model function partial +* derivatives. +* +* chunk_index: The chunk index. (not used) +* +* user_info: An input vector containing user information. (not used) +* +* user_info_size: The number of elements in user_info. (not used) +* +* Calling the calculate_gauss1d function +* ====================================== +* +* This __device__ function can be only called from a __global__ function or an other +* __device__ function. When calling the function, the blocks and threads of the __global__ +* function must be set up correctly, as shown in the following example code. +* +* dim3 threads(1, 1, 1); +* dim3 blocks(1, 1, 1); +* +* threads.x = n_points * n_fits_per_block; +* blocks.x = n_fits / n_fits_per_block; +* +* global_function<<< blocks,threads >>>(parameter1, ...); +* +*/ + +__device__ void calculate_gauss1d( + float const * parameters, + int const n_fits, + int const n_points, + int const n_parameters, + float * values, + float * derivatives, + int const chunk_index, + char * user_info, + std::size_t const user_info_size) +{ + int const n_fits_per_block = blockDim.x / n_points; + int const fit_in_block = threadIdx.x / n_points; + int const point_index = threadIdx.x - (fit_in_block*n_points); + int const fit_index = blockIdx.x*n_fits_per_block + fit_in_block; + + float * current_value = &values[fit_index * n_points]; + float const * p = ¶meters[fit_index * n_parameters]; + + float const argx = (point_index - p[1]) * (point_index - p[1]) / (2 * p[2] * p[2]); + float const ex = exp(-argx); + current_value[point_index] = p[0] * ex + p[3]; + + // derivatives + + float * current_derivative = &derivatives[fit_index * n_points * n_parameters + point_index]; + + current_derivative[0] = ex; + current_derivative[1 * n_points] = p[0] * ex * (point_index - p[1]) / (p[2] * p[2]); + current_derivative[2 * n_points] = p[0] * ex * (point_index - p[1]) * (point_index - p[1]) / (p[2] * p[2] * p[2]); + current_derivative[3 * n_points] = 1.f; +} + +#endif diff --git a/Gpufit/gauss_2d.cuh b/Gpufit/gauss_2d.cuh new file mode 100644 index 0000000..0448cfa --- /dev/null +++ b/Gpufit/gauss_2d.cuh @@ -0,0 +1,97 @@ +#ifndef GPUFIT_GAUSS2D_CUH_INCLUDED +#define GPUFIT_GAUSS2D_CUH_INCLUDED + +/* Description of the calculate_gauss2d function +* ============================================== +* +* This function calculates the values of two-dimensional gauss model functions +* and their partial derivatives with respect to the model parameters. +* +* No independent variables are passed to this model function. Hence, the +* (X, Y) coordinate of the first data value is assumed to be (0.0, 0.0). For +* a fit size of M x N data points, the (X, Y) coordinates of the data are +* simply the corresponding array index values of the data array, starting from +* zero. +* +* Parameters: +* +* parameters: An input vector of concatenated sets of model parameters. +* p[0]: amplitude +* p[1]: center coordinate x +* p[2]: center coordinate y +* p[3]: width (standard deviation; equal width in x and y dimensions) +* p[4]: offset +* +* n_fits: The number of fits. (not used) +* +* n_points: The number of data points per fit. +* +* n_parameters: The number of model parameters. +* +* values: An output vector of concatenated sets of model function values. +* +* derivatives: An output vector of concatenated sets of model function partial +* derivatives. +* +* chunk_index: The chunk index. (not used) +* +* user_info: An input vector containing user information. (not used) +* +* user_info_size: The number of elements in user_info. (not used) +* +* Calling the calculate_gauss2d function +* ====================================== +* +* This __device__ function can be only called from a __global__ function or an other +* __device__ function. When calling the function, the blocks and threads of the __global__ +* function must be set up correctly, as shown in the following example code. +* +* dim3 threads(1, 1, 1); +* dim3 blocks(1, 1, 1); +* +* threads.x = n_points * n_fits_per_block; +* blocks.x = n_fits / n_fits_per_block; +* +* global_function<<< blocks,threads >>>(parameter1, ...); +* +*/ + +__device__ void calculate_gauss2d( + float const * parameters, + int const n_fits, + int const n_points, + int const n_parameters, + float * values, + float * derivatives, + int const chunk_index, + char * user_info, + std::size_t const user_info_size) +{ + int const n_points_x = sqrt((float)n_points); + int const n_fits_per_block = blockDim.x / n_points; + int const fit_in_block = threadIdx.x / n_points; + int const point_index = threadIdx.x - fit_in_block * n_points; + int const fit_index = blockIdx.x * n_fits_per_block + fit_in_block; + int const point_index_y = point_index / n_points_x; + int const point_index_x = point_index - point_index_y * n_points_x; + + float* current_value = &values[fit_index * n_points]; + float const * p = ¶meters[fit_index * n_parameters]; + + float const argx = (point_index_x - p[1]) * (point_index_x - p[1]) / (2 * p[3] * p[3]); + float const argy = (point_index_y - p[2]) * (point_index_y - p[2]) / (2 * p[3] * p[3]); + float const ex = exp(-(argx + argy)); + current_value[point_index] = p[0] * ex + p[4]; + + // derivatives + + float * current_derivative = &derivatives[fit_index * n_points * n_parameters + point_index]; + + current_derivative[0] = ex; + current_derivative[1 * n_points] = p[0] * ex * (point_index_x - p[1]) / (p[3] * p[3]); + current_derivative[2 * n_points] = p[0] * ex * (point_index_y - p[2]) / (p[3] * p[3]); + current_derivative[3 * n_points] = ex * p[0] * ((point_index_x - p[1]) * (point_index_x - p[1]) + (point_index_y - p[2]) * (point_index_y - p[2])) / (p[3] * p[3] * p[3]); + current_derivative[4 * n_points] = 1; +} + +#endif diff --git a/Gpufit/gauss_2d_elliptic.cuh b/Gpufit/gauss_2d_elliptic.cuh new file mode 100644 index 0000000..5417667 --- /dev/null +++ b/Gpufit/gauss_2d_elliptic.cuh @@ -0,0 +1,100 @@ +#ifndef GPUFIT_GAUSS2DELLIPTIC_CUH_INCLUDED +#define GPUFIT_GAUSS2DELLIPTIC_CUH_INCLUDED + +/* Description of the calculate_gauss2delliptic function +* ====================================================== +* +* This function calculates the values of two-dimensional elliptic gauss model +* functions and their partial derivatives with respect to the model parameters. +* +* No independent variables are passed to this model function. Hence, the +* (X, Y) coordinate of the first data value is assumed to be (0.0, 0.0). For +* a fit size of M x N data points, the (X, Y) coordinates of the data are +* simply the corresponding array index values of the data array, starting from +* zero. +* +* Parameters: +* +* parameters: An input vector of concatenated sets of model parameters. +* p[0]: amplitude +* p[1]: center coordinate x +* p[2]: center coordinate y +* p[3]: width x (standard deviation) +* p[4]: width y (standard deviation) +* p[5]: offset +* +* n_fits: The number of fits. (not used) +* +* n_points: The number of data points per fit. +* +* n_parameters: The number of model parameters. +* +* values: An output vector of concatenated sets of model function values. +* +* derivatives: An output vector of concatenated sets of model function partial +* derivatives. +* +* chunk_index: The chunk index. (not used) +* +* user_info: An input vector containing user information. (not used) +* +* user_info_size: The number of elements in user_info. (not used) +* +* Calling the calculate_gauss2delliptic function +* ============================================== +* +* This __device__ function can be only called from a __global__ function or an other +* __device__ function. When calling the function, the blocks and threads of the __global__ +* function must be set up correctly, as shown in the following example code. +* +* dim3 threads(1, 1, 1); +* dim3 blocks(1, 1, 1); +* +* threads.x = n_points * n_fits_per_block; +* blocks.x = n_fits / n_fits_per_block; +* +* global_function<<< blocks,threads >>>(parameter1, ...); +* +*/ + +__device__ void calculate_gauss2delliptic( + float const * parameters, + int const n_fits, + int const n_points, + int const n_parameters, + float * values, + float * derivatives, + int const chunk_index, + char * user_info, + std::size_t const user_info_size) +{ + int const n_points_x = sqrt((float)n_points); + int const n_fits_per_block = blockDim.x / n_points; + int const fit_in_block = threadIdx.x / n_points; + int const point_index = threadIdx.x - (fit_in_block*n_points); + int const fit_index = blockIdx.x * n_fits_per_block + fit_in_block; + + int const point_index_y = point_index / n_points_x; + int const point_index_x = point_index - point_index_y * n_points_x; + + float* current_value = &values[fit_index * n_points]; + float const * p = ¶meters[fit_index * n_parameters]; + + float const argx = (point_index_x - p[1]) * (point_index_x - p[1]) / (2 * p[3] * p[3]); + float const argy = (point_index_y - p[2]) * (point_index_y - p[2]) / (2 * p[4] * p[4]); + float const ex = exp(-(argx + argy)); + current_value[point_index] = p[0] * ex + p[5]; + + // derivatives + + float * current_derivative = &derivatives[fit_index * n_points * n_parameters + point_index]; + + current_derivative[0] = ex; + current_derivative[1 * n_points] = p[0] * ex * (point_index_x - p[1]) / (p[3] * p[3]); + current_derivative[2 * n_points] = p[0] * ex * (point_index_y - p[2]) / (p[4] * p[4]); + current_derivative[3 * n_points] = p[0] * ex * (point_index_x - p[1]) * (point_index_x - p[1]) / (p[3] * p[3] * p[3]); + current_derivative[4 * n_points] = p[0] * ex * (point_index_y - p[2]) * (point_index_y - p[2]) / (p[4] * p[4] * p[4]); + current_derivative[5 * n_points] = 1; +} + +#endif diff --git a/Gpufit/gauss_2d_rotated.cuh b/Gpufit/gauss_2d_rotated.cuh new file mode 100644 index 0000000..09d042f --- /dev/null +++ b/Gpufit/gauss_2d_rotated.cuh @@ -0,0 +1,106 @@ +#ifndef GPUFIT_GAUSS2DROTATED_CUH_INCLUDED +#define GPUFIT_GAUSS2DROTATED_CUH_INCLUDED + +/* Description of the calculate_gauss2drotated function +* ===================================================== +* +* This function calculates the values of two-dimensional elliptic gauss model +* functions including a rotation parameter and their partial derivatives with +* respect to the model parameters. +* +* No independent variables are passed to this model function. Hence, the +* (X, Y) coordinate of the first data value is assumed to be (0.0, 0.0). For +* a fit size of M x N data points, the (X, Y) coordinates of the data are +* simply the corresponding array index values of the data array, starting from +* zero. +* +* Parameters: +* +* parameters: An input vector of concatenated sets of model parameters. +* p[0]: amplitude +* p[1]: center coordinate x +* p[2]: center coordinate y +* p[3]: width x (standard deviation) +* p[4]: width y (standard deviation) +* p[5]: offset +* p[6]: rotation angle [radians] +* +* n_fits: The number of fits. (not used) +* +* n_points: The number of data points per fit. +* +* n_parameters: The number of model parameters. +* +* values: An output vector of concatenated sets of model function values. +* +* derivatives: An output vector of concatenated sets of model function partial +* derivatives. +* +* chunk_index: The chunk index. (not used) +* +* user_info: An input vector containing user information. (not used) +* +* user_info_size: The number of elements in user_info. (not used) +* +* Calling the calculate_gauss2drotated function +* ============================================= +* +* This __device__ function can be only called from a __global__ function or an other +* __device__ function. When calling the function, the blocks and threads of the __global__ +* function must be set up correctly, as shown in the following example code. +* +* dim3 threads(1, 1, 1); +* dim3 blocks(1, 1, 1); +* +* threads.x = n_points * n_fits_per_block; +* blocks.x = n_fits / n_fits_per_block; +* +* global_function<<< blocks,threads >>>(parameter1, ...); +* +*/ + +__device__ void calculate_gauss2drotated( + float const * parameters, + int const n_fits, + int const n_points, + int const n_parameters, + float * values, + float * derivatives, + int const chunk_index, + char * user_info, + std::size_t const user_info_size) +{ + int const n_points_x = sqrt((float)n_points); + int const n_fits_per_block = blockDim.x / n_points; + int const fit_in_block = threadIdx.x / n_points; + int const point_index = threadIdx.x - (fit_in_block*n_points); + int const fit_index = blockIdx.x * n_fits_per_block + fit_in_block; + + int const point_index_y = point_index / n_points_x; + int const point_index_x = point_index - point_index_y * n_points_x; + + float* current_value = &values[fit_index * n_points]; + float const * p = ¶meters[fit_index * n_parameters]; + + float const cosp6 = cosf(p[6]); + float const sinp6 = sinf(p[6]); + + float const arga = (point_index_x - p[1]) * cosp6 - (point_index_y - p[2]) * sinp6; + float const argb = (point_index_x - p[1]) * sinp6 + (point_index_y - p[2]) * cosp6; + float const ex = exp(-0.5 * (((arga / p[3]) * (arga / p[3])) + ((argb / p[4]) * (argb / p[4])))); + current_value[point_index] = p[0] * ex + p[5]; + + // derivatives + + float * current_derivative = &derivatives[fit_index * n_points * n_parameters + point_index]; + + current_derivative[0] = ex; + current_derivative[1 * n_points] = (((p[0] * cosp6 * arga) / (p[3] * p[3])) + ((p[0] * sinp6 * argb) / (p[4] * p[4]))) * ex; + current_derivative[2 * n_points] = (((-p[0] * sinp6 * arga) / (p[3] * p[3])) + ((p[0] * cosp6 * argb) / (p[4] * p[4]))) * ex; + current_derivative[3 * n_points] = p[0] * arga * arga / (p[3] * p[3] * p[3]) * ex; + current_derivative[4 * n_points] = p[0] * argb * argb / (p[4] * p[4] * p[4]) * ex; + current_derivative[5 * n_points] = 1; + current_derivative[6 * n_points] = p[0] * arga * argb * (1.0 / (p[3] * p[3]) - 1.0 / (p[4] * p[4])) * ex; +} + +#endif diff --git a/Gpufit/gpu_data.cu b/Gpufit/gpu_data.cu new file mode 100644 index 0000000..afbca05 --- /dev/null +++ b/Gpufit/gpu_data.cu @@ -0,0 +1,175 @@ +#include "gpu_data.cuh" +#include +#include + +GPUData::GPUData(Info const & info) : + chunk_size_(0), + info_(info), + + data_( info_.max_chunk_size_*info_.n_points_ ), + weights_( info_.use_weights_ ? info_.n_points_ * info_.max_chunk_size_ : 0 ), + parameters_( info_.max_chunk_size_*info_.n_parameters_ ), + prev_parameters_( info_.max_chunk_size_*info_.n_parameters_ ), + parameters_to_fit_indices_( info_.n_parameters_to_fit_ ), + user_info_( info_.user_info_size_ ), + + chi_squares_( info_.max_chunk_size_ ), + prev_chi_squares_( info_.max_chunk_size_ ), + gradients_( info_.max_chunk_size_ * info_.n_parameters_to_fit_ ), + hessians_( info_.max_chunk_size_ * info_.n_parameters_to_fit_ * info_.n_parameters_to_fit_ ), + deltas_(info_.max_chunk_size_ * info_.n_parameters_to_fit_), + + values_( info_.max_chunk_size_ * info_.n_points_ ), + derivatives_( info_.max_chunk_size_ * info_.n_points_ * info_.n_parameters_ ), + + lambdas_( info_.max_chunk_size_ ), + states_( info_.max_chunk_size_ ), + finished_( info_.max_chunk_size_ ), + iteration_falied_(info_.max_chunk_size_), + all_finished_( 1 ), + n_iterations_( info_.max_chunk_size_ ) +{ + +} + +void GPUData::reset(int const chunk_size) +{ + chunk_size_ = chunk_size; + + set(data_, 0.f, chunk_size_ * info_.n_points_); + if (info_.use_weights_) + set(weights_, 0.f, chunk_size_ * info_.n_points_); + set(parameters_, 0.f, chunk_size_ * info_.n_parameters_); + set(prev_parameters_, 0.f, chunk_size_ * info_.n_parameters_); + set(parameters_to_fit_indices_, 0, info_.n_parameters_to_fit_); + + set(chi_squares_, 0.f, chunk_size_); + set(prev_chi_squares_, 0.f, chunk_size_); + set(gradients_, 0.f, chunk_size_ * info_.n_parameters_to_fit_); + set(hessians_, 0.f, chunk_size_ * info_.n_parameters_to_fit_ * info_.n_parameters_to_fit_); + set(deltas_, 0.f, chunk_size_ * info_.n_parameters_to_fit_); + + set(values_, 0.f, chunk_size_*info_.n_points_); + set(derivatives_, 0.f, chunk_size_ * info_.n_points_ * info_.n_parameters_); + + set(lambdas_, 0.f, chunk_size_); + set(states_, 0, chunk_size_); + set(finished_, 0, chunk_size_); + set(iteration_falied_, 0, chunk_size_); + set(all_finished_, 0, 1); + set(n_iterations_, 0, chunk_size_); +} + +void GPUData::init +( + int const chunk_index, + float const * const data, + float const * const weights, + float const * const initial_parameters, + std::vector const & parameters_to_fit_indices) +{ + chunk_index_ = chunk_index; + write( + data_, + &data[chunk_index_*info_.max_chunk_size_*info_.n_points_], + chunk_size_*info_.n_points_); + if (info_.use_weights_) + write(weights_, &weights[chunk_index_*info_.max_chunk_size_*info_.n_points_], + chunk_size_*info_.n_points_); + write( + parameters_, + &initial_parameters[chunk_index_*info_.max_chunk_size_*info_.n_parameters_], + chunk_size_ * info_.n_parameters_); + write(parameters_to_fit_indices_, parameters_to_fit_indices); + + set(lambdas_, 0.001f, chunk_size_); +} + +void GPUData::init_user_info(char const * const user_info) +{ + if (info_.user_info_size_ > 0) + write(user_info_, user_info, info_.user_info_size_); +} + +void GPUData::read(bool * dst, int const * src) +{ + int int_dst = 0; + CUDA_CHECK_STATUS(cudaMemcpy(&int_dst, src, sizeof(int), cudaMemcpyDeviceToHost)); + * dst = (int_dst == 1) ? true : false; +} + +void GPUData::write(float* dst, float const * src, int const count) +{ + CUDA_CHECK_STATUS(cudaMemcpy(dst, src, count * sizeof(float), cudaMemcpyHostToDevice)); +} + +void GPUData::write(int* dst, std::vector const & src) +{ + std::size_t const size = src.size() * sizeof(int); + CUDA_CHECK_STATUS(cudaMemcpy(dst, src.data(), size, cudaMemcpyHostToDevice)); +} + +void GPUData::write(char* dst, char const * src, std::size_t const count) +{ + CUDA_CHECK_STATUS(cudaMemcpy(dst, src, count * sizeof(char), cudaMemcpyHostToDevice)); +} + +void GPUData::copy(float * dst, float const * src, std::size_t const count) +{ + CUDA_CHECK_STATUS(cudaMemcpy(dst, src, count * sizeof(float), cudaMemcpyDeviceToDevice)); +} + +__global__ void set_kernel(int* dst, int const value, int const count) +{ + int const index = blockIdx.x * blockDim.x + threadIdx.x; + + if (index >= count) + return; + + dst[index] = value; +} + +void GPUData::set(int* arr, int const value, int const count) +{ + int const tx = 256; + int const bx = (count / tx) + 1; + + dim3 threads(tx, 1, 1); + dim3 blocks(bx, 1, 1); + + set_kernel<<< blocks, threads >>>(arr, value, count); + CUDA_CHECK_STATUS(cudaGetLastError()); +} + +void GPUData::set(int* arr, int const value) +{ + int const tx = 1; + int const bx = 1; + + dim3 threads(tx, 1, 1); + dim3 blocks(bx, 1, 1); + + set_kernel<<< blocks, threads >>>(arr, value, 1); + CUDA_CHECK_STATUS(cudaGetLastError()); +} + +__global__ void set_kernel(float* dst, float const value, std::size_t const count) +{ + std::size_t const index = blockIdx.x * blockDim.x + threadIdx.x; + + if (index >= count) + return; + + dst[index] = value; +} + +void GPUData::set(float* arr, float const value, int const count) +{ + int const tx = 256; + int const bx = (count / tx) + 1; + + dim3 threads(tx, 1, 1); + dim3 blocks(bx, 1, 1); + set_kernel<<< blocks, threads >>>(arr, value, count); + CUDA_CHECK_STATUS(cudaGetLastError()); +} diff --git a/Gpufit/gpu_data.cuh b/Gpufit/gpu_data.cuh new file mode 100644 index 0000000..b35f09d --- /dev/null +++ b/Gpufit/gpu_data.cuh @@ -0,0 +1,122 @@ +#ifndef GPUFIT_GPU_DATA_CUH_INCLUDED +#define GPUFIT_GPU_DATA_CUH_INCLUDED + +#include "info.h" + +#include + +#include +#include +#include + +template< typename Type > +struct Device_Array +{ + explicit Device_Array( std::size_t const size ) + { + std::size_t const maximum_size = std::numeric_limits< std::size_t >::max() ; + std::size_t const type_size = sizeof( Type ) ; + if (size <= maximum_size / type_size) + { + cudaError_t const status = cudaMalloc( & data_, size * type_size ) ; + if (status == cudaSuccess) + { + return ; + } + else + { + throw std::runtime_error( cudaGetErrorString( status ) ) ; + } + } + else + { + throw std::runtime_error( "maximum array size exceeded" ) ; + } + } + + ~Device_Array() { cudaFree( data_ ) ; } + + operator Type * () { return static_cast< Type * >( data_ ) ; } + operator Type const * () const { return static_cast< Type * >( data_ ) ; } + + Type * copy( std::size_t const size, Type * const to ) const + { + /// \todo check size parameter + + std::size_t const type_size = sizeof( Type ) ; + cudaError_t const status + = cudaMemcpy( to, data_, size * type_size, cudaMemcpyDeviceToHost ) ; + if (status == cudaSuccess) + { + return to + size ; + } + else + { + throw std::runtime_error( cudaGetErrorString( status ) ) ; + } + } + +private: + void * data_ ; +} ; + +class GPUData +{ +public: + GPUData(Info const & info); + + void reset(int const chunk_size); + void init + ( + int const chunk_index, + float const * data, + float const * weights, + float const * initial_parameters, + std::vector const & parameters_to_fit_indices + ) ; + void init_user_info(char const * user_info); + + void read(bool * dst, int const * src); + void set(int* arr, int const value); + void copy(float * dst, float const * src, std::size_t const count); + +private: + void set(float* arr, float const value, int const count); + void set(int* arr, int const value, int const count); + void write(float* dst, float const * src, int const count); + void write(int* dst, std::vector const & src); + void write(char* dst, char const * src, std::size_t const count); + +private: + int chunk_size_; + Info const & info_; + +public: + int chunk_index_; + + Device_Array< float > data_; + Device_Array< float > weights_; + Device_Array< float > parameters_; + Device_Array< float > prev_parameters_; + Device_Array< int > parameters_to_fit_indices_; + Device_Array< char > user_info_; + + Device_Array< float > chi_squares_; + Device_Array< float > prev_chi_squares_; + Device_Array< float > gradients_; + Device_Array< float > hessians_; + Device_Array< float > deltas_; + + + Device_Array< float > values_; + Device_Array< float > derivatives_; + + Device_Array< float > lambdas_; + Device_Array< int > states_; + Device_Array< int > finished_; + Device_Array< int > iteration_falied_; + Device_Array< int > all_finished_; + Device_Array< int > n_iterations_; +}; + +#endif diff --git a/Gpufit/gpufit.cpp b/Gpufit/gpufit.cpp new file mode 100644 index 0000000..e7f2d31 --- /dev/null +++ b/Gpufit/gpufit.cpp @@ -0,0 +1,130 @@ +#include "gpufit.h" +#include "interface.h" + +#include + +std::string last_error ; + +int gpufit +( + size_t n_fits, + size_t n_points, + float * data, + float * weights, + int model_id, + float * initial_parameters, + float tolerance, + int max_n_iterations, + int * parameters_to_fit, + int estimator_id, + size_t user_info_size, + char * user_info, + float * output_parameters, + int * output_states, + float * output_chi_squares, + int * output_n_iterations +) +try +{ + __int32 n_points_32 = 0; + if (n_points <= (unsigned int)(std::numeric_limits<__int32>::max())) + { + n_points_32 = __int32(n_points); + } + else + { + throw std::runtime_error("maximum number of data points per fit exceeded"); + } + + FitInterface fi( + data, + weights, + n_fits, + n_points_32, + tolerance, + max_n_iterations, + estimator_id, + initial_parameters, + parameters_to_fit, + user_info, + user_info_size, + output_parameters, + output_states, + output_chi_squares, + output_n_iterations); + + fi.fit(model_id); + + return STATUS_OK ; +} +catch( std::exception & exception ) +{ + last_error = exception.what() ; + + return STATUS_ERROR ; +} +catch( ... ) +{ + last_error = "unknown error" ; + + return STATUS_ERROR; +} + +char const * gpufit_get_last_error() +{ + return last_error.c_str() ; +} + +int gpufit_cuda_available() +{ + try + { + getDeviceCount(); + return 1; + } + catch (std::exception & exception) + { + last_error = exception.what(); + + return 0; + } +} + +int gpufit_get_cuda_version(int * runtime_version, int * driver_version) +{ + try + { + cudaRuntimeGetVersion(runtime_version); + cudaDriverGetVersion(driver_version); + return 1; + } + catch (std::exception & exception) + { + last_error = exception.what(); + + return 0; + } +} + +int gpufit_portable_interface(int argc, void *argv[]) +{ + + return gpufit( + *((size_t *) argv[0]), + *((size_t *) argv[1]), + (float *) argv[2], + (float *) argv[3], + *((int *) argv[4]), + (float *) argv[5], + *((float *) argv[6]), + *((int *) argv[7]), + (int *) argv[8], + *((int *) argv[9]), + *((size_t *) argv[10]), + (char *) argv[11], + (float *) argv[12], + (int *) argv[13], + (float *) argv[14], + (int *) argv[15]); + +} \ No newline at end of file diff --git a/Gpufit/gpufit.h b/Gpufit/gpufit.h new file mode 100644 index 0000000..985e6d7 --- /dev/null +++ b/Gpufit/gpufit.h @@ -0,0 +1,63 @@ +#ifndef GPU_FIT_H_INCLUDED +#define GPU_FIT_H_INCLUDED + +// fitting model ID +#define GAUSS_1D 0 +#define GAUSS_2D 1 +#define GAUSS_2D_ELLIPTIC 2 +#define GAUSS_2D_ROTATED 3 +#define CAUCHY_2D_ELLIPTIC 4 +#define LINEAR_1D 5 + +// estimator ID +#define LSE 0 +#define MLE 1 + +// fit state +#define STATE_CONVERGED 0 +#define STATE_MAX_ITERATION 1 +#define STATE_SINGULAR_HESSIAN 2 +#define STATE_NEG_CURVATURE_MLE 3 +#define STATE_GPU_NOT_READY 4 + +// gpufit return state +#define STATUS_OK 0 +#define STATUS_ERROR -1 + +#ifdef __cplusplus +extern "C" { +#endif + +int gpufit +( + size_t n_fits, + size_t n_points, + float * data, + float * weights, + int model_id, + float * initial_parameters, + float tolerance, + int max_n_iterations, + int * parameters_to_fit, + int estimator_id, + size_t user_info_size, + char * user_info, + float * output_parameters, + int * output_states, + float * output_chi_squares, + int * output_n_iterations +) ; + +char const * gpufit_get_last_error() ; + +int gpufit_cuda_available(); + +int gpufit_get_cuda_version(int * runtime_version, int * driver_version); + +int gpufit_portable_interface(int argc, void *argv[]); + +#ifdef __cplusplus +} +#endif + +#endif // GPU_FIT_H_INCLUDED diff --git a/Gpufit/info.cpp b/Gpufit/info.cpp new file mode 100644 index 0000000..e2fecca --- /dev/null +++ b/Gpufit/info.cpp @@ -0,0 +1,124 @@ +#include "info.h" +#include + +Info::Info() : + n_parameters_(0), + n_parameters_to_fit_(0), + max_chunk_size_(0), + max_n_iterations_(0), + n_points_(0), + power_of_two_n_points_(0), + n_fits_(0), + user_info_size_(0), + n_fits_per_block_(0), + model_id_(0), + estimator_id_(0), + max_threads_(0), + max_blocks_(0), + available_gpu_memory_(0) +{ +} + +Info::~Info(void) +{ +} + +void Info::set_number_of_parameters_to_fit(int const * const parameters_to_fit) +{ + n_parameters_to_fit_ = n_parameters_; + + for (int i = 0; i < n_parameters_; i++) + { + if (!parameters_to_fit[i]) + { + n_parameters_to_fit_--; + } + } +} + +void Info::set_fits_per_block(std::size_t const current_chunk_size) +{ + n_fits_per_block_ = 8; + bool is_divisible = false; + bool enough_threads = false; + do + { + n_fits_per_block_ /= 2; + is_divisible = current_chunk_size % n_fits_per_block_ == 0; + enough_threads = n_fits_per_block_ * n_points_ < max_threads_ / 4; + } while ((!is_divisible || !enough_threads) && n_fits_per_block_ > 1); +} + +void Info::set_max_chunk_size() +{ + int one_fit_memory + = sizeof(float) + *(2 * n_points_ + + 2 * n_parameters_ + + 2 * n_parameters_to_fit_ + + 1 * n_parameters_to_fit_*n_parameters_to_fit_ + + 1 * n_points_*n_parameters_ + + 4) + + sizeof(int) + * 3; + + if (use_weights_) + one_fit_memory += sizeof(float) * n_points_; + + std::size_t tmp_chunk_size = available_gpu_memory_ / one_fit_memory; + + if (tmp_chunk_size == 0) + { + throw std::runtime_error("not enough free GPU memory available"); + } + + tmp_chunk_size = (std::min)(tmp_chunk_size, max_blocks_); + + std::size_t highest_factor = 1; + + if (n_parameters_to_fit_) + { + highest_factor + = n_points_ + * n_parameters_to_fit_ + * n_parameters_to_fit_ + * sizeof(float); + } + else + { + highest_factor = n_points_ * n_parameters_; + } + + std::size_t const highest_size_t_value + = std::numeric_limits< std::size_t >::max(); + + if (tmp_chunk_size > highest_size_t_value / highest_factor) + { + tmp_chunk_size = highest_size_t_value / highest_factor; + } + + max_chunk_size_ = tmp_chunk_size; + + int i = 1; + int const divisor = 10; + while (tmp_chunk_size > divisor) + { + i *= divisor; + tmp_chunk_size /= divisor; + } + max_chunk_size_ = max_chunk_size_ / i * i; + max_chunk_size_ = std::min(max_chunk_size_, n_fits_); +} + + +void Info::configure() +{ + power_of_two_n_points_ = 1; + while (power_of_two_n_points_ < n_points_) + { + power_of_two_n_points_ *= 2; + } + + get_gpu_properties(); + set_max_chunk_size(); +} diff --git a/Gpufit/info.cu b/Gpufit/info.cu new file mode 100644 index 0000000..60568f8 --- /dev/null +++ b/Gpufit/info.cu @@ -0,0 +1,31 @@ +#include "info.h" +#include + +void Info::get_gpu_properties() +{ + cudaDeviceProp devProp; + CUDA_CHECK_STATUS(cudaGetDeviceProperties(&devProp, 0)); + max_threads_ = devProp.maxThreadsPerBlock; + max_blocks_ = devProp.maxGridSize[0]; + + std::size_t free_bytes; + std::size_t total_bytes; + CUDA_CHECK_STATUS(cudaMemGetInfo(&free_bytes, &total_bytes)); + available_gpu_memory_ = std::size_t(double(free_bytes) * 0.1); + + if (available_gpu_memory_ > user_info_size_) + { + available_gpu_memory_ -= user_info_size_; + } + else + { + throw std::runtime_error("maximum user info size exceeded"); + } +} + +int getDeviceCount() +{ + int deviceCount; + CUDA_CHECK_STATUS(cudaGetDeviceCount(&deviceCount)); + return deviceCount; +} \ No newline at end of file diff --git a/Gpufit/info.h b/Gpufit/info.h new file mode 100644 index 0000000..3f17623 --- /dev/null +++ b/Gpufit/info.h @@ -0,0 +1,48 @@ +#ifndef GPUFIT_PARAMETERS_H_INCLUDED +#define GPUFIT_PARAMETERS_H_INCLUDED + +#include "definitions.h" +#include + + +class Info +{ +public: + Info(); + virtual ~Info(); + + void set_fits_per_block(std::size_t const n_fits); + void set_number_of_parameters_to_fit(int const * parameters_to_fit); + void configure(); + +private: + void get_gpu_properties(); + void set_max_chunk_size(); + +public: + int n_parameters_; + int n_parameters_to_fit_; + + int n_points_; + int power_of_two_n_points_; + + std::size_t n_fits_; + + std::size_t user_info_size_; + + int max_n_iterations_; + std::size_t max_chunk_size_; + int n_fits_per_block_; + int model_id_; + int estimator_id_; + bool use_weights_; + +private: + int max_threads_; + std::size_t max_blocks_; + std::size_t available_gpu_memory_; +}; + +int getDeviceCount(); + +#endif diff --git a/Gpufit/interface.cpp b/Gpufit/interface.cpp new file mode 100644 index 0000000..e8ddac3 --- /dev/null +++ b/Gpufit/interface.cpp @@ -0,0 +1,123 @@ +#include "gpufit.h" +#include "interface.h" + +FitInterface::FitInterface +( + float const * data, + float const * weights, + std::size_t n_fits, + int n_points, + float tolerance, + int max_n_iterations, + int estimator_id, + float const * initial_parameters, + int * parameters_to_fit, + char * user_info, + std::size_t user_info_size, + float * output_parameters, + int * output_states, + float * output_chi_squares, + int * output_n_iterations +) : + data_( data ), + weights_( weights ), + initial_parameters_( initial_parameters ), + parameters_to_fit_( parameters_to_fit ), + user_info_( user_info ), + n_fits_(n_fits), + n_points_(n_points), + tolerance_(tolerance), + max_n_iterations_(max_n_iterations), + estimator_id_(estimator_id), + user_info_size_(user_info_size), + output_parameters_( output_parameters ), + output_states_(output_states), + output_chi_squares_(output_chi_squares), + output_n_iterations_(output_n_iterations), + n_parameters_(0) +{} + +FitInterface::~FitInterface() +{} + +void FitInterface::check_sizes() +{ + std::size_t maximum_size = std::numeric_limits< std::size_t >::max(); + + if (n_fits_ > maximum_size / n_points_ / sizeof(float)) + { + throw std::runtime_error("maximum absolute number of data points exceeded"); + } + + if (n_fits_ > maximum_size / n_parameters_ / sizeof(float)) + { + throw std::runtime_error("maximum number of fits and/or parameters exceeded"); + } +} + +void FitInterface::set_number_of_parameters(int const model_id) +{ + switch (model_id) + { + case GAUSS_1D: + n_parameters_ = 4; + break; + case GAUSS_2D: + n_parameters_ = 5; + break; + case GAUSS_2D_ELLIPTIC: + n_parameters_ = 6; + break; + case GAUSS_2D_ROTATED: + n_parameters_ = 7; + break; + case CAUCHY_2D_ELLIPTIC: + n_parameters_ = 6; + break; + case LINEAR_1D: + n_parameters_ = 2; + break; + default: + break; + } +} + +void FitInterface::configure_info(Info & info, int const model_id) +{ + info.model_id_ = model_id; + info.n_fits_ = n_fits_; + info.n_points_ = n_points_; + info.max_n_iterations_ = max_n_iterations_; + info.estimator_id_ = estimator_id_; + info.user_info_size_ = user_info_size_; + info.n_parameters_ = n_parameters_; + info.use_weights_ = weights_ ? true : false; + + info.set_number_of_parameters_to_fit(parameters_to_fit_); + info.configure(); +} + +void FitInterface::fit(int const model_id) +{ + set_number_of_parameters(model_id); + + check_sizes(); + + Info info; + configure_info(info, model_id); + + LMFit lmfit + ( + data_, + weights_, + info, + initial_parameters_, + parameters_to_fit_, + user_info_, + output_parameters_, + output_states_, + output_chi_squares_, + output_n_iterations_ + ) ; + lmfit.run(tolerance_); +} diff --git a/Gpufit/interface.h b/Gpufit/interface.h new file mode 100644 index 0000000..27814aa --- /dev/null +++ b/Gpufit/interface.h @@ -0,0 +1,63 @@ +#ifndef GPUFIT_INTERFACE_H_INCLUDED +#define GPUFIT_INTERFACE_H_INCLUDED + +#include "lm_fit.h" + +static_assert( sizeof( int ) == 4, "32 bit 'int' type required" ) ; + +class FitInterface +{ +public: + FitInterface + ( + float const * data, + float const * weights, + std::size_t n_fits, + int n_points, + float tolerance, + int max_n_iterations, + int estimator_id, + float const * initial_parameters, + int * parameters_to_fit, + char * user_info, + std::size_t user_info_size, + float * output_parameters, + int * output_states, + float * output_chi_squares, + int * output_n_iterations + ) ; + + virtual ~FitInterface(); + void fit(int const model_id); + +private: + void set_number_of_parameters(int const model_id); + void check_sizes(); + void configure_info(Info & info, int const model_id); + +public: + +private: + //input + float const * const data_ ; + float const * const weights_; + float const * const initial_parameters_; + int const * const parameters_to_fit_; + char * const user_info_; + int n_parameters_; + + std::size_t const n_fits_; + int const n_points_; + float const tolerance_; + int const max_n_iterations_; + int const estimator_id_; + std::size_t const user_info_size_; + + //output + float * output_parameters_; + int * output_states_; + float * output_chi_squares_; + int * output_n_iterations_; +}; + +#endif diff --git a/Gpufit/linear_1d.cuh b/Gpufit/linear_1d.cuh new file mode 100644 index 0000000..0b6a5c8 --- /dev/null +++ b/Gpufit/linear_1d.cuh @@ -0,0 +1,103 @@ +#ifndef GPUFIT_LINEAR1D_CUH_INCLUDED +#define GPUFIT_LINEAR1D_CUH_INCLUDED + +/* Description of the calculate_linear1d function +* =================================================== +* +* This function calculates the values of one-dimensional linear model functions +* and their partial derivatives with respect to the model parameters. +* +* This function makes use of the user information data to pass in the +* independent variables (X values) corresponding to the data. +* +* Note that if no user information is provided, the (X) coordinate of the +* first data value is assumed to be (0.0). In this case, for a fit size of +* M data points, the (X) coordinates of the data are simply the corresponding +* array index values of the data array, starting from zero. +* +* Parameters: +* +* parameters: An input vector of concatenated sets of model parameters. +* p[0]: offset +* p[1]: slope +* +* n_fits: The number of fits. +* +* n_points: The number of data points per fit. +* +* n_parameters: The number of model parameters. +* +* values: An output vector of concatenated sets of model function values. +* +* derivatives: An output vector of concatenated sets of model function partial +* derivatives. +* +* chunk_index: The chunk index. Used for indexing of user_info. +* +* user_info: An input vector containing user information. +* +* user_info_size: The number of elements in user_info. +* +* Calling the calculate_linear1d function +* ======================================= +* +* This __device__ function can be only called from a __global__ function or an other +* __device__ function. When calling the function, the blocks and threads of the __global__ +* function must be set up correctly, as shown in the following example code. +* +* dim3 threads(1, 1, 1); +* dim3 blocks(1, 1, 1); +* +* threads.x = n_points * n_fits_per_block; +* blocks.x = n_fits / n_fits_per_block; +* +* global_function<<< blocks,threads >>>(parameter1, ...); +* +*/ + +__device__ void calculate_linear1d( + float const * parameters, + int const n_fits, + int const n_points, + int const n_parameters, + float * values, + float * derivatives, + int const chunk_index, + char * user_info, + std::size_t const user_info_size) +{ + int const n_fits_per_block = blockDim.x / n_points; + int const fit_in_block = threadIdx.x / n_points; + int const point_index = threadIdx.x - (fit_in_block*n_points); + int const fit_index = blockIdx.x * n_fits_per_block + fit_in_block; + + float * user_info_float = (float*) user_info; + float x = 0.0f; + if (!user_info_float) + { + x = point_index; + } + else if (user_info_size / sizeof(float) == n_points) + { + x = user_info_float[point_index]; + } + else if (user_info_size / sizeof(float) > n_points) + { + int const chunk_begin = chunk_index * n_fits * n_points; + int const fit_begin = fit_index * n_points; + x = user_info_float[chunk_begin + fit_begin + point_index]; + } + + float* current_value = &values[fit_index*n_points]; + float const * current_parameters = ¶meters[fit_index * n_parameters]; + + current_value[point_index] = current_parameters[0] + current_parameters[1] * x; + + // derivatives + + float * current_derivative = &derivatives[fit_index * n_parameters * n_points + point_index]; + current_derivative[0] = 1.f; + current_derivative[1 * n_points] = x; +} + +#endif diff --git a/Gpufit/lm_fit.cpp b/Gpufit/lm_fit.cpp new file mode 100644 index 0000000..19a658f --- /dev/null +++ b/Gpufit/lm_fit.cpp @@ -0,0 +1,92 @@ +#include "lm_fit.h" +#include + +LMFit::LMFit +( + float const * const data, + float const * const weights, + Info & info, + float const * const initial_parameters, + int const * const parameters_to_fit, + char * const user_info, + float * output_parameters, + int * output_states, + float * output_chi_squares, + int * output_n_iterations +) : + data_( data ), + weights_( weights ), + initial_parameters_( initial_parameters ), + parameters_to_fit_( parameters_to_fit ), + user_info_( user_info ), + output_parameters_( output_parameters ), + output_states_( output_states ), + output_chi_squares_( output_chi_squares ), + output_n_iterations_( output_n_iterations ), + info_(info), + chunk_size_(0), + ichunk_(0), + n_fits_left_(info.n_fits_), + parameters_to_fit_indices_(0) +{} + +LMFit::~LMFit() +{} + +void LMFit::set_parameters_to_fit_indices() +{ + int const n_parameters_to_fit = info_.n_parameters_; + for (int i = 0; i < n_parameters_to_fit; i++) + { + if (parameters_to_fit_[i]) + { + parameters_to_fit_indices_.push_back(i); + } + } +} + +void LMFit::get_results(GPUData const & gpu_data, int const n_fits) +{ + output_parameters_ + = gpu_data.parameters_.copy( n_fits*info_.n_parameters_, output_parameters_ ) ; + output_states_ = gpu_data.states_.copy( n_fits, output_states_ ) ; + output_chi_squares_ = gpu_data.chi_squares_.copy( n_fits, output_chi_squares_ ) ; + output_n_iterations_ = gpu_data.n_iterations_.copy( n_fits, output_n_iterations_ ) ; +} + +void LMFit::run(float const tolerance) +{ + set_parameters_to_fit_indices(); + + GPUData gpu_data(info_); + gpu_data.init_user_info(user_info_); + + // loop over data chunks + while (n_fits_left_ > 0) + { + chunk_size_ = int((std::min)(n_fits_left_, info_.max_chunk_size_)); + + info_.set_fits_per_block(chunk_size_); + + gpu_data.reset(chunk_size_); + gpu_data.init( + ichunk_, + data_, + weights_, + initial_parameters_, + parameters_to_fit_indices_); + + LMFitCUDA lmfit_cuda( + tolerance, + info_, + gpu_data, + chunk_size_); + + lmfit_cuda.run(); + + get_results(gpu_data, chunk_size_); + + n_fits_left_ -= chunk_size_; + ichunk_++; + } +} diff --git a/Gpufit/lm_fit.h b/Gpufit/lm_fit.h new file mode 100644 index 0000000..6ee3b86 --- /dev/null +++ b/Gpufit/lm_fit.h @@ -0,0 +1,88 @@ +#ifndef GPUFIT_LM_FIT_H_INCLUDED +#define GPUFIT_LM_FIT_H_INCLUDED + +#include "definitions.h" +#include "info.h" +#include "gpu_data.cuh" + +class LMFitCUDA; + +class LMFit +{ +public: + LMFit + ( + float const * data, + float const * weights, + Info & info, + float const * initial_parameters, + int const * parameters_to_fit, + char * user_info, + float * output_parameters, + int * output_states, + float * output_chi_squares, + int * output_n_iterations + ) ; + + virtual ~LMFit(); + + void run(float const tolerance); + +private: + void set_parameters_to_fit_indices(); + void get_results(GPUData const & gpu_data, int const n_fits); + + float const * const data_ ; + float const * const weights_ ; + float const * const initial_parameters_ ; + int const * const parameters_to_fit_; + char const * const user_info_; + + float * output_parameters_ ; + int * output_states_ ; + float * output_chi_squares_ ; + int * output_n_iterations_ ; + + int ichunk_; + int chunk_size_; + std::size_t n_fits_left_; + + Info & info_; + + std::vector parameters_to_fit_indices_; +}; + +class LMFitCUDA +{ +public: + LMFitCUDA( + float const tolerance, + Info const & info, + GPUData & gpu_data, + int const n_fits); + + virtual ~LMFitCUDA(); + + void run(); + +private: + void calc_curve_values(); + void calc_chi_squares(); + void calc_gradients(); + void calc_hessians(); + void evaluate_iteration(int const iteration); + void solve_equation_system(); + +public: + +private: + Info const & info_; + GPUData & gpu_data_; + int const n_fits_; + + bool all_finished_; + + float tolerance_; +}; + +#endif diff --git a/Gpufit/lm_fit_cuda.cpp b/Gpufit/lm_fit_cuda.cpp new file mode 100644 index 0000000..94799a0 --- /dev/null +++ b/Gpufit/lm_fit_cuda.cpp @@ -0,0 +1,57 @@ +#include "lm_fit.h" + +LMFitCUDA::LMFitCUDA( + float const tolerance, + Info const & info, + GPUData & gpu_data, + int const n_fits + ) : + info_(info), + gpu_data_(gpu_data), + n_fits_(n_fits), + all_finished_(false), + tolerance_(tolerance) +{ +} + +LMFitCUDA::~LMFitCUDA() +{ +} + +void LMFitCUDA::run() +{ + // initialize the chi-square values + calc_curve_values(); + calc_chi_squares(); + calc_gradients(); + calc_hessians(); + + gpu_data_.copy( + gpu_data_.prev_chi_squares_, + gpu_data_.chi_squares_, + n_fits_); + + // loop over the fit iterations + for (int iteration = 0; !all_finished_; iteration++) + { + // modify step width + // Gauss Jordan + // update fitting parameters + solve_equation_system(); + + // calculate fitting curve values and its derivatives + // calculate chi-squares, gradients and hessians + calc_curve_values(); + calc_chi_squares(); + calc_gradients(); + calc_hessians(); + + // check which fits have converged + // flag finished fits + // check whether all fits finished + // save the number of needed iterations by each fitting process + // check whether chi-squares are increasing or decreasing + // update chi-squares, curve parameters and lambdas + evaluate_iteration(iteration); + } +} \ No newline at end of file diff --git a/Gpufit/lm_fit_cuda.cu b/Gpufit/lm_fit_cuda.cu new file mode 100644 index 0000000..8d74fb9 --- /dev/null +++ b/Gpufit/lm_fit_cuda.cu @@ -0,0 +1,253 @@ +#include "lm_fit.h" +#include +#include "cuda_kernels.cuh" +#include "cuda_gaussjordan.cuh" + +void LMFitCUDA::solve_equation_system() +{ + dim3 threads(1, 1, 1); + dim3 blocks(1, 1, 1); + + threads.x = info_.n_parameters_to_fit_*info_.n_fits_per_block_; + threads.y = 1; + blocks.x = n_fits_ / info_.n_fits_per_block_; + blocks.y = 1; + cuda_modify_step_widths<<< blocks, threads >>>( + gpu_data_.hessians_, + gpu_data_.lambdas_, + info_.n_parameters_to_fit_, + gpu_data_.iteration_falied_, + gpu_data_.finished_, + info_.n_fits_per_block_); + CUDA_CHECK_STATUS(cudaGetLastError()); + + int n_parameters_pow2 = 1; + + while (n_parameters_pow2 < info_.n_parameters_to_fit_) + { + n_parameters_pow2 *= 2; + } + + //set up to run the Gauss Jordan elimination + int const n_equations = info_.n_parameters_to_fit_; + int const n_solutions = n_fits_; + + threads.x = n_equations + 1; + threads.y = n_equations; + blocks.x = n_solutions; + blocks.y = 1; + + //set the size of the shared memory area for each block + int const shared_size + = sizeof(float) * ((threads.x * threads.y) + + n_parameters_pow2 + n_parameters_pow2); + + //set up the singular_test vector + int * singular_tests; + CUDA_CHECK_STATUS(cudaMalloc((void**)&singular_tests, n_fits_ * sizeof(int))); + + //run the Gauss Jordan elimination + cuda_gaussjordan<<< blocks, threads, shared_size >>>( + gpu_data_.deltas_, + gpu_data_.gradients_, + gpu_data_.hessians_, + gpu_data_.finished_, + singular_tests, + info_.n_parameters_to_fit_, + n_parameters_pow2); + CUDA_CHECK_STATUS(cudaGetLastError()); + + //set up to update the lm_state_gpu_ variable with the Gauss Jordan results + threads.x = std::min(n_fits_, 256); + threads.y = 1; + blocks.x = int(std::ceil(float(n_fits_) / float(threads.x))); + blocks.y = 1; + + //update the lm_state_gpu_ variable + cuda_update_state_after_gaussjordan<<< blocks, threads >>>( + n_fits_, + singular_tests, + gpu_data_.states_); + CUDA_CHECK_STATUS(cudaGetLastError()); + + CUDA_CHECK_STATUS(cudaFree(singular_tests)); + + threads.x = info_.n_parameters_*info_.n_fits_per_block_; + threads.y = 1; + blocks.x = n_fits_ / info_.n_fits_per_block_; + blocks.y = 1; + cuda_update_parameters<<< blocks, threads >>>( + gpu_data_.parameters_, + gpu_data_.prev_parameters_, + gpu_data_.deltas_, + info_.n_parameters_to_fit_, + gpu_data_.parameters_to_fit_indices_, + gpu_data_.finished_, + info_.n_fits_per_block_); + CUDA_CHECK_STATUS(cudaGetLastError()); +} + +void LMFitCUDA::calc_curve_values() +{ + dim3 threads(1, 1, 1); + dim3 blocks(1, 1, 1); + + threads.x = info_.n_points_ * info_.n_fits_per_block_; + threads.y = 1; + blocks.x = n_fits_ / info_.n_fits_per_block_; + blocks.y = 1; + + cuda_calc_curve_values << < blocks, threads >> >( + gpu_data_.parameters_, + n_fits_, + info_.n_points_, + info_.n_parameters_, + gpu_data_.finished_, + gpu_data_.values_, + gpu_data_.derivatives_, + info_.n_fits_per_block_, + info_.model_id_, + gpu_data_.chunk_index_, + gpu_data_.user_info_, + info_.user_info_size_); + CUDA_CHECK_STATUS(cudaGetLastError()); +} + +void LMFitCUDA::calc_chi_squares() +{ + dim3 threads(1, 1, 1); + dim3 blocks(1, 1, 1); + + int const shared_size + = sizeof(float) + * info_.power_of_two_n_points_ + * info_.n_fits_per_block_; + + threads.x = info_.power_of_two_n_points_*info_.n_fits_per_block_; + threads.y = 1; + blocks.x = n_fits_ / info_.n_fits_per_block_; + blocks.y = 1; + + cuda_calculate_chi_squares <<< blocks, threads, shared_size >>>( + gpu_data_.chi_squares_, + gpu_data_.states_, + gpu_data_.iteration_falied_, + gpu_data_.prev_chi_squares_, + gpu_data_.data_, + gpu_data_.values_, + gpu_data_.weights_, + info_.n_points_, + info_.estimator_id_, + gpu_data_.finished_, + info_.n_fits_per_block_, + gpu_data_.user_info_, + info_.user_info_size_); + CUDA_CHECK_STATUS(cudaGetLastError()); +} + +void LMFitCUDA::calc_gradients() +{ + dim3 threads(1, 1, 1); + dim3 blocks(1, 1, 1); + + int const shared_size + = sizeof(float) + * info_.power_of_two_n_points_ + * info_.n_fits_per_block_; + + threads.x = info_.power_of_two_n_points_*info_.n_fits_per_block_; + threads.y = 1; + blocks.x = n_fits_ / info_.n_fits_per_block_; + blocks.y = 1; + + cuda_calculate_gradients <<< blocks, threads, shared_size >>>( + gpu_data_.gradients_, + gpu_data_.data_, + gpu_data_.values_, + gpu_data_.derivatives_, + gpu_data_.weights_, + info_.n_points_, + info_.n_parameters_, + info_.n_parameters_to_fit_, + gpu_data_.parameters_to_fit_indices_, + info_.estimator_id_, + gpu_data_.finished_, + gpu_data_.iteration_falied_, + info_.n_fits_per_block_, + gpu_data_.user_info_, + info_.user_info_size_); + CUDA_CHECK_STATUS(cudaGetLastError()); +} + +void LMFitCUDA::calc_hessians() +{ + dim3 threads(1, 1, 1); + dim3 blocks(1, 1, 1); + + threads.x = info_.n_parameters_to_fit_; + threads.y = info_.n_parameters_to_fit_; + blocks.x = n_fits_; + blocks.y = 1; + + cuda_calculate_hessians <<< blocks, threads >>>( + gpu_data_.hessians_, + gpu_data_.data_, + gpu_data_.values_, + gpu_data_.derivatives_, + gpu_data_.weights_, + info_.n_points_, + info_.n_parameters_, + info_.n_parameters_to_fit_, + gpu_data_.parameters_to_fit_indices_, + info_.estimator_id_, + gpu_data_.iteration_falied_, + gpu_data_.finished_, + gpu_data_.user_info_, + info_.user_info_size_); + CUDA_CHECK_STATUS(cudaGetLastError()); +} + +void LMFitCUDA::evaluate_iteration(int const iteration) +{ + dim3 threads(1, 1, 1); + dim3 blocks(1, 1, 1); + + threads.x = std::min(n_fits_, 256); + threads.y = 1; + blocks.x = int(std::ceil(float(n_fits_) / float(threads.x))); + blocks.y = 1; + + cuda_check_for_convergence<<< blocks, threads >>>( + gpu_data_.finished_, + tolerance_, + gpu_data_.states_, + gpu_data_.chi_squares_, + gpu_data_.prev_chi_squares_, + iteration, + info_.max_n_iterations_, + n_fits_); + CUDA_CHECK_STATUS(cudaGetLastError()); + + gpu_data_.set(gpu_data_.all_finished_, 1); + + cuda_evaluate_iteration<<< blocks, threads >>>( + gpu_data_.all_finished_, + gpu_data_.n_iterations_, + gpu_data_.finished_, + iteration, + gpu_data_.states_, + n_fits_); + CUDA_CHECK_STATUS(cudaGetLastError()); + + gpu_data_.read(&all_finished_, gpu_data_.all_finished_); + + cuda_prepare_next_iteration<<< blocks, threads >>>( + gpu_data_.lambdas_, + gpu_data_.chi_squares_, + gpu_data_.prev_chi_squares_, + gpu_data_.parameters_, + gpu_data_.prev_parameters_, + n_fits_, + info_.n_parameters_); + CUDA_CHECK_STATUS(cudaGetLastError()); +} diff --git a/Gpufit/lse.cuh b/Gpufit/lse.cuh new file mode 100644 index 0000000..e615b01 --- /dev/null +++ b/Gpufit/lse.cuh @@ -0,0 +1,186 @@ +#ifndef GPUFIT_LSE_CUH_INCLUDED +#define GPUFIT_LSE_CUH_INCLUDED + +/* Description of the calculate_chi_square_lse function +* ===================================================== +* +* This function calculates the chi-square values for the weighted LSE estimator. +* +* Parameters: +* +* chi_square: An output vector of chi-square values for each data point. +* +* point_index: The data point index. +* +* data: An input vector of data values. +* +* value: An input vector of fitting curve values. +* +* weight: An input vector of values for weighting the chi-square values. +* +* state: A pointer to a value which indicates whether the fitting +* process was carreid out correctly or which problem occurred. +* In this function it is not used. It can be used in functions calculating +* other estimators than the LSE, such as MLE. It is passed into this function +* to provide the same interface for all estimator functions. +* +* user_info: An input vector containing user information. (not used) +* +* user_info_size: The number of elements in user_info. (not used) +* +* Calling the calculate_chi_square_lse function +* ============================================= +* +* This __device__ function can be only called from a __global__ function or an other +* __device__ function. +* +*/ + +__device__ void calculate_chi_square_lse( + volatile float * chi_square, + int const point_index, + float const * data, + float const * value, + float const * weight, + int * state, + char * user_info, + std::size_t const user_info_size) +{ + float const deviation = value[point_index] - data[point_index]; + + if (weight) + { + chi_square[point_index] = deviation * deviation * weight[point_index]; + } + else + { + chi_square[point_index] = deviation * deviation; + } +} + +/* Description of the calculate_hessian_lse function +* ================================================== +* +* This function calculates the hessian matrix values of the weighted LSE equation. +* The calculation is performed based on previously calculated fitting curve derivative +* values. +* +* Parameters: +* +* hessian: An output vector of values of the hessian matrix for each data point. +* +* point_index: The data point index. +* +* parameter_index_i: Index of the hessian column. +* +* parameter_index_j: Index of the hessian row. +* +* data: An input vector of data values. +* +* value: An input vector of fitting curve values. +* +* derivative: An input vector of partial derivative values of the fitting +* curve for each data point. +* +* weight: An input vector of values for weighting the hessian matrix values. +* +* user_info: An input vector containing user information. (not used) +* +* user_info_size: The number of elements in user_info. (not used) +* +* Calling the calculate_hessian_lse function +* ========================================== +* +* This __device__ function can be only called from a __global__ function or an other +* __device__ function. +* +*/ + +__device__ void calculate_hessian_lse( + double * hessian, + int const point_index, + int const parameter_index_i, + int const parameter_index_j, + float const * data, + float const * value, + float const * derivative, + float const * weight, + char * user_info, + std::size_t const user_info_size) +{ + if (weight) + { + *hessian + += derivative[parameter_index_i] * derivative[parameter_index_j] + * weight[point_index]; + } + else + { + *hessian + += derivative[parameter_index_i] * derivative[parameter_index_j]; + } +} + +/* Description of the calculate_gradient_lse function +* =================================================== +* +* This function calculates the gradient values of the weighted LSE equation +* based on previously calculated fitting curve derivative values. +* +* Parameters: +* +* gradient: An output vector of values of the gradient vector for each data point. +* +* point_index: The data point index. +* +* parameter_index: The parameter index. +* +* n_parameters: The number of fitting curve parameters. +* +* data: An input vector of data values. +* +* value: An input vector of fitting curve values. +* +* derivative: An input vector of partial derivative values of the fitting +* curve for each data point. +* +* weight: An input vector of values for weighting gradient values. +* +* user_info: An input vector containing user information. (not used) +* +* user_info_size: The number of elements in user_info. (not used) +* +* Calling the calculate_gradient_lse function +* =========================================== +* +* This __device__ function can be only called from a __global__ function or an other +* __device__ function. +* +*/ + +__device__ void calculate_gradient_lse( + volatile float * gradient, + int const point_index, + int const parameter_index, + float const * data, + float const * value, + float const * derivative, + float const * weight, + char * user_info, + std::size_t const user_info_size) +{ + float const deviation = data[point_index] - value[point_index]; + + if (weight) + { + gradient[point_index] + = derivative[parameter_index] * deviation * weight[point_index]; + } + else + { + gradient[point_index] + = derivative[parameter_index] * deviation; + } +} + +#endif diff --git a/Gpufit/matlab/CMakeLists.txt b/Gpufit/matlab/CMakeLists.txt new file mode 100644 index 0000000..b0c5dc8 --- /dev/null +++ b/Gpufit/matlab/CMakeLists.txt @@ -0,0 +1,69 @@ + +# MATLAB Gpufit binding + +find_package( Matlab COMPONENTS MX_LIBRARY ) + +if( NOT Matlab_FOUND ) + message( STATUS "Matlab and/or MX_Library NOT found - skipping Gpufit Matlab binding!" ) + return() +endif() + +# MATLAB MEX FILE + +set( Headers + ) + +set( Sources + mex/GpufitMex.cpp + ) + +add_library( GpufitMex SHARED + ${Headers} + ${Sources} + ) + +set_property( TARGET GpufitMex + PROPERTY SUFFIX .${Matlab_MEX_EXTENSION} ) + +set_property( TARGET GpufitMex + PROPERTY RUNTIME_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}" ) + +target_include_directories( GpufitMex PRIVATE ${Matlab_INCLUDE_DIRS} ${PROJECT_SOURCE_DIR} ) + +target_link_libraries( GpufitMex Gpufit ${Matlab_LIBRARIES} ) + +if( WIN32 ) + SET(CMAKE_SHARED_LINKER_FLAGS "/export:mexFunction") +endif() + +add_matlab_launcher( GpufitMex "${CMAKE_CURRENT_SOURCE_DIR}" ) + +# MATLAB Gpufit PACKAGE + +set( build_directory "${CMAKE_BINARY_DIR}/${CMAKE_CFG_INTDIR}/matlab" ) +set( package_files + "${CMAKE_CURRENT_SOURCE_DIR}/EstimatorID.m" + "${CMAKE_CURRENT_SOURCE_DIR}/gpufit.m" + "${CMAKE_CURRENT_SOURCE_DIR}/ModelID.m" + "${CMAKE_CURRENT_SOURCE_DIR}/README.txt" +) +set( binary_gpufit $ ) +set( binary_mex $ ) + +add_custom_target( MATLAB_GPUFIT_PACKAGE + COMMAND ${CMAKE_COMMAND} -E + remove_directory ${build_directory} + COMMAND ${CMAKE_COMMAND} -E + make_directory ${build_directory} + COMMAND ${CMAKE_COMMAND} -E + copy_if_different ${package_files} ${build_directory} + COMMAND ${CMAKE_COMMAND} -E + copy_if_different ${binary_gpufit} ${build_directory} + COMMAND ${CMAKE_COMMAND} -E + copy_if_different ${binary_mex} ${build_directory} + COMMENT "Creating Gpufit Matlab package" +) +set_property( TARGET MATLAB_GPUFIT_PACKAGE PROPERTY FOLDER CMakePredefinedTargets ) +add_dependencies( MATLAB_GPUFIT_PACKAGE Gpufit GpufitMex) + +# add launcher diff --git a/Gpufit/matlab/EstimatorID.m b/Gpufit/matlab/EstimatorID.m new file mode 100644 index 0000000..a853ffa --- /dev/null +++ b/Gpufit/matlab/EstimatorID.m @@ -0,0 +1,6 @@ +classdef EstimatorID + properties (Constant = true) + LSE = 0 + MLE = 1 + end +end \ No newline at end of file diff --git a/Gpufit/matlab/ModelID.m b/Gpufit/matlab/ModelID.m new file mode 100644 index 0000000..174c703 --- /dev/null +++ b/Gpufit/matlab/ModelID.m @@ -0,0 +1,10 @@ +classdef ModelID + properties (Constant = true) + GAUSS_1D = 0 + GAUSS_2D = 1 + GAUSS_2D_ELLIPTIC = 2 + GAUSS_2D_ROTATED = 3 + CAUCHY_2D_ELLIPTIC = 4 + LINEAR_1D = 5 + end +end \ No newline at end of file diff --git a/Gpufit/matlab/README.txt b/Gpufit/matlab/README.txt new file mode 100644 index 0000000..02ddfd2 --- /dev/null +++ b/Gpufit/matlab/README.txt @@ -0,0 +1,19 @@ +Matlab binding for the [Gpufit library](https://github.com/gpufit/Gpufit) which implements Levenberg Marquardt curve fitting in CUDA + +Requirements + +- A CUDA capable graphics card with a recent Nvidia graphics driver (at least 367.48 / July 2016) +- Windows +- Matlab 32/64bit + +Installation + +An installation is not necessary. However, this path must be part of the Matlab path. Use `addpath` if necessary. + +Examples + +See examples folder. The examples are fully functional only from Matlab2014a. + +Troubleshooting + +A common reason for the error message 'CUDA driver version is insufficient for CUDA runtime version' is an outdated Nvidia graphics driver. \ No newline at end of file diff --git a/Gpufit/matlab/examples/gauss2d.m b/Gpufit/matlab/examples/gauss2d.m new file mode 100644 index 0000000..bf478a4 --- /dev/null +++ b/Gpufit/matlab/examples/gauss2d.m @@ -0,0 +1,182 @@ +function gauss2d() +% Example of the Matlab binding of the Gpufit library implementing +% Levenberg Marquardt curve fitting in CUDA +% https://github.com/gpufit/Gpufit +% +% Multiple fits of a 2D Gaussian peak function with Poisson distributed noise +% http://gpufit.readthedocs.io/en/latest/bindings.html#matlab + +% perform some 2D Gaussian peak fits with a symmetrical Gaussian peak +fit_gauss2d(); + +% perform some 2D Gaussian peak fits with an asymmetrical, rotated Gaussian peak +fit_gauss2d_rotated(); + +end +function fit_gauss2d() + +%% number of fits and fit points +number_fits = 1e4; +size_x = 20; +number_parameters = 5; + +%% set input arguments + +% true parameters +true_parameters = single([20, 9.5, 9.5, 3, 10]); + +% initialize random number generator +rng(0); + +% initial parameters (randomized) +initial_parameters = repmat(single(true_parameters'), [1, number_fits]); +% randomize relative to width for positions +initial_parameters([2,3], :) = initial_parameters([2,3], :) + true_parameters(4) * (-0.2 + 0.4 * rand(2, number_fits)); +% randomize relative for other parameters +initial_parameters([1,4,5], :) = initial_parameters([1,4,5], :) .* (0.8 + 0.4 * rand(3, number_fits)); + +% generate x and y values +g = single(0 : size_x - 1); +[x, y] = ndgrid(g, g); + +% generate data with Poisson noise +data = gaussian_2d(x, y, true_parameters); +data = repmat(data(:), [1, number_fits]); +data = poissrnd(data); + +% tolerance +tolerance = 1e-3; + +% maximum number of iterations +max_n_iterations = 20; + +% estimator id +estimator_id = EstimatorID.MLE; + +% model ID +model_id = ModelID.GAUSS_2D; + +%% run Gpufit +[parameters, states, chi_squares, n_iterations, time] = gpufit(data, [], ... + model_id, initial_parameters, tolerance, max_n_iterations, [], estimator_id, []); + +%% displaying results +display_results('2D Gaussian peak', model_id, number_fits, number_parameters, size_x, time, true_parameters, parameters, states, chi_squares, n_iterations); + +end + +function fit_gauss2d_rotated() + +%% number of fits and fit points +number_fits = 1e4; +size_x = 20; +number_parameters = 7; + +%% set input arguments + +% true parameters +true_parameters = single([200, 9.5, 9.5, 3, 4, 10, 0.5]); + +% initialize random number generator +rng(0); + +% initial parameters (randomized) +initial_parameters = repmat(single(true_parameters'), [1, number_fits]); +% randomize relative to width for positions +initial_parameters(2, :) = initial_parameters(2, :) + true_parameters(4) * (-0.2 + 0.4 * rand(1, number_fits)); +initial_parameters(3, :) = initial_parameters(3, :) + true_parameters(5) * (-0.2 + 0.4 * rand(1, number_fits)); +% randomize relative for other parameters +initial_parameters([1,4,5,6,7], :) = initial_parameters([1,4,5,6,7], :) .* (0.8 + 0.4 * rand(5, number_fits)); + +% generate x and y values +g = single(0 : size_x - 1); +[x, y] = ndgrid(g, g); + +% generate data with Poisson noise +data = gaussian_2d_rotated(x, y, true_parameters); +data = repmat(data(:), [1, number_fits]); +data = poissrnd(data); + +% tolerance +tolerance = 1e-3; + +% maximum number of iterations +max_n_iterations = 20; + +% estimator id +estimator_id = EstimatorID.MLE; + +% model ID +model_id = ModelID.GAUSS_2D_ROTATED; + +%% run Gpufit +[parameters, states, chi_squares, n_iterations, time] = gpufit(data, [], ... + model_id, initial_parameters, tolerance, max_n_iterations, [], estimator_id, []); + +%% displaying results +display_results('2D rotated Gaussian peak', model_id, number_fits, number_parameters, size_x, time, true_parameters, parameters, states, chi_squares, n_iterations); + + +end + +function g = gaussian_2d(x, y, p) +% Generates a 2D Gaussian peak. +% http://gpufit.readthedocs.io/en/latest/api.html#gauss-2d +% +% x,y - x and y grid position values +% p - parameters (amplitude, x,y center position, width, offset) + +g = p(1) * exp(-((x - p(2)).^2 + (y - p(3)).^2) / (2 * p(4)^2)) + p(5); + +end + +function g = gaussian_2d_rotated(x, y, p) +% Generates a 2D rotated elliptic Gaussian peak. +% http://gpufit.readthedocs.io/en/latest/api.html#d-rotated-elliptic-gaussian-peak +% +% x,y - x and y grid position values +% p - parameters (amplitude, x,y center position, width, offset) + +% cosine and sine of rotation angle +cp = cos(p(7)); +sp = sin(p(7)); + +% Gaussian peak with two axes +arga = (x - p(2)) .* cp - (y - p(3)) .* sp; +argb = (x - p(2)) .* sp + (y - p(3)) .* cp; +ex = exp(-0.5 .* (((arga / p(4)) .* (arga / p(4))) + ((argb / p(5)) .* (argb / p(5))))); +g = p(1) .* ex + p(6); + +end + +function display_results(name, model_id, number_fits, number_parameters, size_x, time, true_parameters, parameters, states, chi_squares, n_iterations) + +%% displaying results +converged = states == 0; +fprintf('\nGpufit of %s\n', name); + +% print summary +fprintf('\nmodel ID: %d\n', model_id); +fprintf('number of fits: %d\n', number_fits); +fprintf('fit size: %d x %d\n', size_x, size_x); +fprintf('mean chi-square: %6.2f\n', mean(chi_squares(converged))); +fprintf('mean iterations: %6.2f\n', mean(n_iterations(converged))); +fprintf('time: %6.2f s\n', time); + +% get fit states +number_converged = sum(converged); +fprintf('\nratio converged %6.2f %%\n', number_converged / number_fits * 100); +fprintf('ratio max it. exceeded %6.2f %%\n', sum(states == 1) / number_fits * 100); +fprintf('ratio singular hessian %6.2f %%\n', sum(states == 2) / number_fits * 100); +fprintf('ratio neg curvature MLE %6.2f %%\n', sum(states == 3) / number_fits * 100); + +% mean and std of fitted parameters +converged_parameters = parameters(:, converged); +converged_parameters_mean = mean(converged_parameters, 2); +converged_parameters_std = std(converged_parameters, [], 2); +fprintf('\nparameters of %s\n', name); +for i = 1 : number_parameters + fprintf('p%d true %6.2f mean %6.2f std %6.2f\n', i, true_parameters(i), converged_parameters_mean(i), converged_parameters_std(i)); +end + +end \ No newline at end of file diff --git a/Gpufit/matlab/examples/gauss2d_comparison.m b/Gpufit/matlab/examples/gauss2d_comparison.m new file mode 100644 index 0000000..39dc68b --- /dev/null +++ b/Gpufit/matlab/examples/gauss2d_comparison.m @@ -0,0 +1,206 @@ +function gauss2d_comparison() +% Example of the Matlab binding of the Gpufit library implementing +% Levenberg Marquardt curve fitting in CUDA +% https://github.com/gpufit/Gpufit +% +% Multiple fits of a 2D Gaussian peak function with Poisson distributed noise +% compared to a generic Matlab implementation using fminunc and supplying +% the gradient by the user (uses quasi-newton as algorithm) +% http://gpufit.readthedocs.io/en/latest/bindings.html#matlab + +%% number of fits and fit points +number_fits = 1e3; +size_x = 20; +number_parameters = 5; + +%% set input arguments + +% true parameters +true_parameters = single([10, 9.5, 9.5, 3, 10]); + +% initialize random number generator +rng(0); + +% initial parameters (randomized) +initial_parameters = repmat(single(true_parameters'), [1, number_fits]); +% randomize relative to width for positions +initial_parameters([2,3], :) = initial_parameters([2,3], :) + true_parameters(4) * (-0.2 + 0.4 * rand(2, number_fits)); +% randomize relative for other parameters +initial_parameters([1,4,5], :) = initial_parameters([1,4,5], :) .* (0.8 + 0.4 * rand(3, number_fits)); + +% generate x and y values +g = single(0 : size_x - 1); +[x, y] = ndgrid(g, g); + +% generate data with Poisson noise +data = gaussian_2d(x, y, true_parameters); +data = repmat(data(:), [1, number_fits]); +data = poissrnd(data); + +% tolerance +tolerance = 1e-4; + +% maximum number of iterations +max_n_iterations = 20; + +% estimator id +estimator_id = EstimatorID.MLE; + +% model ID +model_id = ModelID.GAUSS_2D; % Gaussian peak in 2D + +%% run Gpufit +fprintf('run Gpufit\n'); +[gf_parameters, gf_states, gf_chi_squares, gf_n_iterations, time] = gpufit(data, [], ... + model_id, initial_parameters, tolerance, max_n_iterations, [], estimator_id, []); + +% display results +display_results('Gpufit', gf_parameters, gf_states, gf_chi_squares, gf_n_iterations, time, true_parameters); + +% store parameters + +%% run Matlab + +% convert data and initial_parameters to double (otherwise causes an error +% in fminunc) +data = double(data); +initial_parameters = double(initial_parameters); +xi = double(x(:)'); +yi = double(y(:)'); + +% set fit options +options = optimoptions(@fminunc,'Display', 'off', 'MaxIter', max_n_iterations, 'Algorithm', 'quasi-newton', 'TolFun', tolerance, 'GradObj', 'on', 'DerivativeCheck', 'off', 'Diagnostics', 'off'); + +% initialize output arrays +m_parameters = zeros(number_parameters, number_fits); +m_states = zeros(1, number_fits); +m_chi_squares = zeros(1, number_fits); +m_n_iterations = zeros(1, number_fits); + +% loop over each fit +fprintf('\n') +progress = 0; +L = 50; % length of progressbar +tic; +for i = 1 : number_fits + + % get data and initial_parameters + d = data(:, i)'; + p0 = initial_parameters(:, i); + + % define minimizer function (give grid and data as implicit parameters) + fun = @(p) minimizer(p, xi, yi, d); + + % call to fminunc + [p, fval, exitflag, output] = fminunc(fun, p0, options); + + % copy to output + m_parameters(:, i) = p; + m_chi_squares(i) = fval; + m_states(i) = exitflag - 1; + m_n_iterations(i) = output.iterations; + + progress = progress + 1; + if progress >= number_fits / L + progress = 0; + fprintf('|'); + end +end +time = toc; +fprintf(repmat('\b', [1, L])); + +% display results +display_results('Matlab (one CPU kernel)', m_parameters, m_states, m_chi_squares, m_n_iterations, time, true_parameters); + +end + +function [f, g] = minimizer(p, xi, yi, d) +% calls the model with the current parameters, then the likelihood function +% and returns value and derivatives of the likelihood function +% +% p - current parameters +% xi, yi - grid positions +% d - current data + +if nargout > 1 + [m, mg] = gaussian_2d_with_gradient(xi, yi, p); + [f, g] = poisson_likelihood(m, mg, d); +else + m = gaussian_2d(xi, yi, p); + f = poisson_likelihood(m, [], d); +end + +end + +function [f, g] = poisson_likelihood(m, mg, d) +% Calculates value and derivatives of the poisson likelihood function for +% given model and model derivatives + +h = d > 0; +f = 2 * (sum(m-d) - sum(d(h) .* log(m(h) ./ d(h)))); + +if nargout > 1 % gradient required + h = 2 * (1 - d ./ max(m, 1e-6)); + h = repmat(h, [size(mg, 1), 1]); + g = h .* mg; + g = sum(g, 2); +end + +end + + +function display_results(name, parameters, ~, chi_squares, n_iterations, time, true_parameters) +% displaying results + +fprintf('*%s*\n', name); +number_parameters = size(parameters, 1); +number_fits = size(parameters, 2); + +% print summary +fprintf('\nnumber of fits: %d\n', number_fits); +fprintf('mean chi-square: %6.2f\n', mean(chi_squares)); +fprintf('mean iterations: %6.2f\n', mean(n_iterations)); +fprintf('time: %6.2f s\n', time); +fprintf('fits per second: %.0f\n', number_fits / time); + +% mean and std of fitted parameters +parameters_mean = mean(parameters, 2); +parameters_std = std(parameters, [], 2); +fprintf('\nparameters of 2D Gaussian peak\n'); +for i = 1 : number_parameters + fprintf('p%d true %6.2f mean %6.2f std %6.2f\n', i, true_parameters(i), parameters_mean(i), parameters_std(i)); +end + +end + +function f = gaussian_2d(x, y, p) +% Generates a 2D Gaussian peak. +% http://gpufit.readthedocs.io/en/latest/api.html#gauss-2d +% +% x,y - x and y grid position values +% p - parameters (amplitude, x,y center position, width, offset) + +f = p(1) * exp(-((x - p(2)).^2 + (y - p(3)).^2) / (2 * p(4)^2)) + p(5); + +end + +function [f, g] = gaussian_2d_with_gradient(x, y, p) +% Computes the gradient for a 2D Gaussian peak with respect to parameters. + +dx = x - p(2); +dy = y - p(3); +p42 = p(4)^2; +arg = (dx.^2 + dy.^2) / p42; +exp_f = exp(-0.5 * arg); +p1_exp_f = p(1) * exp_f; + +f = p1_exp_f + p(5); + +g1 = exp_f; +g2 = p1_exp_f .* dx / p42; +g3 = p1_exp_f .* dy / p42; +g4 = p1_exp_f .* arg / p(4); +g5 = ones(size(x)); +g = [g1; g2; g3; g4; g5]; + +end diff --git a/Gpufit/matlab/examples/gauss2d_plot.m b/Gpufit/matlab/examples/gauss2d_plot.m new file mode 100644 index 0000000..cef6adc --- /dev/null +++ b/Gpufit/matlab/examples/gauss2d_plot.m @@ -0,0 +1,117 @@ +function gauss2d_plot() +% Example of the Matlab binding of the Gpufit library implementing +% Levenberg Marquardt curve fitting in CUDA +% https://github.com/gpufit/Gpufit +% +% Multiple fits of a 2D Gaussian peak function with Poisson distributed noise +% repeated for a different total number of fits each time and plotting the +% results +% http://gpufit.readthedocs.io/en/latest/bindings.html#matlab + +%% number of fit points +size_x = 5; +n_points = size_x * size_x; + +%% set input arguments + +% mean true parameters +mean_true_parameters = single([100, 3, 3, 1, 10]); + +% average noise level +average_noise_level = 10; + +% initialize random number generator +rng(0); + +% tolerance +tolerance = 1e-4; + +% max number of itetations +max_n_iterations = 10; + +% model id +model_id = ModelID.GAUSS_2D; + +%% loop over different number of fits +n_fits_all = round(logspace(2, 6, 20)); + +% generate x and y values +g = single(0 : size_x - 1); +[x, y] = ndgrid(g, g); + +% loop +speed = zeros(length(n_fits_all), 1); +for i = 1:length(n_fits_all) + n_fits = n_fits_all(i); + + % vary positions of 2D Gaussians peaks slightly + test_parameters = repmat(mean_true_parameters', [1, n_fits]); + test_parameters([2,3], :) = test_parameters([2,3], :) + mean_true_parameters(4) * (-0.2 + 0.4 * rand(2, n_fits)); + + % generate data + data = gaussians_2d(x, y, test_parameters); + data = reshape(data, [n_points, n_fits]); + + % add noise + data = data + average_noise_level * randn(size(data), 'single'); + + % initial parameters (randomized) + initial_parameters = repmat(mean_true_parameters', [1, n_fits]); + % randomize relative to width for positions + initial_parameters([2,3], :) = initial_parameters([2,3], :) + mean_true_parameters(4) * (-0.2 + 0.4 * rand(2, n_fits)); + % randomize relative for other parameters + initial_parameters([1,4,5], :) = initial_parameters([1,4,5], :) .* (0.8 + 0.4 * rand(3, n_fits)); + + % run Gpufit + [parameters, states, chi_squares, n_iterations, time] = gpufit(data, [], ... + model_id, initial_parameters, tolerance, max_n_iterations); + + % analyze result + converged = states == 0; + speed(i) = n_fits / time; + precision_x0 = std(parameters(2, converged) - test_parameters(2, converged)); + + % display result + fprintf(' iterations: %.2f | time: %.3f s | speed: %8.0f fits/s\n', ... + mean(n_iterations(converged)), time, speed(i)); +end + +%% plot +figure(); +semilogx(n_fits_all, speed, 'bo-') +xlabel('number of fits per function call') +ylabel('fits per second') +legend('Gpufit', 'Location', 'NorthWest') +grid on; +xlim(n_fits_all([1,end])); + +end + +function g = gaussians_2d(x, y, p) +% Generates many 2D Gaussians peaks for a given set of parameters + +n_fits = size(p, 2); +msg = sprintf('generating %d fits ', n_fits); +fprintf(msg); + +g = zeros([size(x), n_fits], 'single'); + +progress = 0; +L = 50; % length of progressbar +l = 0; +for i = 1 : n_fits + + pi = p(:, i); + g(:, :, i) = pi(1) * exp(-((x - pi(2)).^2 + (y - pi(3)).^2) / (2 * pi(4)^2)) + pi(5); + + progress = progress + 1; + if progress >= n_fits / L + progress = 0; + fprintf('|'); + l = l + 1; + end +end +fprintf(repmat('\b', [1, length(msg) + l])); +fprintf('%7d fits', n_fits); + +end diff --git a/Gpufit/matlab/examples/simple.m b/Gpufit/matlab/examples/simple.m new file mode 100644 index 0000000..27487d1 --- /dev/null +++ b/Gpufit/matlab/examples/simple.m @@ -0,0 +1,26 @@ +function simple() +% Example of the Matlab binding of the Gpufit library implementing +% Levenberg Marquardt curve fitting in CUDA +% https://github.com/gpufit/Gpufit +% +% Simple example demonstrating a minimal call of all needed parameters for the Matlab interface +% http://gpufit.readthedocs.io/en/latest/bindings.html#matlab + +% number of fits, number of points per fit +number_fits = 10; +number_points = 10; + +% model ID and number of parameter +model_id = ModelID.GAUSS_1D; +number_parameter = 4; + +% initial parameters +initial_parameters = zeros(number_parameter, number_fits, 'single'); + +% data +data = zeros(number_points, number_fits, 'single'); + +% run Gpufit +[parameters, states, chi_squares, number_iterations, execution_time] = gpufit(data, [], model_id, initial_parameters); + +end \ No newline at end of file diff --git a/Gpufit/matlab/gpufit.m b/Gpufit/matlab/gpufit.m new file mode 100644 index 0000000..2e3beae --- /dev/null +++ b/Gpufit/matlab/gpufit.m @@ -0,0 +1,119 @@ +function [parameters, states, chi_squares, n_iterations, time]... + = gpufit(data, weights, model_id, initial_parameters, tolerance, max_n_iterations, parameters_to_fit, estimator_id, user_info) +% Wrapper around the Gpufit mex file. +% +% Optional arguments can be given as empty matrix []. +% +% Default values as specified + +%% size checks + +% number of input parameter (variable) +if nargin < 9 + user_info = []; + if nargin < 8 + estimator_id = []; + if nargin < 7 + parameters_to_fit = []; + if nargin < 6 + max_n_iterations = []; + if nargin < 5 + tolerance = []; + assert(nargin == 4, 'Not enough parameters'); + end + end + end + end +end + +% data is 2D and read number of points and fits +data_size = size(data); +assert(length(data_size) == 2, 'data is not two-dimensional'); +n_points = data_size(1); +n_fits = data_size(2); + +% consistency with weights (if given) +if ~isempty(weights) + assert(isequal(data_size, size(weights)), 'Dimension mismatch between data and weights') +end + +% initial parameters is 2D and read number of parameters +initial_parameters_size = size(initial_parameters); +assert(length(initial_parameters_size) == 2, 'initial_parameters is not two-dimensional'); +n_parameters = initial_parameters_size(1); +assert(n_fits == initial_parameters_size(2), 'Dimension mismatch in number of fits between data and initial_parameters'); + +% consistency with parameters_to_fit (if given) +if ~isempty(parameters_to_fit) + assert(size(parameters_to_fit, 1) == n_parameters, 'Dimension mismatch in number of parameters between initial_parameters and parameters_to_fit'); +end + +%% default values + +% tolerance +if isempty(tolerance) + tolerance = 1e-4; +end + +% max_n_iterations +if isempty(max_n_iterations) + max_n_iterations = 25; +end + +% estimator_id +if isempty(estimator_id) + estimator_id = EstimatorID.LSE; +end + +% parameters_to_fit +if isempty(parameters_to_fit) + parameters_to_fit = ones(n_parameters, 1, 'int32'); +end + +% now only weights and user_info could be not given (empty matrix) + +%% type checks + +% data, weights (if given), initial_parameters are all single +assert(isa(data, 'single'), 'Type of data is not single'); +if ~isempty(weights) + assert(isa(weights, 'single'), 'Type of weights is not single'); +end +assert(isa(initial_parameters, 'single'), 'Type of initial_parameters is not single'); + +% parameters_to_fit is int32 (cast to int32 if incorrect type) +if ~isa(parameters_to_fit, 'int32') + parameters_to_fit = int32(parameters_to_fit); +end + +% max_n_iterations must be int32 (cast if incorrect type) +if ~isa(max_n_iterations, 'int32') + max_n_iterations = int32(max_n_iterations); +end + +% tolerance must be single (cast if incorrect type) +if ~isa(tolerance, 'single') + tolerance = single(tolerance); +end + +% we don't check type of user_info, but we extract the size in bytes of it +if ~isempty(user_info) + user_info_info = whos('user_info'); + user_info_size = user_info_info.bytes; +else + user_info_size = 0; +end + + +%% run Gpufit taking the time +tic; +[parameters, states, chi_squares, n_iterations] ... + = GpufitMex(data, weights, n_fits, n_points, tolerance, max_n_iterations, estimator_id, initial_parameters, parameters_to_fit, model_id, n_parameters, user_info, user_info_size); + +time = toc; + +% reshape the output parameters array to have dimensions +% (n_parameters,n_fits) +parameters = reshape(parameters,n_parameters,n_fits); + +end diff --git a/Gpufit/matlab/mex/GpufitMex.cpp b/Gpufit/matlab/mex/GpufitMex.cpp new file mode 100644 index 0000000..071ed7c --- /dev/null +++ b/Gpufit/matlab/mex/GpufitMex.cpp @@ -0,0 +1,150 @@ +#include "Gpufit/gpufit.h" + +#include + +#include +#include + +/* + Get a arbitrary scalar (non complex) and check for class id. + https://www.mathworks.com/help/matlab/apiref/mxclassid.html +*/ +template inline bool get_scalar(const mxArray *p, T &v, const mxClassID id) +{ + if (mxIsNumeric(p) && !mxIsComplex(p) && mxGetNumberOfElements(p) == 1 && mxGetClassID(p) == id) + { + v = *static_cast(mxGetData(p)); + return true; + } + else { + return false; + } +} + +void mexFunction( + int nlhs, + mxArray *plhs[], + int nrhs, + mxArray const *prhs[]) +{ + int expected_nrhs = 0; + int expected_nlhs = 0; + bool wrong_nrhs = false; + bool wrong_nlhs = false; + + // expects a certain number of input (nrhs) and output (nlhs) arguments + expected_nrhs = 13; + expected_nlhs = 4; + if (nrhs != expected_nrhs) + { + wrong_nrhs = true; + } + else if (nlhs != expected_nlhs) + { + wrong_nlhs = true; + } + + if (wrong_nrhs || wrong_nlhs) + { + if (nrhs != expected_nrhs) + { + char s1[50]; + _itoa_s(expected_nrhs, s1, 10); + char const s2[] = " input arguments required."; + size_t const string_length = strlen(s1) + 1 + strlen(s2); + strcat_s(s1, string_length, s2); + mexErrMsgIdAndTxt("Gpufit:Mex", s1); + } + else if (nlhs != expected_nlhs) + { + char s1[50]; + _itoa_s(expected_nlhs, s1, 10); + char const s2[] = " output arguments required."; + size_t const string_length = strlen(s1) + 1 + strlen(s2); + strcat_s(s1, string_length, s2); + mexErrMsgIdAndTxt("Gpufit:Mex", s1); + } + } + + // input parameters + float * data = (float*)mxGetPr(prhs[0]); + float * weights = (float*)mxGetPr(prhs[1]); + std::size_t n_fits = (std::size_t)*mxGetPr(prhs[2]); + std::size_t n_points = (std::size_t)*mxGetPr(prhs[3]); + + // tolerance + float tolerance = 0; + if (!get_scalar(prhs[4], tolerance, mxSINGLE_CLASS)) + { + mexErrMsgIdAndTxt("Gpufit:Mex", "tolerance is not a single"); + } + + // max_n_iterations + int max_n_iterations = 0; + if (!get_scalar(prhs[5], max_n_iterations, mxINT32_CLASS)) + { + mexErrMsgIdAndTxt("Gpufit:Mex", "max_n_iteration is not int32"); + } + + int estimator_id = (int)*mxGetPr(prhs[6]); + float * initial_parameters = (float*)mxGetPr(prhs[7]); + int * parameters_to_fit = (int*)mxGetPr(prhs[8]); + int model_id = (int)*mxGetPr(prhs[9]); + int n_parameters = (int)*mxGetPr(prhs[10]); + int * user_info = (int*)mxGetPr(prhs[11]); + std::size_t user_info_size = (std::size_t)*mxGetPr(prhs[12]); + + // output parameters + float * output_parameters; + mxArray * mx_parameters; + mx_parameters = mxCreateNumericMatrix(1, n_fits*n_parameters, mxSINGLE_CLASS, mxREAL); + output_parameters = (float*)mxGetData(mx_parameters); + plhs[0] = mx_parameters; + + int * output_states; + mxArray * mx_states; + mx_states = mxCreateNumericMatrix(1, n_fits, mxINT32_CLASS, mxREAL); + output_states = (int*)mxGetData(mx_states); + plhs[1] = mx_states; + + float * output_chi_squares; + mxArray * mx_chi_squares; + mx_chi_squares = mxCreateNumericMatrix(1, n_fits, mxSINGLE_CLASS, mxREAL); + output_chi_squares = (float*)mxGetData(mx_chi_squares); + plhs[2] = mx_chi_squares; + + int * output_n_iterations; + mxArray * mx_n_iterations; + mx_n_iterations = mxCreateNumericMatrix(1, n_fits, mxINT32_CLASS, mxREAL); + output_n_iterations = (int*)mxGetData(mx_n_iterations); + plhs[3] = mx_n_iterations; + + // call to gpufit + int const status + = gpufit + ( + n_fits, + n_points, + data, + weights, + model_id, + initial_parameters, + tolerance, + max_n_iterations, + parameters_to_fit, + estimator_id, + user_info_size, + reinterpret_cast< char * >( user_info ), + output_parameters, + output_states, + output_chi_squares, + output_n_iterations + ) ; + + // check status + if (status != STATUS_OK) + { + std::string const error = gpufit_get_last_error() ; + mexErrMsgIdAndTxt( "Gpufit:Mex", error.c_str() ) ; + } +} diff --git a/Gpufit/matlab/tests/gauss_fit_1d_test.m b/Gpufit/matlab/tests/gauss_fit_1d_test.m new file mode 100644 index 0000000..412c72e --- /dev/null +++ b/Gpufit/matlab/tests/gauss_fit_1d_test.m @@ -0,0 +1,35 @@ +% Equivalent/similar to tests/Gauss_Fit_1D.cpp + +% constants +n_fits = 1; +n_points = 5; +n_parameters = 4; +true_parameters = single([4; 2; 0.5; 1]); + +% data +x = single((1:n_points)' - 1); +y = gaussian_1d(true_parameters, x); +data = zeros(n_points, n_fits, 'single'); +data(:, 1) = y; + +% model +model_id = ModelID.GAUSS_1D; + +% initial_parameters +initial_parameters = zeros(n_parameters, n_fits, 'single'); +initial_parameters(:, 1) = [2, 1.5, 0.3, 0]; + +% call to gpufit +[parameters, states, chi_squares, n_iterations] = gpufit(data, [], model_id, initial_parameters); + +%% Test results +assert(states == 0); +assert(n_iterations < 10); +assert(chi_squares < 1e-6); +assert(all(abs(parameters - true_parameters) < 1e-6)); + +function y = gaussian_1d(p, x) + +y = p(1) * exp(-(x - p(2)).^2 ./ (2 * p(3).^2)) + p(4); + +end \ No newline at end of file diff --git a/Gpufit/matlab/tests/run_tests.m b/Gpufit/matlab/tests/run_tests.m new file mode 100644 index 0000000..80da345 --- /dev/null +++ b/Gpufit/matlab/tests/run_tests.m @@ -0,0 +1,8 @@ +function run_tests() +% Runs all test scripts in this folder. +% See also: http://www.mathworks.com/help/matlab/script-based-unit-tests.html + +suite = testsuite(); +result = run(suite); +disp(result); +end \ No newline at end of file diff --git a/Gpufit/mle.cuh b/Gpufit/mle.cuh new file mode 100644 index 0000000..32a45a0 --- /dev/null +++ b/Gpufit/mle.cuh @@ -0,0 +1,179 @@ +#ifndef GPUFIT_MLE_CUH_INCLUDED +#define GPUFIT_MLE_CUH_INCLUDED + +#include + +/* Description of the calculate_chi_square_mle function +* ===================================================== +* +* This function calculates the chi-square values for the MLE estimator. +* +* Parameters: +* +* chi_square: An output vector of chi-square values for each data point. +* +* point_index: The data point index. +* +* data: An input vector of data. +* +* value: An input vector of fitting curve values. +* +* weight: An input vector of values for weighting chi-square values. It is not used +* in this function. It can be used in functions calculating other estimators +* than the MLE, such as LSE. +* +* state: A pointer to a value which indicates whether the fitting process was carreid +* out correctly or which problem occurred. It is set to 3 if a fitting curve +* value is negative. +* +* user_info: An input vector containing user information. (not used) +* +* user_info_size: The number of elements in user_info. (not used) +* +* Calling the calculate_chi_square_mle function +* ============================================= +* +* This __device__ function can be only called from a __global__ function or an other +* __device__ function. +* +*/ + +__device__ void calculate_chi_square_mle( + volatile float * chi_square, + int const point_index, + float const * data, + float const * value, + float const * weight, + int * state, + char * user_info, + std::size_t const user_info_size) +{ + if (value[point_index] < 0) + { + *state = 3; + } + + float const deviation = value[point_index] - data[point_index]; + + if (data[point_index] != 0) + { + chi_square[point_index] + = 2 * (deviation - data[point_index] * logf(value[point_index] / data[point_index])); + } + else + { + chi_square[point_index] = 2 * deviation; + } +} + +/* Description of the calculate_hessian_mle function +* ================================================== +* +* This function calculates the hessian matrix values of the MLE equation. The +* calculation is performed based on previously calculated derivative values. +* +* Parameters: +* +* hessian: An output vector of values of the hessian matrix for each data point. +* +* point_index: The data point index. +* +* parameter_index_i: Index of the hessian column. +* +* parameter_index_j: Index of the hessian row. +* +* data: An input vector of data values. +* +* value: An input vector of fitting curve values. +* +* derivative: An input vector of partial derivative values of the fitting +* curve for each data point. +* +* weight: An input vector of values for weighting hessian matrix values. It is not +* used in this function. It can be used in functions calculating other estimators +* than the MLE, such as LSE. +* +* user_info: An input vector containing user information. (not used) +* +* user_info_size: The number of elements in user_info. (not used) +* +* Calling the calculate_hessian_mle function +* ========================================== +* +* This __device__ function can be only called from a __global__ function or an other +* __device__ function. +* +*/ + +__device__ void calculate_hessian_mle( + double * hessian, + int const point_index, + int const parameter_index_i, + int const parameter_index_j, + float const * data, + float const * value, + float const * derivatives, + float const * weight, + char * user_info, + std::size_t const user_info_size) +{ + *hessian + += data[point_index] + / (value[point_index] * value[point_index]) + * derivatives[parameter_index_i] * derivatives[parameter_index_j]; +} + +/* Description of the calculate_gradient_mle function +* =================================================== +* +* This function calculates the gradient values of the MLE equation based +* on previously calculated derivative values. +* +* Parameters: +* +* gradient: An output vector of values of the gradient vector for each data point. +* +* point_index: The data point index. +* +* parameter_index: The parameter index. +* +* data: An input vector of data values. +* +* value: An input vector of fitting curve values. +* +* derivative: An input vector of partial derivative values of the fitting +* curve for each data point. +* +* weight: An input vector of values for weighting gradient vector values. It is not +* used in this function. It can be used in functions calculating other estimators +* than the MLE, such as LSE. +* +* user_info: An input vector containing user information. (not used) +* +* user_info_size: The number of elements in user_info. (not used) +* +* Calling the calculate_gradient_mle function +* =========================================== +* +* This __device__ function can be only called from a __global__ function or an other +* __device__ function. +* +*/ + +__device__ void calculate_gradient_mle( + volatile float * gradient, + int const point_index, + int const parameter_index, + float const * data, + float const * value, + float const * derivative, + float const * weight, + char * user_info, + std::size_t const user_info_size) +{ + gradient[point_index] + = -derivative[parameter_index] + * (1 - data[point_index] / value[point_index]); +} + +#endif diff --git a/Gpufit/python/CMakeLists.txt b/Gpufit/python/CMakeLists.txt new file mode 100644 index 0000000..1ed2b3c --- /dev/null +++ b/Gpufit/python/CMakeLists.txt @@ -0,0 +1,53 @@ + +# Python + +# Python package + +set( build_directory "${CMAKE_BINARY_DIR}/${CMAKE_CFG_INTDIR}/pyGpufit" ) +set( setup_files + "${CMAKE_CURRENT_SOURCE_DIR}/README.txt" + "${CMAKE_CURRENT_SOURCE_DIR}/setup.py" + "${CMAKE_CURRENT_SOURCE_DIR}/setup.cfg" +) +set( module_directory "${build_directory}/pygpufit" ) +set( module_files + "${CMAKE_CURRENT_SOURCE_DIR}/pygpufit/__init__.py" + "${CMAKE_CURRENT_SOURCE_DIR}/pygpufit/gpufit.py" +) +set( binary $ ) + +add_custom_target( PYTHON_PACKAGE + COMMAND ${CMAKE_COMMAND} -E + remove_directory ${build_directory} + COMMAND ${CMAKE_COMMAND} -E + make_directory ${build_directory} + COMMAND ${CMAKE_COMMAND} -E + copy_if_different ${setup_files} ${build_directory} + COMMAND ${CMAKE_COMMAND} -E + make_directory ${module_directory} + COMMAND ${CMAKE_COMMAND} -E + copy_if_different ${module_files} ${module_directory} + COMMAND ${CMAKE_COMMAND} -E + copy_if_different ${binary} ${module_directory} +) +set_property( TARGET PYTHON_PACKAGE PROPERTY FOLDER CMakePredefinedTargets ) +add_dependencies( PYTHON_PACKAGE Gpufit ) + +if( NOT PYTHONINTERP_FOUND ) + message( STATUS "Python NOT found - skipping creation of Python wheel!" ) + return() +endif() + +# Python wheel (output name is incorrect, requires plattform tag, see packaging) + +add_custom_target( PYTHON_WHEEL ALL + COMMAND ${CMAKE_COMMAND} -E + chdir ${build_directory} "${PYTHON_EXECUTABLE}" setup.py clean --all + COMMAND ${CMAKE_COMMAND} -E + chdir ${build_directory} "${PYTHON_EXECUTABLE}" setup.py bdist_wheel + COMMENT "Preparing Python Wheel" +) +set_property( TARGET PYTHON_WHEEL PROPERTY FOLDER CMakePredefinedTargets ) +add_dependencies( PYTHON_WHEEL PYTHON_PACKAGE ) + +# add launcher to Python package diff --git a/Gpufit/python/README.txt b/Gpufit/python/README.txt new file mode 100644 index 0000000..2e58557 --- /dev/null +++ b/Gpufit/python/README.txt @@ -0,0 +1,27 @@ +Python binding for the [Gpufit library](https://github.com/gpufit/Gpufit) which implements Levenberg Marquardt curve fitting in CUDA + +Requirements + +- A CUDA capable graphics card with a recent Nvidia graphics driver (at least 367.48 / July 2016) +- Windows +- Python 2 or 3 with NumPy + +Installation + +Currently the wheel file has to be installed locally. + +If NumPy is not yet installed, install it using pip from the command line + +pip install numpy + +Then install pyGpufit from the local folder via: + +pip install --no-index --find-links=LocalPathToWheelFile pyGpufit + +Examples + +See examples folder. + +Troubleshooting + +A common reason for the error message 'CUDA driver version is insufficient for CUDA runtime version' is an outdated Nvidia graphics driver. \ No newline at end of file diff --git a/Gpufit/python/examples/gauss2d.py b/Gpufit/python/examples/gauss2d.py new file mode 100644 index 0000000..435c4de --- /dev/null +++ b/Gpufit/python/examples/gauss2d.py @@ -0,0 +1,112 @@ +""" + Example of the Python binding of the Gpufit library which implements + Levenberg Marquardt curve fitting in CUDA + https://github.com/gpufit/Gpufit + + Multiple fits of a 2D Gaussian peak function with Poisson distributed noise + http://gpufit.readthedocs.io/en/latest/bindings.html#python + + This example additionally requires numpy. +""" + +import numpy as np +import pygpufit.gpufit as gf + +def generate_gauss_2d(p, xi, yi): + """ + Generates a 2D Gaussian peak. + http://gpufit.readthedocs.io/en/latest/api.html#gauss-2d + + :param p: Parameters (amplitude, x,y center position, width, offset) + :param xi: x positions + :param yi: y positions + :return: The Gaussian 2D peak. + """ + + arg = -(np.square(xi - p[1]) + np.square(yi - p[2])) / (2*p[3]*p[3]) + y = p[0] * np.exp(arg) + p[4] + + return y + +if __name__ == '__main__': + + if not gf.cuda_available(): + raise RuntimeError(gf.get_last_error()) + + # number of fits and fit points + number_fits = 10000 + size_x = 12 + number_points = size_x * size_x + number_parameters = 5 + + # set input arguments + + # true parameters + true_parameters = np.array((10, 5.5, 5.5, 3, 10), dtype=np.float32) + + # initialize random number generator + np.random.seed(0) + + # initial parameters (relative randomized, positions relative to width) + initial_parameters = np.tile(true_parameters, (number_fits, 1)) + initial_parameters[:, (1,2)] += true_parameters[3] * (-0.2 + 0.4 * np.random.rand(number_fits, 2)) + initial_parameters[:, (0, 3, 4)] *= 0.8 + 0.4 * np.random.rand(number_fits, 3) + + # generate x and y values + g = np.arange(size_x) + yi, xi = np.meshgrid(g, g, indexing='ij') + xi = xi.astype(np.float32) + yi = yi.astype(np.float32) + + # generate data + data = generate_gauss_2d(true_parameters, xi, yi) + data = np.reshape(data, (1, number_points)) + data = np.tile(data, (number_fits, 1)) + + # add Poisson noise + data = np.random.poisson(data) + data = data.astype(np.float32) + + # tolerance + tolerance = 0.0001 + + # maximum number of iterations + max_number_iterations = 20 + + # estimator ID + estimator_id = gf.EstimatorID.MLE + + # model ID + model_id = gf.ModelID.GAUSS_2D + + # run Gpufit + parameters, states, chi_squares, number_iterations, execution_time = gf.fit(data, None, model_id, initial_parameters, \ + tolerance, max_number_iterations, None, estimator_id, None) + + # print fit results + converged = states == 0 + print('*Gpufit*') + + # print summary + print('\nmodel ID: {}'.format(model_id)) + print('number of fits: {}'.format(number_fits)) + print('fit size: {} x {}'.format(size_x, size_x)) + print('mean chi_square: {:.2f}'.format(np.mean(chi_squares[converged]))) + print('iterations: {:.2f}'.format(np.mean(number_iterations[converged]))) + print('time: {:.2f} s'.format(execution_time)) + + # get fit states + number_converged = np.sum(converged) + print('\nratio converged {:6.2f} %'.format(number_converged / number_fits * 100)) + print('ratio max it. exceeded {:6.2f} %'.format(np.sum(states == 1) / number_fits * 100)) + print('ratio singular hessian {:6.2f} %'.format(np.sum(states == 2) / number_fits * 100)) + print('ratio neg curvature MLE {:6.2f} %'.format(np.sum(states == 3) / number_fits * 100)) + + # mean, std of fitted parameters + converged_parameters = parameters[converged, :] + converged_parameters_mean = np.mean(converged_parameters, axis=0) + converged_parameters_std = np.std(converged_parameters, axis=0) + print('\nparameters of 2D Gaussian peak') + for i in range(number_parameters): + print('p{} true {:6.2f} mean {:6.2f} std {:6.2f}'.format(i, true_parameters[i], converged_parameters_mean[i], converged_parameters_std[i])) + diff --git a/Gpufit/python/examples/gauss2d_plot.py b/Gpufit/python/examples/gauss2d_plot.py new file mode 100644 index 0000000..d7feb8e --- /dev/null +++ b/Gpufit/python/examples/gauss2d_plot.py @@ -0,0 +1,114 @@ +""" + Example of the Python binding of the Gpufit library which implements + Levenberg Marquardt curve fitting in CUDA + https://github.com/gpufit/Gpufit + + Multiple fits of a 2D Gaussian peak function with Poisson distributed noise + repeated for a different total number of fits each time and plotting the results + http://gpufit.readthedocs.io/en/latest/bindings.html#python + + This example additionally requires numpy (http://www.numpy.org/) and matplotlib (http://matplotlib.org/). +""" + +import numpy as np +import matplotlib.pyplot as plt +import pygpufit.gpufit as gf + +def gaussians_2d(x, y, p): + """ + Generates many 2D Gaussians peaks for a given set of parameters + """ + + n_fits = p.shape[0] + + y = np.zeros((n_fits, x.shape[0], x.shape[1]), dtype=np.float32) + + # loop over each fit + for i in range(n_fits): + pi = p[i, :] + arg = -(np.square(xi - pi[1]) + np.square(yi - pi[2])) / (2 * pi[3] * pi[3]) + y[i, :, :] = pi[0] * np.exp(arg) + pi[4] + + return y + +if __name__ == '__main__': + + print('\n') + + # number of fit points + size_x = 5 + number_points = size_x * size_x + + # set input arguments + + # true parameters + mean_true_parameters = np.array((100, 2, 2, 1, 10), dtype=np.float32) + + # average noise level + average_noise_level = 10 + + # initialize random number generator + np.random.seed(0) + + # tolerance + tolerance = 0.0001 + + # maximum number of iterations + max_number_iterations = 10 + + # model ID + model_id = gf.ModelID.GAUSS_2D + + # loop over different number of fits + n_fits_all = np.around(np.logspace(2, 6, 20)).astype(np.int) + + # generate x and y values + g = np.arange(size_x) + yi, xi = np.meshgrid(g, g, indexing='ij') + xi = xi.astype(np.float32) + yi = yi.astype(np.float32) + + # loop + speed = np.zeros(n_fits_all.size) + for i in range(n_fits_all.size): + n_fits = n_fits_all[i] + + # vary positions of 2D Gaussian peaks slightly + test_parameters = np.tile(mean_true_parameters, (n_fits, 1)) + test_parameters[:, (1,2)] += mean_true_parameters[3] * (-0.2 + 0.4 * np.random.rand(n_fits, 2)) + + # generate data + data = gaussians_2d(xi, yi, test_parameters) + data = np.reshape(data, (n_fits, number_points)) + + # add noise + data += np.random.normal(scale=average_noise_level, size=data.shape) + + # initial parameters (randomized relative (to width for position)) + initial_parameters = np.tile(mean_true_parameters, (n_fits, 1)) + initial_parameters[:, (1,2)] += mean_true_parameters[3] * (-0.2 + 0.4 * np.random.rand(n_fits, 2)) + initial_parameters[:, (0,3,4)] *= 0.8 + 0.4 * np.random.rand(n_fits, 3) + + # run Gpufit + parameters, states, chi_squares, number_iterations, execution_time = gf.fit(data, None, model_id, initial_parameters, tolerance, max_number_iterations) + + # analyze result + converged = states == 0 + speed[i] = n_fits / execution_time + precision_x0 = np.std(parameters[converged, 1] - test_parameters[converged, 1], axis=0, dtype=np.float64) + + # display result + '{} fits '.format(n_fits) + print('{:7} fits iterations: {:6.2f} | time: {:6.3f} s | speed: {:8.0f} fits/s'\ + .format(n_fits, np.mean(number_iterations[converged]), execution_time, speed[i])) + +# plot +plt.semilogx(n_fits_all, speed, 'bo-') +plt.grid(True) +plt.xlabel('number of fits per function call') +plt.ylabel('fits per second') +plt.legend(['Gpufit'], loc='upper left') +ax = plt.gca() +ax.set_xlim(n_fits_all[0], n_fits_all[-1]) + +plt.show() \ No newline at end of file diff --git a/Gpufit/python/examples/simple.py b/Gpufit/python/examples/simple.py new file mode 100644 index 0000000..5184001 --- /dev/null +++ b/Gpufit/python/examples/simple.py @@ -0,0 +1,30 @@ +""" + Example of the Python binding of the Gpufit library which implements + Levenberg Marquardt curve fitting in CUDA + https://github.com/gpufit/Gpufit + + Simple example demonstrating a minimal call of all needed parameters for the Python interface + http://gpufit.readthedocs.io/en/latest/bindings.html#python +""" + +import numpy as np +import pygpufit.gpufit as gf + +if __name__ == '__main__': + + # number of fits, number of points per fit + number_fits = 10 + number_points = 10 + + # model ID and number of parameter + model_id = gf.ModelID.GAUSS_1D + number_parameter = 5 + + # initial parameters + initial_parameters = np.zeros((number_fits, number_parameter), dtype=np.float32) + + # data + data = np.zeros((number_fits, number_points), dtype=np.float32) + + # run Gpufit + parameters, states, chi_squares, number_iterations, execution_time = gf.fit(data, None, model_id, initial_parameters) \ No newline at end of file diff --git a/Gpufit/python/pygpufit/__init__.py b/Gpufit/python/pygpufit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Gpufit/python/pygpufit/gpufit.py b/Gpufit/python/pygpufit/gpufit.py new file mode 100644 index 0000000..22a889f --- /dev/null +++ b/Gpufit/python/pygpufit/gpufit.py @@ -0,0 +1,201 @@ +""" + Python binding for Gpufit, a Levenberg Marquardt curve fitting library written in CUDA + See https://github.com/gpufit/Gpufit, http://gpufit.readthedocs.io/en/latest/bindings.html#python + + The binding is based on ctypes. + See https://docs.python.org/3.5/library/ctypes.html, http://www.scipy-lectures.org/advanced/interfacing_with_c/interfacing_with_c.html +""" + +import os +import time +from ctypes import cdll, POINTER, c_int, c_float, c_char, c_char_p, c_size_t +import numpy as np + +# define library loader (actual loading is lazy) +package_dir = os.path.dirname(os.path.realpath(__file__)) +lib_path = os.path.join(package_dir, 'Gpufit.dll') # this will only work on Windows +lib = cdll.LoadLibrary(lib_path) + +# gpufit function in the dll +gpufit_func = lib.gpufit +gpufit_func.restype = c_int +gpufit_func.argtypes = [c_size_t, c_size_t, POINTER(c_float), POINTER(c_float), c_int, POINTER(c_float), c_float, c_int, POINTER(c_int), c_int, c_size_t, POINTER(c_char), POINTER(c_float), POINTER(c_int), POINTER(c_float), POINTER(c_int)] + +# gpufit_get_last_error function in the dll +error_func = lib.gpufit_get_last_error +error_func.restype = c_char_p +error_func.argtypes = None + +# gpufit_cuda_available function in the dll +cuda_available_func = lib.gpufit_cuda_available +cuda_available_func.restype = c_int +cuda_available_func.argtypes = None + + +class ModelID(): + + GAUSS_1D = 0 + GAUSS_2D = 1 + GAUSS_2D_ELLIPTIC = 2 + GAUSS_2D_ROTATED = 3 + CAUCHY_2D_ELLIPTIC = 4 + LINEAR_1D = 5 + + +class EstimatorID(): + + LSE = 0 + MLE = 1 + + +def fit(data, weights, model_id, initial_parameters, tolerance=None, max_number_iterations=None, \ + parameters_to_fit=None, estimator_id=None, user_info=None): + """ + Calls the C interface fit function in the library. + (see also http://gpufit.readthedocs.io/en/latest/bindings.html#python) + + All 2D NumPy arrays must be in row-major order (standard in NumPy), i.e. array.flags.C_CONTIGUOUS must be True + (see also https://docs.scipy.org/doc/numpy/reference/arrays.ndarray.html#internal-memory-layout-of-an-ndarray) + + :param data: The data - 2D NumPy array of dimension [number_fits, number_points] and data type np.float32 + :param weights: The weights - 2D NumPy array of the same dimension and data type as parameter data or None (no weights available) + :param model_id: The model ID + :param initial_parameters: Initial values for parameters - NumPy array of dimension [number_fits, number_parameters] and data type np.float32 + :param tolerance: The fit tolerance or None (will use default value) + :param max_number_iterations: The maximal number of iterations or None (will use default value) + :param parameters_to_fit: Which parameters to fit - NumPy array of length number_parameters and type np.int32 or None (will fit all parameters) + :param estimator_id: The Estimator ID or None (will use default values) + :param user_info: User info - NumPy array of type np.char or None (no user info available) + :return: parameters, states, chi_squares, number_iterations, execution_time + """ + + # check all 2D NumPy arrays for row-major memory layout (otherwise interpretation of order of dimensions fails) + if not data.flags.c_contiguous: + raise RuntimeError('Memory layout of data array mismatch.') + + if weights is not None and not weights.flags.c_contiguous: + raise RuntimeError('Memory layout of weights array mismatch.') + + if not initial_parameters.flags.c_contiguous: + raise RuntimeError('Memory layout of initial_parameters array mismatch.') + + # size check: data is 2D and read number of points and fits + if data.ndim != 2: + raise RuntimeError('data is not two-dimensional') + number_points = data.shape[1] + number_fits = data.shape[0] + + # size check: consistency with weights (if given) + if weights is not None and data.shape != weights.shape: + raise RuntimeError('dimension mismatch between data and weights') + # the unequal operator checks, type, length and content (https://docs.python.org/3.7/reference/expressions.html#value-comparisons) + + # size check: initial parameters is 2D and read number of parameters + if initial_parameters.ndim != 2: + raise RuntimeError('initial_parameters is not two-dimensional') + number_parameters = initial_parameters.shape[1] + if initial_parameters.shape[0] != number_fits: + raise RuntimeError('dimension mismatch in number of fits between data and initial_parameters') + + # size check: consistency with parameters_to_fit (if given) + if parameters_to_fit is not None and parameters_to_fit.shape[0] != number_parameters: + raise RuntimeError('dimension mismatch in number of parameters between initial_parameters and parameters_to_fit') + + # default value: tolerance + if not tolerance: + tolerance = 1e-4 + + # default value: max_number_iterations + if not max_number_iterations: + max_number_iterations = 25 + + # default value: estimator ID + if not estimator_id: + estimator_id = EstimatorID.LSE + + # default value: parameters_to_fit + if parameters_to_fit is None: + parameters_to_fit = np.ones(number_parameters, dtype=np.int32) + + # now only weights and user_info could be not given + + # type check: data, weights (if given), initial_parameters are all np.float32 + if data.dtype != np.float32: + raise RuntimeError('type of data is not np.float32') + if weights is not None and weights.dtype != np.float32: + raise RuntimeError('type of weights is not np.float32') + if initial_parameters.dtype != np.float32: + raise RuntimeError('type of initial_parameters is not np.float32') + + # type check: parameters_to_fit is np.int32 + if parameters_to_fit.dtype != np.int32: + raise RuntimeError('type of parameters_to_fit is not np.int32') + + # we don't check type of user_info, but we extract the size in bytes of it + if user_info is not None: + user_info_size = user_info.nbytes + else: + user_info_size = 0 + + # pre-allocate output variables + parameters = np.zeros((number_fits, number_parameters), dtype=np.float32) + states = np.zeros(number_fits, dtype=np.int32) + chi_squares = np.zeros(number_fits, dtype=np.float32) + number_iterations = np.zeros(number_fits, dtype=np.int32) + + # conversion to ctypes types for optional C interface parameters using NULL pointer (None) as default argument + if weights is not None: + weights_p = weights.ctypes.data_as(gpufit_func.argtypes[3]) + else: + weights_p = None + if user_info is not None: + user_info_p = user_info.ctypes.data_as(gpufit_func.argtypes[11]) + else: + user_info_p = None + + # call into the library (measure time) + t0 = time.clock() + status = gpufit_func( + gpufit_func.argtypes[0](number_fits), \ + gpufit_func.argtypes[1](number_points), \ + data.ctypes.data_as(gpufit_func.argtypes[2]), \ + weights_p, \ + gpufit_func.argtypes[4](model_id), \ + initial_parameters.ctypes.data_as(gpufit_func.argtypes[5]), \ + gpufit_func.argtypes[6](tolerance), \ + gpufit_func.argtypes[7](max_number_iterations), \ + parameters_to_fit.ctypes.data_as(gpufit_func.argtypes[8]), \ + gpufit_func.argtypes[9](estimator_id), \ + gpufit_func.argtypes[10](user_info_size), \ + user_info_p, \ + parameters.ctypes.data_as(gpufit_func.argtypes[12]), \ + states.ctypes.data_as(gpufit_func.argtypes[13]), \ + chi_squares.ctypes.data_as(gpufit_func.argtypes[14]), \ + number_iterations.ctypes.data_as(gpufit_func.argtypes[15])) + t1 = time.clock() + + + # check status + if status != 0: + # get error from last error and raise runtime error + error_message = error_func() + raise RuntimeError('status = {}, message = {}'.format(status, error_message)) + + # return output values + return parameters, states, chi_squares, number_iterations, t1 - t0 + + +def get_last_error(): + """ + + :return: + """ + return error_func() + + +def cuda_available(): + """ + + :return: True if CUDA is available, False otherwise + """ + return cuda_available_func() != 0 diff --git a/Gpufit/python/requirements.txt b/Gpufit/python/requirements.txt new file mode 100644 index 0000000..b316bf2 --- /dev/null +++ b/Gpufit/python/requirements.txt @@ -0,0 +1 @@ +NumPy>=1.8 \ No newline at end of file diff --git a/Gpufit/python/setup.cfg b/Gpufit/python/setup.cfg new file mode 100644 index 0000000..3c6e79c --- /dev/null +++ b/Gpufit/python/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 diff --git a/Gpufit/python/setup.py b/Gpufit/python/setup.py new file mode 100644 index 0000000..c2e2b83 --- /dev/null +++ b/Gpufit/python/setup.py @@ -0,0 +1,40 @@ +""" + setup script for pyGpufit + + TODO get version, get meaningful email +""" + +from setuptools import setup, find_packages +import os +from io import open # to have encoding as parameter of open on Python >=2.6 + +HERE = os.path.abspath(os.path.dirname(__file__)) + +CLASSIFIERS = ['Development Status :: 5 - Production/Stable', + 'Intended Audience :: End Users/Desktop', + 'Operating System :: Microsoft :: Windows', + 'Topic :: Scientific/Engineering', + 'Topic :: Software Development :: Libraries'] + +def get_long_description(): + """ + Get the long description from the README file. + """ + with open(os.path.join(HERE, 'README.txt'), encoding='utf-8') as f: + return f.read() + +if __name__ == "__main__": + setup(name='pyGpufit', + version='1.0.0', + description='Levenberg Marquardt curve fitting in CUDA', + long_description=get_long_description(), + url='https://github.com/gpufit/Gpufit', + author='M. Bates, A. Przybylski, B. Thiel, and J. Keller-Findeisen', + author_email='a@b.c', + license='', + classifiers=[], + keywords='Levenberg Marquardt, curve fitting, CUDA', + packages=find_packages(where=HERE), + package_data={'pygpufit': ['*.dll']}, + install_requires=['NumPy>=1.0'], + zip_safe=False) \ No newline at end of file diff --git a/Gpufit/python/tests/run_tests.py b/Gpufit/python/tests/run_tests.py new file mode 100644 index 0000000..5395da2 --- /dev/null +++ b/Gpufit/python/tests/run_tests.py @@ -0,0 +1,19 @@ +""" +Discovers all tests and runs them. Assumes that initially the working directory is test. +""" + +import sys +import unittest + +if __name__ == '__main__': + + loader = unittest.defaultTestLoader + + tests = loader.discover('.') + + runner = unittest.TextTestRunner() + + results = runner.run(tests) + + # return number of failures + sys.exit(len(results.failures)) \ No newline at end of file diff --git a/Gpufit/python/tests/test_gaussian_fit_1d.py b/Gpufit/python/tests/test_gaussian_fit_1d.py new file mode 100644 index 0000000..a2f2bd7 --- /dev/null +++ b/Gpufit/python/tests/test_gaussian_fit_1d.py @@ -0,0 +1,76 @@ +""" + Equivalent to https://github.com/gpufit/Gpufit/blob/master/Gpufit/tests/Gauss_Fit_1D.cpp +""" + +import unittest +import numpy as np +import pygpufit.gpufit as gf + +def generate_gauss_1d(parameters, x): + """ + Generates a 1D Gaussian curve. + + :param parameters: The parameters (a, x0, s, b) + :param x: The x values + :return: A 1D Gaussian curve. + """ + + a = parameters[0] + x0 = parameters[1] + s = parameters[2] + b = parameters[3] + + y = a * np.exp(-np.square(x - x0) / (2 * s**2)) + b + + return y + +class Test(unittest.TestCase): + + def test_gaussian_fit_1d(self): + # constants + n_fits = 1 + n_points = 5 + n_parameter = 4 # model will be GAUSS_1D + + # true parameters + true_parameters = np.array((4, 2, 0.5, 1), dtype=np.float32) + + # generate data + data = np.empty((n_fits, n_points), dtype=np.float32) + x = np.arange(n_points, dtype=np.float32) + data[0, :] = generate_gauss_1d(true_parameters, x) + + # tolerance + tolerance = 0.001 + + # max_n_iterations + max_n_iterations = 10 + + # model id + model_id = gf.ModelID.GAUSS_1D + + # initial parameters + initial_parameters = np.empty((n_fits, n_parameter), dtype=np.float32) + initial_parameters[0, :] = (2, 1.5, 0.3, 0) + + # call to gpufit + parameters, states, chi_squares, number_iterations, execution_time = gf.fit(data, None, model_id, + initial_parameters, tolerance, \ + max_n_iterations, None, None, None) + + # print results + for i in range(n_parameter): + print(' p{} true {} fit {}'.format(i, true_parameters[i], parameters[0, i])) + print('fit state : {}'.format(states)) + print('chi square: {}'.format(chi_squares)) + print('iterations: {}'.format(number_iterations)) + print('time: {} s'.format(execution_time)) + + assert (chi_squares < 1e-6) + assert (states == 0) + assert (number_iterations <= max_n_iterations) + for i in range(n_parameter): + assert (abs(true_parameters[i] - parameters[0, i]) < 1e-6) + +if __name__ == '__main__': + unittest.main() diff --git a/Gpufit/python/tests/test_linear_regression.py b/Gpufit/python/tests/test_linear_regression.py new file mode 100644 index 0000000..ad05ff4 --- /dev/null +++ b/Gpufit/python/tests/test_linear_regression.py @@ -0,0 +1,60 @@ +""" + Equivalent to https://github.com/gpufit/Gpufit/blob/master/Gpufit/tests/Linear_Fit_1D.cpp +""" + +import unittest +import numpy as np +import pygpufit.gpufit as gf + +class Test(unittest.TestCase): + + def test_gaussian_fit_1d(self): + # constants + n_fits = 1 + n_points = 2 + n_parameter = 2 + + # true parameters + true_parameters = np.array((0, 1), dtype=np.float32) + + # data values + data = np.empty((n_fits, n_points), dtype=np.float32) + data[0, :] = (0, 1) + + # max number iterations + max_number_iterations = 10 + + # initial parameters + initial_parameters = np.empty((n_fits, n_parameter), dtype=np.float32) + initial_parameters[0, :] = (0, 0) + + # model id + model_id = gf.ModelID.LINEAR_1D + + # tolerance + tolerance = 0.001 + + # user info + user_info = np.array((0, 1), dtype=np.float32) + + # call to gpufit + parameters, states, chi_squares, number_iterations, execution_time = gf.fit(data, None, model_id, + initial_parameters, tolerance, \ + None, None, None, user_info) + + # print results + for i in range(n_parameter): + print(' p{} true {} fit {}'.format(i, true_parameters[i], parameters[0, i])) + print('fit state : {}'.format(states)) + print('chi square: {}'.format(chi_squares)) + print('iterations: {}'.format(number_iterations)) + print('time: {} s'.format(execution_time)) + + assert (chi_squares < 1e-6) + assert (states == 0) + assert (number_iterations <= max_number_iterations) + for i in range(n_parameter): + assert (abs(true_parameters[i] - parameters[0, i]) < 1e-6) + +if __name__ == '__main__': + unittest.main() diff --git a/Gpufit/tests/CMakeLists.txt b/Gpufit/tests/CMakeLists.txt new file mode 100644 index 0000000..a53ba34 --- /dev/null +++ b/Gpufit/tests/CMakeLists.txt @@ -0,0 +1,10 @@ + +# Tests + +add_boost_test( Gpufit Error_Handling ) +add_boost_test( Gpufit Linear_Fit_1D ) +add_boost_test( Gpufit Gauss_Fit_1D ) +add_boost_test( Gpufit Gauss_Fit_2D ) +add_boost_test( Gpufit Gauss_Fit_2D_Elliptic ) +add_boost_test( Gpufit Gauss_Fit_2D_Rotated ) +add_boost_test( Gpufit Cauchy_Fit_2D_Elliptic ) diff --git a/Gpufit/tests/Cauchy_Fit_2D_Elliptic.cpp b/Gpufit/tests/Cauchy_Fit_2D_Elliptic.cpp new file mode 100644 index 0000000..461c726 --- /dev/null +++ b/Gpufit/tests/Cauchy_Fit_2D_Elliptic.cpp @@ -0,0 +1,73 @@ +#define BOOST_TEST_MODULE Gpufit + +#include "Gpufit/gpufit.h" + +#include + +#include + +template +void generate_cauchy_2d_elliptic(std::array< float, SIZE>& values) +{ + int const size_x = int(std::sqrt(SIZE)); + int const size_y = size_x; + + float const a = 4; + float const x0 = (float(size_x) - 1.f) / 2.f; + float const y0 = (float(size_y) - 1.f) / 2.f; + float const sx = 0.4f; + float const sy = 0.6f; + float const b = 1.f; + + for (int point_index_y = 0; point_index_y < size_y; point_index_y++) + { + for (int point_index_x = 0; point_index_x < size_x; point_index_x++) + { + int const point_index = point_index_y * size_x + point_index_x; + float const argx = ((x0 - point_index_x) / sx) *((x0 - point_index_x) / sx) + 1.f; + float const argy = ((y0 - point_index_y) / sy) *((y0 - point_index_y) / sy) + 1.f; + values[point_index] = a / argx / argy + b; + } + } +} + +BOOST_AUTO_TEST_CASE( Cauchy_Fit_2D_Elliptic ) +{ + std::size_t const n_fits{ 1 } ; + std::size_t const n_points{ 25 } ; + std::array< float, n_points > data{}; + generate_cauchy_2d_elliptic(data); + std::array< float, n_points > weights{}; + std::fill(weights.begin(), weights.end(), 1.f); + std::array< float, 6 > initial_parameters{ { 2.f, 1.8f, 2.2f, 0.5f, 0.5f, 0.f } }; + float tolerance{ 0.001f }; + int max_n_iterations{ 100 }; + std::array< int, 6 > parameters_to_fit{ { 1, 1, 1, 1, 1, 1 } }; + std::array< float, 6 > output_parameters; + int output_states; + float output_chi_square; + int output_n_iterations; + + int const status + = gpufit + ( + n_fits, + n_points, + data.data(), + weights.data(), + CAUCHY_2D_ELLIPTIC, + initial_parameters.data(), + tolerance, + max_n_iterations, + parameters_to_fit.data(), + LSE, + 0, + 0, + output_parameters.data(), + &output_states, + &output_chi_square, + &output_n_iterations + ) ; + + BOOST_CHECK( status == 0 ) ; +} diff --git a/Gpufit/tests/Error_Handling.cpp b/Gpufit/tests/Error_Handling.cpp new file mode 100644 index 0000000..c35a078 --- /dev/null +++ b/Gpufit/tests/Error_Handling.cpp @@ -0,0 +1,51 @@ +#define BOOST_TEST_MODULE Gpufit + +#include "Gpufit/gpufit.h" + +#include + +#include + +BOOST_AUTO_TEST_CASE( Error_Handling ) +{ + std::size_t const n_fits{ 1 } ; + std::size_t const n_points{ 2 } ; + std::array< float, n_points > data{ { 0, 1 } } ; + std::array< float, n_points > weights{ { 1, 1 } } ; + std::array< float, 2 > initial_parameters{ { 0, 0 } } ; + float tolerance{ 0.001f } ; + int max_n_iterations{ 10 } ; + std::array< int, 2 > parameters_to_fit{ { 0, 0 } } ; + std::array< int, 2 > user_info{ { 0, 1 } } ; + std::array< float, 2 > output_parameters ; + int output_states ; + float output_chi_square ; + int output_n_iterations ; + + int const status + = gpufit + ( + n_fits, + n_points, + data.data(), + weights.data(), + LINEAR_1D, + initial_parameters.data(), + tolerance, + max_n_iterations, + parameters_to_fit.data(), + LSE, + n_points * sizeof( int ), + reinterpret_cast< char * >( user_info.data() ), + output_parameters.data(), + & output_states, + & output_chi_square, + & output_n_iterations + ) ; + + BOOST_CHECK( status == - 1 ) ; + + std::string const error = gpufit_get_last_error() ; + + BOOST_CHECK( error == "invalid configuration argument" ) ; +} diff --git a/Gpufit/tests/Gauss_Fit_1D.cpp b/Gpufit/tests/Gauss_Fit_1D.cpp new file mode 100644 index 0000000..81a8c64 --- /dev/null +++ b/Gpufit/tests/Gauss_Fit_1D.cpp @@ -0,0 +1,87 @@ +#define BOOST_TEST_MODULE Gpufit + +#include "Gpufit/gpufit.h" + +#include + +#include + +template +void generate_gauss_1d( + std::array< float, n_points >& values, + std::array< float, 4 > const & parameters ) +{ + float const a = parameters[ 0 ]; + float const x0 = parameters[ 1 ]; + float const s = parameters[ 2 ]; + float const b = parameters[ 3 ]; + + for ( int point_index = 0; point_index < n_points; point_index++ ) + { + float const argx = ( ( point_index - x0 )*( point_index - x0 ) ) / ( 2.f * s * s ); + float const ex = exp( -argx ); + values[ point_index ] = a * ex + b; + } +} + +BOOST_AUTO_TEST_CASE( Gauss_Fit_1D ) +{ + /* + Performs a single fit using the GAUSS_1D model. + - Doesn't use user_info or weights. + - No noise is added. + - Checks fitted parameters equalling the true parameters. + */ + + std::size_t const n_fits{ 1 } ; + std::size_t const n_points{ 5 } ; + + std::array< float, 4 > const true_parameters{ { 4.f, 2.f, 0.5f, 1.f } }; + + std::array< float, n_points > data{}; + generate_gauss_1d( data, true_parameters ); + + std::array< float, 4 > initial_parameters{ { 2.f, 1.5f, 0.3f, 0.f } }; + + float tolerance{ 0.001f }; + + int max_n_iterations{ 10 }; + + std::array< int, 4 > parameters_to_fit{ { 1, 1, 1, 1 } }; + + std::array< float, 4 > output_parameters; + int output_states; + float output_chi_square; + int output_n_iterations; + + int const status + = gpufit + ( + n_fits, + n_points, + data.data(), + 0, + GAUSS_1D, + initial_parameters.data(), + tolerance, + max_n_iterations, + parameters_to_fit.data(), + LSE, + 0, + 0, + output_parameters.data(), + &output_states, + &output_chi_square, + &output_n_iterations + ) ; + + BOOST_CHECK( status == 0 ) ; + BOOST_CHECK( output_states == 0 ); + BOOST_CHECK( output_chi_square < 1e-6f ); + BOOST_CHECK( output_n_iterations <= max_n_iterations ); + + BOOST_CHECK( std::fabsf(output_parameters[ 0 ] - true_parameters[ 0 ] ) < 1e-6f ); + BOOST_CHECK( std::fabsf(output_parameters[ 1 ] - true_parameters[ 1 ] ) < 1e-6f ); + BOOST_CHECK( std::fabsf(output_parameters[ 2 ] - true_parameters[ 2 ] ) < 1e-6f ); + BOOST_CHECK( std::fabsf(output_parameters[ 3 ] - true_parameters[ 3 ] ) < 1e-6f ); +} diff --git a/Gpufit/tests/Gauss_Fit_2D.cpp b/Gpufit/tests/Gauss_Fit_2D.cpp new file mode 100644 index 0000000..0222933 --- /dev/null +++ b/Gpufit/tests/Gauss_Fit_2D.cpp @@ -0,0 +1,96 @@ +#define BOOST_TEST_MODULE Gpufit + +#include "Gpufit/gpufit.h" + +#include + +#include + +template +void generate_gauss_2d(std::array< float , SIZE>& values) +{ + int const size_x = int(std::sqrt(SIZE)); + int const size_y = size_x; + + float const a = 4.f; + float const x0 = (float(size_x) - 1.f) / 2.f; + float const y0 = (float(size_y) - 1.f) / 2.f; + float const s = 0.5f; + float const b = 1.f; + + for (int point_index_y = 0; point_index_y < size_y; point_index_y++) + { + for (int point_index_x = 0; point_index_x < size_x; point_index_x++) + { + int const point_index = point_index_y * size_x + point_index_x; + float const argx = ((point_index_x - x0)*(point_index_x - x0)) / (2.f * s * s); + float const argy = ((point_index_y - y0)*(point_index_y - y0)) / (2.f * s * s); + float const ex = exp(-argx) * exp(-argy); + values[point_index] = a * ex + b; + } + } +} + +BOOST_AUTO_TEST_CASE( Gauss_Fit_2D ) +{ + std::size_t const n_fits{ 1 } ; + std::size_t const n_points{ 25 } ; + std::array< float, n_points > data{}; + generate_gauss_2d(data); + std::array< float, n_points > weights{}; + std::fill(weights.begin(), weights.end(), 1.f); + std::array< float, 5 > initial_parameters{ { 2.f, 1.8f, 2.2f, 0.4f, 0.f } }; + float tolerance{ 0.001f }; + int max_n_iterations{ 10 }; + std::array< int, 5 > parameters_to_fit{ { 1, 1, 1, 1, 1 } }; + std::array< float, 5 > output_parameters; + int output_states; + float output_chi_square; + int output_n_iterations; + + int const status + = gpufit + ( + n_fits, + n_points, + data.data(), + 0, + GAUSS_2D, + initial_parameters.data(), + tolerance, + max_n_iterations, + parameters_to_fit.data(), + LSE, + 0, + 0, + output_parameters.data(), + &output_states, + &output_chi_square, + &output_n_iterations + ) ; + + BOOST_CHECK( status == 0 ) ; + + int const status_with_weights + = gpufit + ( + n_fits, + n_points, + data.data(), + weights.data(), + GAUSS_2D, + initial_parameters.data(), + tolerance, + max_n_iterations, + parameters_to_fit.data(), + LSE, + 0, + 0, + output_parameters.data(), + &output_states, + &output_chi_square, + &output_n_iterations + ) ; + + BOOST_CHECK( status_with_weights == 0 ) ; +} diff --git a/Gpufit/tests/Gauss_Fit_2D_Elliptic.cpp b/Gpufit/tests/Gauss_Fit_2D_Elliptic.cpp new file mode 100644 index 0000000..072169c --- /dev/null +++ b/Gpufit/tests/Gauss_Fit_2D_Elliptic.cpp @@ -0,0 +1,74 @@ +#define BOOST_TEST_MODULE Gpufit + +#include "Gpufit/gpufit.h" + +#include + +#include + +template +void generate_gauss_2d_elliptic(std::array< float, SIZE>& values) +{ + int const size_x = int(std::sqrt(SIZE)); + int const size_y = size_x; + + float const a = 4; + float const x0 = (float(size_x) - 1.f) / 2.f; + float const y0 = (float(size_y) - 1.f) / 2.f; + float const sx = 0.4f; + float const sy = 0.6f; + float const b = 1.f; + + for (int point_index_y = 0; point_index_y < size_y; point_index_y++) + { + for (int point_index_x = 0; point_index_x < size_x; point_index_x++) + { + int const point_index = point_index_y * size_x + point_index_x; + float const argx = ((point_index_x - x0)*(point_index_x - x0)) / (2.f * sx * sx); + float const argy = ((point_index_y - y0)*(point_index_y - y0)) / (2.f* sy * sy); + float const ex = exp(-argx) * exp(-argy); + values[point_index] = a * ex + b; + } + } +} + +BOOST_AUTO_TEST_CASE( Gauss_Fit_2D_Elliptic ) +{ + std::size_t const n_fits{ 1 } ; + std::size_t const n_points{ 25 } ; + std::array< float, n_points > data{}; + generate_gauss_2d_elliptic(data); + std::array< float, n_points > weights{}; + std::fill(weights.begin(), weights.end(), 1.f); + std::array< float, 6 > initial_parameters{ { 2.f, 1.8f, 2.2f, 0.5f, 0.5f, 0.f } }; + float tolerance{ 0.001f }; + int max_n_iterations{ 10 }; + std::array< int, 6 > parameters_to_fit{ { 1, 1, 1, 1, 1, 1 } }; + std::array< float, 6 > output_parameters; + int output_states; + float output_chi_square; + int output_n_iterations; + + int const status + = gpufit + ( + n_fits, + n_points, + data.data(), + weights.data(), + GAUSS_2D_ELLIPTIC, + initial_parameters.data(), + tolerance, + max_n_iterations, + parameters_to_fit.data(), + LSE, + 0, + 0, + output_parameters.data(), + &output_states, + &output_chi_square, + &output_n_iterations + ) ; + + BOOST_CHECK( status == 0 ) ; +} diff --git a/Gpufit/tests/Gauss_Fit_2D_Rotated.cpp b/Gpufit/tests/Gauss_Fit_2D_Rotated.cpp new file mode 100644 index 0000000..55cd682 --- /dev/null +++ b/Gpufit/tests/Gauss_Fit_2D_Rotated.cpp @@ -0,0 +1,77 @@ +#define BOOST_TEST_MODULE Gpufit + +#define PI 3.1415926535897f + +#include "Gpufit/gpufit.h" + +#include + +#include + +template +void generate_gauss_2d_rotated(std::array< float, SIZE>& values) +{ + int const size_x = int(std::sqrt(SIZE)); + int const size_y = size_x; + + float const a = 10.f; + float const x0 = (float(size_x) - 1.f) / 2.f; + float const y0 = (float(size_y) - 1.f) / 2.f; + float const sx = 0.4f; + float const sy = 0.5f; + float const b = 1.f; + float const r = PI / 16.f; + + for (int point_index_y = 0; point_index_y < size_y; point_index_y++) + { + for (int point_index_x = 0; point_index_x < size_x; point_index_x++) + { + int const point_index = point_index_y * size_x + point_index_x; + float const arga = ((point_index_x - x0) * cosf(r)) - ((point_index_y - y0) * sinf(r)); + float const argb = ((point_index_x - x0) * sinf(r)) + ((point_index_y - y0) * cosf(r)); + float const ex = exp((-0.5f) * (((arga / sx) * (arga / sx)) + ((argb / sy) * (argb / sy)))); + values[point_index] = a * ex + b; + } + } +} + +BOOST_AUTO_TEST_CASE( Gauss_Fit_2D_Rotated ) +{ + std::size_t const n_fits{ 1 } ; + std::size_t const n_points{ 64 } ; + std::array< float, n_points > data{}; + generate_gauss_2d_rotated(data); + std::array< float, n_points > weights{}; + std::fill(weights.begin(), weights.end(), 1.f); + std::array< float, 7 > initial_parameters{ { 8.f, 3.4f, 3.6f, 0.4f, 0.5f, 2.f, 0.f } }; + float tolerance{ 0.001f }; + int max_n_iterations{ 10 }; + std::array< int, 7 > parameters_to_fit{ { 1, 1, 1, 1, 1, 1, 1 } }; + std::array< float, 7 > output_parameters; + int output_states; + float output_chi_square; + int output_n_iterations; + + int const status + = gpufit + ( + n_fits, + n_points, + data.data(), + weights.data(), + GAUSS_2D_ROTATED, + initial_parameters.data(), + tolerance, + max_n_iterations, + parameters_to_fit.data(), + LSE, + 0, + 0, + output_parameters.data(), + &output_states, + &output_chi_square, + &output_n_iterations + ) ; + + BOOST_CHECK( status == 0 ) ; +} diff --git a/Gpufit/tests/Linear_Fit_1D.cpp b/Gpufit/tests/Linear_Fit_1D.cpp new file mode 100644 index 0000000..abd7c81 --- /dev/null +++ b/Gpufit/tests/Linear_Fit_1D.cpp @@ -0,0 +1,101 @@ +#define BOOST_TEST_MODULE Gpufit + +#include "Gpufit/gpufit.h" + +#include + +#include + +BOOST_AUTO_TEST_CASE( Linear_Fit_1D ) +{ + /* + Performs a single fit using the Linear Fit (LINEAR_1D) model. + - Uses user info + - Uses trivial weights. + - No noise is added. + - Checks fitted parameters equalling the true parameters. + */ + + std::size_t const n_fits{ 1 } ; + std::size_t const n_points{ 2 } ; + + std::array< float, 2 > const true_parameters{ { 1, 1 } }; + + std::array< float, n_points > data{ { 1, 2 } } ; + + std::array< float, n_points > weights{ { 1, 1 } } ; + + std::array< float, 2 > initial_parameters{ { 1, 0 } } ; + + float tolerance{ 0.001f } ; + + int max_n_iterations{ 10 } ; + + std::array< int, 2 > parameters_to_fit{ { 1, 1 } } ; + + std::array< float, n_points > user_info{ { 0.f, 1.f } } ; + + std::array< float, 2 > output_parameters ; + int output_states ; + float output_chi_squares ; + int output_n_iterations ; + + // test with LSE + int status = gpufit + ( + n_fits, + n_points, + data.data(), + weights.data(), + LINEAR_1D, + initial_parameters.data(), + tolerance, + max_n_iterations, + parameters_to_fit.data(), + LSE, + n_points * sizeof( float ), + reinterpret_cast< char * >( user_info.data() ), + output_parameters.data(), + & output_states, + & output_chi_squares, + & output_n_iterations + ) ; + + BOOST_CHECK( status == 0 ) ; + BOOST_CHECK( output_states == 0 ); + BOOST_CHECK( output_n_iterations <= max_n_iterations ); + BOOST_CHECK( output_chi_squares < 1e-6f ); + + BOOST_CHECK(std::fabsf(output_parameters[0] - true_parameters[0]) < 1e-6f); + BOOST_CHECK(std::fabsf(output_parameters[1] - true_parameters[1]) < 1e-6f); + + // test with MLE + status = gpufit + ( + n_fits, + n_points, + data.data(), + weights.data(), + LINEAR_1D, + initial_parameters.data(), + tolerance, + max_n_iterations, + parameters_to_fit.data(), + MLE, + n_points * sizeof(float), + reinterpret_cast< char * >(user_info.data()), + output_parameters.data(), + &output_states, + &output_chi_squares, + &output_n_iterations + ); + + BOOST_CHECK(status == 0); + BOOST_CHECK(output_states == 0); + BOOST_CHECK(output_n_iterations <= max_n_iterations); + BOOST_CHECK(output_chi_squares < 1e-6f); + + BOOST_CHECK(std::fabsf(output_parameters[0] - true_parameters[0]) < 1e-6f); + BOOST_CHECK(std::fabsf(output_parameters[1] - true_parameters[1]) < 1e-4f); + +} diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..6fe98c3 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Mark Bates, Adrian Przybylski, Björn Thiel, and Jan Keller-Findeisen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..498877e --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# Gpufit + +Levenberg Marquardt curve fitting in CUDA. + +Homepage: [github.com/gpufit/Gpufit](https://github.com/gpufit/Gpufit) + +## Quick start instructions + +To verify that Gpufit is working correctly on the host computer, go to the folder gpufit_performance_test of the binary package and run Gpufit_Cpufit_Performance_Comparison.exe. Further details of the test executable can be found in the documentation package. + +## Binary distribution + +The latest Gpufit binary release, supporting Windows 32-bit and 64-bit machines, can be found on the [release page](https://github.com/gpufit/Gpufit/releases). + +## Documentation + +[![Documentation Status](https://readthedocs.org/projects/gpufit/badge/?version=latest)](http://gpufit.readthedocs.io/en/latest/?badge=latest) + +Documentation for the Gpufit library may be found online ([latest documentation](http://gpufit.readthedocs.io/en/latest/?badge=latest)), and also +as a PDF file in the binary distribution of Gpufit. + +## Building Gpufit from source code + +Instructions for building Gpufit are found in the documentation: [Building from source code](https://github.com/gpufit/Gpufit/blob/master/docs/installation.rst). + +## Using the Gpufit binary distribution + +Instructions for using the bindary distribution may be found in the documentation. The binary package contains: + +- The Gpufit SDK, which consists of the 32-bit and 64-bit DLL files, and + the Gpufit header file which contains the function definitions. The Gpufit + SDK is intented to be used when calling Gpufit from an external application + written in e.g. C code. +- Gpufit Performance test: A simple console application comparing the execution speed of curve fitting on the GPU and CPU. This program also serves as a test to ensure the correct functioning of Gpufit. +- Matlab 32 bit and 64 bit bindings, with Matlab examples. +- Python version 2.x and version 3.x bindings (compiled as wheel files) and + Python examples. +- The Gpufit manual in PDF format + +## License + +MIT License + +Copyright (c) 2017 Mark Bates, Adrian Przybylski, Björn Thiel, and Jan Keller-Findeisen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/_static/style.css b/docs/_static/style.css new file mode 100644 index 0000000..6c92e05 --- /dev/null +++ b/docs/_static/style.css @@ -0,0 +1,15 @@ +.wy-nav-content { + max-width: 1100px !important; +} + +@media screen and (max-width: 767px) { + .wy-table-responsive table td { + white-space: nowrap; + } +} + +@media screen and (min-width: 768px) { + .wy-table-responsive table td { + white-space: normal; + } +} \ No newline at end of file diff --git a/docs/_templates/layout.html b/docs/_templates/layout.html new file mode 100644 index 0000000..b0a4480 --- /dev/null +++ b/docs/_templates/layout.html @@ -0,0 +1,4 @@ +{% extends "!layout.html" %} +{% block extrahead %} + +{% endblock %} \ No newline at end of file diff --git a/docs/appendix.rst b/docs/appendix.rst new file mode 100644 index 0000000..103df3e --- /dev/null +++ b/docs/appendix.rst @@ -0,0 +1,31 @@ +======== +Appendix +======== + +Levenberg-Marquardt algorithm +----------------------------- + +A flowchart of the implementation of the Levenberg-Marquardt algorithm is given in :numref:`appendix-gpufit-flowchart`. + +.. _appendix-gpufit-flowchart: + +.. figure:: /images/gpufit_program_flow_skeleton_v2.png + :width: 14 cm + :align: center + + Levenberg-Marquardt algorithm flow as implemented in |GF|. + + +Performance comparison to other GPU benchmarks +---------------------------------------------- + +Using the bundled application to estimate the fitting speed per second of 10 million fits for various CUDA capable +graphics cards of various architectures (on different computers with different versions of graphics drivers) we can +compare to the results of Passmark G3D. By and large, the results seem to correlate, i.e. a high Passmark G3D score +also relates to a high Gpufit fitting speed. + +.. figure:: /images/Gpufit_PassmarkG3D_relative_performance.png + :width: 14 cm + :align: center + + Performance of Gpufit vs Passmark G3D \ No newline at end of file diff --git a/docs/bindings.rst b/docs/bindings.rst new file mode 100644 index 0000000..ff3d914 --- /dev/null +++ b/docs/bindings.rst @@ -0,0 +1,413 @@ +.. _external-bindings: + +================= +External bindings +================= + +This sections describes the Gpufit bindings to other programming languages. The bindings (e.g. to Python or Matlab) aim to +emulate the :ref:`c-interface` as closely as possible. + +Most high level languages feature multidimensional numerical arrays. In the bindings implemented for Matlab and Python, +we adopt the convention that the input data should be organized as a 2D array, with one dimension corresponding to the +number of data points per fit, and the other corresponding to the number of fits. Internally, in memory, these arrays should +always be ordered such that the data values for each fit are kept together. In Matlab, for example, this means storing the +data in an array with dimensions [number_points_per_fit, number_fits]. In this manner, the data in memory is ordered in the +same way that is expected by the Gpufit C interface, and there is no need to copy or otherwise re-organize the data +before passing it to the GPU. The same convention is used for the weights, the initial model parameters, and the output parameters. + +Unlike the C interface, the external bindings to not require the number of fits and the number of data points per fit to be +specified explicitly. Instead, these numbers are inferred from the dimensions of the 2D input arrays. + +Optional parameters with default values +--------------------------------------- + +The external bindings make some input parameters optional. The optional parameters are shown here. + +:tolerance: + default value 1e-4 +:max_n_iterations: + default value 25 iterations +:estimator_id: + the default estimator is LSE as defined in gpufit.h_ +:parameters_to_fit: + by default all parameters are fit + +For instructions on how to specify these parameters explicitly, see the sections below. + +Python +------ + +The Gpufit binding for Python is a project named pyGpufit. This project contains a Python package named pygpufit, which +contains a module gpufit, and this module implements a method called fit. Calling this method is equivalent to +calling the C interface function *gpufit()* of |GF|. The package expects the input data to be +stored as NumPy array. NumPy follows row-major order by default. + +Installation +++++++++++++ + +Wheel files for Python 2.X and 3.X on Windows 32/64 bit are included in the binary package. NumPy is required. + +Install the wheel file with. + +.. code-block:: bash + + pip install --no-index --find-links=LocalPathToWheelFile pyGpufit + +Python Interface +++++++++++++++++ + +Optional parameters are passed in as None. The numbers of points, fits and parameters is deduced from the dimensions of +the input data and initial parameters arrays. + +The signature of the gpufit method is + +.. code-block:: python + + def fit(data, weights, model_id:ModelID, initial_parameters, tolerance:float=None, max_number_iterations:int=None, parameters_to_fit=None, estimator_id:EstimatorID=None, user_info=None): + +*Input parameters* + +:data: Data + 2D NumPy array of shape (number_fits, number_points) and data type np.float32 +:weights: Weights + 2D NumPy array of shape (number_fits, number_points) and data type np.float32 (same as data) + + :special: None indicates that no weights are available +:tolerance: Fit tolerance + + :type: float + :special: If None, the default value will be used. +:max_number_iterations: Maximal number of iterations + + :type: int + :special: If None, the default value will be used. +:estimator_id: estimator ID + + :type: EstimatorID which is an Enum in the same module and defined analogously to gpufit.h_. + :special: If None, the default value is used. +:model_id: model ID + + :type: ModelID which is an Enum in the same module and defined analogously to gpufit.h_. +:initial_parameters: Initial parameters + 2D NumPy array of shape (number_fits, number_parameter) + + :array data type: np.float32 +:parameters_to_fit: parameters to fit + 1D NumPy array of length number_parameter + A zero indicates that this parameter should not be fitted, everything else means it should be fitted. + + :array data type: np.int32 + :special: If None, the default value is used. +:user_info: user info + 1D NumPy array of arbitrary type. The length in bytes is deduced automatically. + + :special: If None, no user_info is assumed. + +*Output parameters* + +:parameters: Fitted parameters for each fit + 2D NumPy array of shape (number_fits, number_parameter) and data type np.float32 +:states: Fit result states for each fit + 1D NumPy array of length number_parameter of data type np.int32 + As defined in gpufit.h_: +:chi_squares: :math:`\chi^2` values for each fit + 1D NumPy array of length number_parameter of data type np.float32 +:n_iterations: Number of iterations done for each fit + 1D NumPy array of length number_parameter of data type np.int32 +:time: Execution time of call to fit + In seconds. + +Errors are raised if checks on parameters fail or if the execution of fit failed. + +Python Examples ++++++++++++++++ + +2D Gaussian peak example +........................ + +An example can be found at `Python Gauss2D example`_. It is equivalent to :ref:`c-example-2d-gaussian`. + +The essential imports are: + +.. code-block:: python + + import numpy as np + import pygpufit.gpufit as gf + +The true parameters describing an example 2D Gaussian peak functions are: + +.. code-block:: python + + # true parameters + true_parameters = np.array((10, 5.5, 5.5, 3, 10), dtype=np.float32) + +A 2D grid of x and y positions can conveniently be generated using the np.meshgrid function: + +.. code-block:: python + + # generate x and y values + g = np.arange(size_x) + yi, xi = np.meshgrid(g, g, indexing='ij') + xi = xi.astype(np.float32) + yi = yi.astype(np.float32) + +Using these positions and the true parameter values a model function can be calculated as + +.. code-block:: python + + def generate_gauss_2d(p, xi, yi): + """ + Generates a 2D Gaussian peak. + http://gpufit.readthedocs.io/en/latest/api.html#gauss-2d + + :param p: Parameters (amplitude, x,y center position, width, offset) + :param xi: x positions + :param yi: y positions + :return: The Gaussian 2D peak. + """ + + arg = -(np.square(xi - p[1]) + np.square(yi - p[2])) / (2*p[3]*p[3]) + y = p[0] * np.exp(arg) + p[4] + + return y + +The model function can be repeated and noise can be added using the np.tile and np.random.poisson functions. + +.. code-block:: python + + # generate data + data = generate_gauss_2d(true_parameters, xi, yi) + data = np.reshape(data, (1, number_points)) + data = np.tile(data, (number_fits, 1)) + + # add Poisson noise + data = np.random.poisson(data) + data = data.astype(np.float32) + +The model and estimator IDs can be set as + +.. code-block:: python + + # estimator ID + estimator_id = gf.EstimatorID.MLE + + # model ID + model_id = gf.ModelID.GAUSS_2D + +When all input parameters are set we can call the C interface of Gpufit. + +.. code-block:: python + + # run Gpufit + parameters, states, chi_squares, number_iterations, execution_time = gf.fit(data, None, model_id, initial_parameters, tolerance, max_number_iterations, None, estimator_id, None) + +And finally statistics about the results of the fits can be displayed where the mean and standard deviation of the +fitted parameters are limited to those fits that converged. + +.. code-block:: python + + # print fit results + + # get fit states + converged = states == 0 + number_converged = np.sum(converged) + print('ratio converged {:6.2f} %'.format(number_converged / number_fits * 100)) + print('ratio max it. exceeded {:6.2f} %'.format(np.sum(states == 1) / number_fits * 100)) + print('ratio singular hessian {:6.2f} %'.format(np.sum(states == 2) / number_fits * 100)) + print('ratio neg curvature MLE {:6.2f} %'.format(np.sum(states == 3) / number_fits * 100)) + print('ratio gpu not read {:6.2f} %'.format(np.sum(states == 4) / number_fits * 100)) + + # mean, std of fitted parameters + converged_parameters = parameters[converged, :] + converged_parameters_mean = np.mean(converged_parameters, axis=0) + converged_parameters_std = np.std(converged_parameters, axis=0) + + for i in range(number_parameters): + print('p{} true {:6.2f} mean {:6.2f} std {:6.2f}'.format(i, true_parameters[i], converged_parameters_mean[i], converged_parameters_std[i])) + + # print summary + print('model ID: {}'.format(model_id)) + print('number of fits: {}'.format(number_fits)) + print('fit size: {} x {}'.format(size_x, size_x)) + print('mean chi_square: {:.2f}'.format(np.mean(chi_squares[converged]))) + print('iterations: {:.2f}'.format(np.mean(number_iterations[converged]))) + print('time: {:.2f} s'.format(execution_time)) + + +Matlab +------ + +The Matlab binding for Gpufit is a Matlab script (gpufit.m_). This script checks the input data, sets default parameters, and +calls the C interface of |GF|, via a compiled .mex file. + +Please note, that before using the Matlab binding, the path to gpufit.m_ must be added to the Matlab path. + +If other GPU-based computations are to be performed with Matlab in the same session, please use the Matlab GPU computing +functionality first (for example with a call to gpuDevice or gpuArray) before calling the Gpufit Matlab binding. If this is not +done, Matlab will throw an error (Error using gpuArray An unexpected error occurred during CUDA execution. +The CUDA error was: cannot set while device is active in this process). + +Matlab Interface +++++++++++++++++ + +Optional parameters are passed in as empty matrices (``[]``). The numbers of points, fits and parameters is deduced from the dimensions of +the input data and initial parameters matrices. + +The signature of the gpufit function is + +.. code-block:: matlab + + function [parameters, states, chi_squares, n_iterations, time] = gpufit(data, weights, model_id, initial_parameters, tolerance, max_n_iterations, parameters_to_fit, estimator_id, user_info) + +*Input parameters* + +:data: Data + 2D matrix of size [number_points, number_fits] and data type single +:weights: Weights + 2D matrix of size [number_points, number_fits] and data type single (same as data) + + :special: None indicates that no weights are available +:tolerance: Fit tolerance + + :type: single + :special: If empty ([]), the default value will be used. +:max_number_iterations: Maximal number of iterations + Will be converted to int32 if necessary + + :special: If empty ([]), the default value will be used. +:estimator_id: estimator ID + + :type: EstimatorID which is defined in EstimatorID.m analogously to gpufit.h_. + :special: If empty ([]), the default value is used. +:model_id: model ID + + :type: ModelID which is defined in ModelID.m analogously to gpufit.h_. +:initial_parameters: Initial parameters + 2D matrix of size: [number_parameter, number_fits] + + :type: single +:parameters_to_fit: parameters to fit + vector of length number_parameter, will be converted to int32 if necessary + A zero indicates that this parameter should not be fitted, everything else means it should be fitted. + + :special: If empty ([]), the default value is used. +:user_info: user info + vector of arbitrary type. The length in bytes is deduced automatically. + +*Output parameters* + +:parameters: Fitted parameters for each fit + 2D matrix of size: [number_parameter, number_fits] of data type single +:states: Fit result states for each fit + vector of length number_parameter of data type int32 + As defined in gpufit.h_: +:chi_squares: :math:`\chi^2` values for each fit + vector of length number_parameter of data type single +:n_iterations: Number of iterations done for each fit + vector of length number_parameter of data type int32 +:time: Execution time of call to gpufit + In seconds. + +Errors are raised if checks on parameters fail or if the execution of gpufit fails. + +Matlab Examples ++++++++++++++++ + +Simple example +.............. + +The most simple example is the `Matlab simple example`_. It is equivalent to :ref:`c-example-simple` and additionally +relies on default values for optional arguments. + +2D Gaussian peak example +........................ + +An example can be found at `Matlab Gauss2D example`_. It is equivalent to :ref:`c-example-2d-gaussian`. + +The true parameters describing an example 2D Gaussian peak functions are: + +.. code-block:: matlab + + % true parameters + true_parameters = single([10, 5.5, 5.5, 3, 10]); + +A 2D grid of x and y positions can conveniently be generated using the ndgrid function: + +.. code-block:: matlab + + % generate x and y values + g = single(0 : size_x - 1); + [x, y] = ndgrid(g, g); + +Using these positions and the true parameter values a model function can be calculated as + +.. code-block:: matlab + + function g = gaussian_2d(x, y, p) + % Generates a 2D Gaussian peak. + % http://gpufit.readthedocs.io/en/latest/api.html#gauss-2d + % + % x,y - x and y grid position values + % p - parameters (amplitude, x,y center position, width, offset) + + g = p(1) * exp(-((x - p(2)).^2 + (y - p(3)).^2) / (2 * p(4)^2)) + p(5); + + end + +The model function can be repeated and noise can be added using the repmat and poissrnd functions. + +.. code-block:: matlab + + % generate data with Poisson noise + data = gaussian_2d(x, y, true_parameters); + data = repmat(data(:), [1, number_fits]); + data = poissrnd(data); + +The model and estimator IDs can be set as + +.. code-block:: matlab + + % estimator id + estimator_id = EstimatorID.MLE; + + % model ID + model_id = ModelID.GAUSS_2D; + +When all input parameters are set we can call the C interface of |GF|. + +.. code-block:: matlab + + %% run Gpufit + [parameters, states, chi_squares, n_iterations, time] = gpufit(data, [], model_id, initial_parameters, tolerance, max_n_iterations, [], estimator_id, []); + +And finally statistics about the results of the fits can be displayed where the mean and standard deviation of the +fitted parameters are limited to those fits that converged. + +.. code-block:: matlab + + %% displaying results + + % get fit states + converged = states == 0; + number_converged = sum(converged); + fprintf(' ratio converged %6.2f %%\n', number_converged / number_fits * 100); + fprintf(' ratio max it. exceeded %6.2f %%\n', sum(states == 1) / number_fits * 100); + fprintf(' ratio singular hessian %6.2f %%\n', sum(states == 2) / number_fits * 100); + fprintf(' ratio neg curvature MLE %6.2f %%\n', sum(states == 3) / number_fits * 100); + fprintf(' ratio gpu not read %6.2f %%\n', sum(states == 4) / number_fits * 100); + + % mean and std of fitted parameters + converged_parameters = parameters(:, converged); + converged_parameters_mean = mean(converged_parameters, 2); + converged_parameters_std = std(converged_parameters, [], 2); + for i = 1 : number_parameters + fprintf(' p%d true %6.2f mean %6.2f std %6.2f\n', i, true_parameters(i), converged_parameters_mean(i), converged_parameters_std(i)); + end + + % print summary + fprintf('model ID: %d\n', model_id); + fprintf('number of fits: %d\n', number_fits); + fprintf('fit size: %d x %d\n', size_x, size_x); + fprintf('mean chi-square: %6.2f\n', mean(chi_squares(converged))); + fprintf('iterations: %6.2f\n', mean(n_iterations(converged))); + fprintf('time: %6.2f s\n', time); diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..fe55fe3 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,457 @@ +# -*- coding: utf-8 -*- +import sphinx_rtd_theme +# +# RTD Spielwiese documentation build configuration file, created by +# sphinx-quickstart on Tue Oct 04 12:39:10 2016. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.4' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.mathjax', + 'sphinx.ext.todo' +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The encoding of source files. +# +# source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'Gpufit: An open-source toolkit for GPU-accelerated curve fitting' +copyright = 'All rights reserved.' +author = 'Adrian Przybylski, Björn Thiel, Jan Keller-Findeisen, Bernd Stock, and Mark Bates' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = u'1.0' +# The full version, including alpha/beta/rc tags. +release = u'1.0.0' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +# +# today = '' +# +# Else, today_fmt is used as the format for a strftime call. +# +# today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +# +# default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +# +# add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +# +# add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +# +# show_authors = False + +# read epilog.rst +with open('epilog.txt') as f: + rst_epilog = f.read() + +# default highlight language is cpp +highlight_language = 'cpp' + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +# modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +# keep_warnings = False + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = True + +numfig = True + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "sphinx_rtd_theme" +html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + +#html_theme_options = { +# 'collapse_navigation': False, +# 'display_version': False, +# 'navigation_depth': 3, +#} + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +# html_theme_path = [] + +# The name for this set of Sphinx documents. +# " v documentation" by default. +# +# html_title = u'RTD Spielwiese v1' + +# A shorter title for the navigation bar. Default is the same as html_title. +# +# html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +# +# html_logo = None + +# The name of an image file (relative to this directory) to use as a favicon of +# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +# +# html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +# +# html_extra_path = [] + +# If not None, a 'Last updated on:' timestamp is inserted at every page +# bottom, using the given strftime format. +# The empty string is equivalent to '%b %d, %Y'. +# +# html_last_updated_fmt = None + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +# +# html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +# +# html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +# +# html_additional_pages = {} + +# If false, no module index is generated. +# +# html_domain_indices = True + +# If false, no index is generated. +# +# html_use_index = True + +# If true, the index is split into individual pages for each letter. +# +# html_split_index = False + +# If true, links to the reST sources are added to the pages. +# +# html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +# +# html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +# +# html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +# +# html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +# html_file_suffix = None + +# Language to be used for generating the HTML full-text search index. +# Sphinx supports the following languages: +# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' +# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' +# +# html_search_language = 'en' + +# A dictionary with options for the search language support, empty by default. +# 'ja' uses this config value. +# 'zh' user can custom change `jieba` dictionary path. +# +# html_search_options = {'type': 'default'} + +# The name of a javascript file (relative to the configuration directory) that +# implements a search results scorer. If empty, the default will be used. +# +# html_search_scorer = 'scorer.js' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'Gpufit' + +# -- Options for LaTeX output --------------------------------------------- + + +# make code smaller in latex output +# see also: http://stackoverflow.com/questions/9899283/how-do-you-change-the-code-example-font-size-in-latex-pdf-output-with-sphinx +from sphinx.highlighting import PygmentsBridge +from pygments.formatters.latex import LatexFormatter + +class CustomLatexFormatter(LatexFormatter): + def __init__(self, **options): + super(CustomLatexFormatter, self).__init__(**options) + self.verboptions = r"formatcom=\footnotesize" + +PygmentsBridge.latex_formatter = CustomLatexFormatter + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + 'papersize': 'a4paper,oneside', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'Gpufit.tex', 'Gpufit Documentation', + 'Gpufit', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +# +# latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +# +# latex_use_parts = False + +# If true, show page references after internal links. +# +latex_show_pagerefs = True + +# If true, show URL addresses after external links. +# +# latex_show_urls = 'footnote' +latex_show_urls = 'no' + +# Documents to append as an appendix to all manuals. +# +# latex_appendices = [] + +# It false, will not define \strong, \code, itleref, \crossref ... but only +# \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added +# packages. +# +# latex_keep_old_macro_names = True + +# If false, no module index is generated. +# +# latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'gpufit', 'Gpufit Documentation', + [author], 1) +] + +# If true, show URL addresses after external links. +# +# man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'Gpufit', 'Gpufit Documentation', + author, 'Gpufit', 'Levenberg Marquardt curve fitting in CUDA', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +# +# texinfo_appendices = [] + +# If false, no module index is generated. +# +# texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +# +# texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +# +# texinfo_no_detailmenu = False + + +# -- Options for Epub output ---------------------------------------------- + +# Bibliographic Dublin Core info. +epub_title = project +epub_author = author +epub_publisher = author +epub_copyright = copyright + +# The basename for the epub file. It defaults to the project name. +# epub_basename = project + +# The HTML theme for the epub output. Since the default themes are not +# optimized for small screen space, using the same theme for HTML and epub +# output is usually not wise. This defaults to 'epub', a theme designed to save +# visual space. +# +# epub_theme = 'epub' + +# The language of the text. It defaults to the language option +# or 'en' if the language is not set. +# +# epub_language = '' + +# The scheme of the identifier. Typical schemes are ISBN or URL. +# epub_scheme = '' + +# The unique identifier of the text. This can be a ISBN number +# or the project homepage. +# +# epub_identifier = '' + +# A unique identification for the text. +# +# epub_uid = '' + +# A tuple containing the cover image and cover page html template filenames. +# +# epub_cover = () + +# A sequence of (type, uri, title) tuples for the guide element of content.opf. +# +# epub_guide = () + +# HTML files that should be inserted before the pages created by sphinx. +# The format is a list of tuples containing the path and title. +# +# epub_pre_files = [] + +# HTML files that should be inserted after the pages created by sphinx. +# The format is a list of tuples containing the path and title. +# +# epub_post_files = [] + +# A list of files that should not be packed into the epub file. +epub_exclude_files = ['search.html'] + +# The depth of the table of contents in toc.ncx. +# +# epub_tocdepth = 3 + +# Allow duplicate toc entries. +# +# epub_tocdup = True + +# Choose between 'default' and 'includehidden'. +# +# epub_tocscope = 'default' + +# Fix unsupported image types using the Pillow. +# +# epub_fix_images = False + +# Scale large images. +# +# epub_max_image_width = 0 + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +# +# epub_show_urls = 'inline' + +# If false, no index is generated. +# +# epub_use_index = True diff --git a/docs/customization.rst b/docs/customization.rst new file mode 100644 index 0000000..4fcfec5 --- /dev/null +++ b/docs/customization.rst @@ -0,0 +1,299 @@ +.. _gpufit-customization: + +============= +Customization +============= + +This sections explains how to add custom fit model functions and custom fit estimators within |GF|. +Functions calculating the estimator and model values are defined in CUDA header files using the CUDA C syntax. +For each function and estimator there exists a separate file. Therefore, to add an additional model or estimator a new +CUDA header file containing the new model or estimator function must be created and included in the library. + +Please note, that in order to add a model function or estimator, it is necessary to rebuild the Gpufit library +from source. In future releases of Gpufit, it may be possible to include new fit functions or estimators at runtime. + + +Add a new fit model function +---------------------------- + +To add a new fit model, the model function itself as well as analytic expressions for its partial derivatives +must to be known. A function calculating the values of the model as well as a function calculating the +values of the partial derivatives of the model, with respect to the model parameters and possible grid +coordinates, must be implemented. + +Additionally, a new model ID must be defined and included in the list of available model IDs, and the number +of model parameters must be specified as well. + +Detailed step by step instructions for adding a model function are given below. + +1. Define an additional model ID in file gpufit.h_ +2. Implement a CUDA device function within a newly created .cuh file according to the following template. + +.. code-block:: cuda + + __device__ void ... ( // function name + float const * parameters, + int const n_fits, + int const n_points, + int const n_parameters, + float * values, + float * derivatives, + int const chunk_index, + char * user_info, + std::size_t const user_info_size) + { + ///////////////////////////// indices ///////////////////////////// + int const n_fits_per_block = blockDim.x / n_points; + int const fit_in_block = threadIdx.x / n_points; + int const point_index = threadIdx.x - (fit_in_block*n_points); + int const fit_index = blockIdx.x*n_fits_per_block + fit_in_block; + + ///////////////////////////// values ////////////////////////////// + float* current_value = &values[fit_index*n_points]; + float const * current_parameters = ¶meters[fit_index*n_parameters]; + + current_value[point_index] = ... ; // formula calculating fit model values + + /////////////////////////// derivatives /////////////////////////// + float * current_derivative = &derivatives[fit_index * n_points*n_parameters]; + + current_derivative[0 * n_points + point_index] = ... ; // formula calculating derivative values with respect to parameters[0] + current_derivative[1 * n_points + point_index] = ... ; // formula calculating derivative values with respect to parameters[1] + . + . + . + } + +This code can be used as a pattern, where the placeholders ". . ." must be replaced by user code which calculates model +function values and partial derivative values of the model function for a particular set of parameters. See for example linear_1d.cuh_. + +3. Include the newly created .cuh file in cuda_kernels.cu_ +4. Add an if branch in the CUDA global function ``cuda_calc_curve()`` in file cuda_kernels.cu_ to allow calling the added model function + +.. code-block:: cpp + + if (model_id == GAUSS_1D) + calculate_gauss1d + (parameters, n_fits, n_points, n_parameters, values, derivatives, chunk_index, user_info, user_info_size); + . + . + . + else if (model_id == ...) // model ID + ... // function name + (parameters, n_fits, n_points, n_parameters, values, derivatives, chunk_index, user_info, user_info_size); + +Compare model_id with the defined model of the new model and call the calculate model values function of your model. + +5. Add a switch case in function set_number_of_parameters in file interface.cpp_ + +.. code-block:: cpp + + switch (model_id) + { + case GAUSS_1D: + n_parameters_ = 4; + break; + . + . + . + case ... : // model ID + n_parameters_ = ... ; // number of model parameters + break; + default: + break; + } + +Add a new fit estimator +------------------------ + +To extend |GF| by additional estimators, three CUDA device functions must be defined and integrated. The sections requiring modification are +the functions which calculate the estimator function values, and its gradient and hessian values. Also, a new estimator ID must be defined. +Detailed step by step instructions for adding an additional estimator is given below. + +1. Define an additional estimator ID in gpufit.h_ +2. Implement three functions within a newly created .cuh file calculating :math:`\chi^2` values and + its gradient and hessian according to the following template. + +.. code-block:: cuda + + ///////////////////////////// Chi-square ///////////////////////////// + __device__ void ... ( // function name Chi-square + volatile float * chi_square, + int const point_index, + float const * data, + float const * value, + float const * weight, + int * state, + char * user_info, + std::size_t const user_info_size) + { + chi_square[point_index] = ... ; // formula calculating Chi-square summands + } + + ////////////////////////////// gradient ////////////////////////////// + __device__ void ... ( // function name gradient + volatile float * gradient, + int const point_index, + int const parameter_index, + float const * data, + float const * value, + float const * derivative, + float const * weight, + char * user_info, + std::size_t const user_info_size) + { + gradient[point_index] = ... ; // formula calculating summands of the gradient of Chi-square + } + + ////////////////////////////// hessian /////////////////////////////// + __device__ void ... ( // function name hessian + double * hessian, + int const point_index, + int const parameter_index_i, + int const parameter_index_j, + float const * data, + float const * value, + float const * derivative, + float const * weight, + char * user_info, + std::size_t const user_info_size) + { + *hessian += ... ; // formula calculating summands of the hessian of Chi-square + } + +This code can be used as a pattern, where the placeholders ". . ." must be replaced by user code which calculates the estimator +and the hessian values of the estimator given. For a concrete example, see lse.cuh_. + +3. Include the newly created .cuh file in cuda_kernels.cu_ + +.. code-block:: cpp + + #include "....cuh" // filename + +4. Add an if branch in 3 CUDA global functions in the file cuda_kernels.cu_ + + .. code-block:: cuda + + __global__ void cuda_calculate_chi_squares( + . + . + . + if (estimator_id == LSE) + { + calculate_chi_square_lse( + shared_chi_square, + point_index, + current_data, + current_value, + current_weight, + current_state, + user_info, + user_info_size); + } + . + . + . + else if (estimator_id == ...) // estimator ID + { + ...( // function name Chi-square + shared_chi_square, + point_index, + current_data, + current_value, + current_weight, + current_state, + user_info, + user_info_size); + } + . + . + . + + + .. code-block:: cuda + + __global__ void cuda_calculate_gradients( + . + . + . + if (estimator_id == LSE) + { + calculate_gradient_lse( + shared_gradient, + point_index, + derivative_index, + current_data, + current_value, + current_derivative, + current_weight, + user_info, + user_info_size); + } + . + . + . + else if (estimator_id == ...) // estimator ID + { + ...( // function name gradient + shared_gradient, + point_index, + derivative_index, + current_data, + current_value, + current_derivative, + current_weight, + user_info, + user_info_size); + } + . + . + . + + .. code-block:: cuda + + __global__ void cuda_calculate_hessians( + . + . + . + if (estimator_id == LSE) + { + calculate_hessian_lse( + &sum, + point_index, + derivative_index_i + point_index, + derivative_index_j + point_index, + current_data, + current_value, + current_derivative, + current_weight, + user_info, + user_info_size); + } + . + . + . + else if (estimator_id == ...) // estimator ID + { + ...( // function name hessian + &sum, + point_index, + derivative_index_i + point_index, + derivative_index_j + point_index, + current_data, + current_value, + current_derivative, + current_weight, + user_info, + user_info_size); + } + . + . + . + +Future releases +--------------- + +A disadvantage of the Gpufit library, when compared with established CPU-based curve fitting packages, +is that in order to add or modify a fit model function or a fit estimator, the library must be recompiled. +We anticipate that this limitation can be overcome in future releases of the library, by employing +run-time compilation of the CUDA code. diff --git a/docs/epilog.txt b/docs/epilog.txt new file mode 100644 index 0000000..ee243c1 --- /dev/null +++ b/docs/epilog.txt @@ -0,0 +1,48 @@ + +.. + The content of this file will be appended to every documentation file. Put common substitutions and links here. + +.. |GF| replace:: the Gpufit library +.. |GF_version| replace:: 1.0.0 + +.. _CUDA: http://developer.nvidia.com/cuda-zone +.. _CUDA_SELECT_NVCC_ARCH_FLAGS: http://cmake.org/cmake/help/v3.7/module/FindCUDA.html + +.. _CMake: http://www.cmake.org +.. _Boost: http://www.boost.org +.. _MATLAB: http://www.mathworks.com/products/matlab.html +.. _Python: http://www.python.org + +.. _`Gpufit on Github`: https://github.com/gpufit/Gpufit +.. _`Gpufit release location`: https://github.com/gpufit/Gpufit/releases +.. _Gpufit-master.zip: https://github.com/gpufit/Gpufit/archive/master.zip + +.. _gpufit.h: https://github.com/gpufit/Gpufit/blob/master/Gpufit/gpufit.h +.. _interface.cpp: https://github.com/gpufit/Gpufit/blob/master/Gpufit/interface.cpp + +.. _gauss_1d.cuh: https://github.com/gpufit/Gpufit/blob/master/Gpufit/gauss_1d.cuh +.. _gauss_2d.cuh: https://github.com/gpufit/Gpufit/blob/master/Gpufit/gauss_2d.cuh +.. _gauss_2d_elliptic.cuh: https://github.com/gpufit/Gpufit/blob/master/Gpufit/gauss_2d_elliptic.cuh +.. _gauss_2d_rotated.cuh: https://github.com/gpufit/Gpufit/blob/master/Gpufit/gauss_2d_rotated.cuh +.. _cauchy_2d_elliptic.cuh: https://github.com/gpufit/Gpufit/blob/master/Gpufit/cauchy2delliptic.cuh +.. _linear_1d.cuh: https://github.com/gpufit/Gpufit/blob/master/Gpufit/linear_1d.cuh +.. _lse.cuh: https://github.com/gpufit/Gpufit/blob/master/Gpufit/lse.cuh +.. _mle.cuh: https://github.com/gpufit/Gpufit/blob/master/Gpufit/mle.cuh +.. _cuda_kernels.cu: https://github.com/gpufit/Gpufit/blob/master/Gpufit/cuda_kernels.cu + +.. _Tests: https://github.com/gpufit/Gpufit/tree/master/Gpufit/tests +.. _Examples: https://github.com/gpufit/Gpufit/tree/master/Gpufit/examples +.. _Simple_Example.cpp: https://github.com/gpufit/Gpufit/blob/master/Gpufit/examples/Simple_Example.cpp +.. _Gauss_Fit_2D_Example.cpp: https://github.com/gpufit/Gpufit/blob/master/Gpufit/examples/Gauss_Fit_2D_Example.cpp +.. _Linear_Regression_Example.cpp: https://github.com/gpufit/Gpufit/blob/master/Gpufit/examples/Linear_Regression_Example.cpp + +.. _GpufitMex.cpp: https://github.com/gpufit/Gpufit/blob/master/Gpufit/bindings/matlab/GpufitMex.cpp +.. _gpufit.m: https://github.com/gpufit/Gpufit/blob/master/Gpufit/bindings/matlab/gpufit.m + +.. _`Matlab simple example`: https://github.com/gpufit/Gpufit/blob/master/Gpufit/bindings/matlab/examples/simple.m +.. _`Matlab Gauss2D example`: https://github.com/gpufit/Gpufit/blob/master/Gpufit/bindings/matlab/examples/gauss2d.m +.. _`Matlab Gauss2D plot example`: https://github.com/gpufit/Gpufit/blob/master/Gpufit/bindings/matlab/examples/gauss2d_plot.m + +.. _`Python simple example`: https://github.com/gpufit/Gpufit/blob/master/Gpufit/bindings/python/examples/simple.py +.. _`Python Gauss2D example`: https://github.com/gpufit/Gpufit/blob/master/Gpufit/bindings/python/examples/gauss2d.py +.. _`Python Gauss2D plot example`: https://github.com/gpufit/Gpufit/blob/master/Gpufit/bindings/python/examples/gauss2d_plot.py \ No newline at end of file diff --git a/docs/examples.rst b/docs/examples.rst new file mode 100644 index 0000000..da54114 --- /dev/null +++ b/docs/examples.rst @@ -0,0 +1,394 @@ +======== +Examples +======== + +C++ Examples_ are part of the library code base and can be built and run through the project environment. Here they are +described and important steps are highlighted. + +Please note, that additionally, the C++ Tests_ contained in the code base also demonstrate the usage of |GF|. However, a +detailed description of the tests is not provided. + +.. _c-example-simple: + +Simple skeleton example +----------------------- + +This example shows the minimal code providing all required parameters and the call to the C interface. It is contained +in Simple_Example.cpp_ and can be built and executed within the project environment. Please note, that it this code does +not do anything other than call gpufit(). + +In the first section of the code, the model ID is set, space for initial parameters and data values is reserved (in a normal +application, however, the data array would already exist), the fit tolerance is set, the maximal number of iterations is set, +the estimator ID is set, and the parameters to fit array is initialized to indicate that all parameters should be fit. + +.. code-block:: cpp + + // number of fits, number of points per fit + size_t const number_fits = 10; + size_t const number_points = 10; + + // model ID and number of parameter + int const model_id = GAUSS_1D; + size_t const number_parameters = 5; + + // initial parameters + std::vector< float > initial_parameters(number_fits * number_parameters); + + // data + std::vector< float > data(number_points * number_fits); + + // tolerance + float const tolerance = 0.001f; + + // maximal number of iterations + int const max_number_iterations = 10; + + // estimator ID + int const estimator_id = LSE; + + // parameters to fit (all of them) + std::vector< int > parameters_to_fit(number_parameters, 1); + +In a next step, sufficient memory is reserved for all four output parameters. + +.. code-block:: cpp + + // output parameters + std::vector< float > output_parameters(number_fits * number_parameters); + std::vector< int > output_states(number_fits); + std::vector< float > output_chi_square(number_fits); + std::vector< int > output_number_iterations(number_fits); + +Finally, there is a call to the C interface of Gpufit (in this example, the optional +inputs *weights* and *user info* are not used) and a check of the return status. +If an error occurred, the last error message is obtained and an exception is thrown. + +.. code-block:: cpp + + // call to gpufit (C interface) + int const status = gpufit + ( + number_fits, + number_points, + data.data(), + 0, + model_id, + initial_parameters.data(), + tolerance, + max_number_iterations, + parameters_to_fit.data(), + estimator_id, + 0, + 0, + output_parameters.data(), + output_states.data(), + output_chi_square.data(), + output_number_iterations.data() + ); + + // check status + if (status != STATUS_OK) + { + throw std::runtime_error(gpufit_get_last_error()); + } + +This simple example can easily be adapted to real applications by: + +- choosing your own model ID +- choosing your own estimator ID +- choosing your own fit tolerance and maximal number of iterations +- filling the data structure with the data values to be fitted +- filling the initial parameters structure with suitable estimates of the true parameters +- processing the output data + +The following two examples show |GF| can be used to fit real data. + +.. _c-example-2d-gaussian: + +Fit 2D Gaussian functions example +--------------------------------- + +This example features: + +- Multiple fits using a 2D Gaussian function +- Noisy data and random initial guesses for the fit parameters +- A Poisson noise adapted maximum likelihood estimator + +It is contained in Gauss_Fit_2D_Example.cpp_ and can be built and executed within the project environment. The optional +inputs to gpufit(), *weights* and *user info*, are not used. + +In this example, a 2D Gaussian curve is fit to 10\ :sup:`4` noisy data sets having a size of 20 x 20 points each. +The model function and the model parameters are described in :ref:`gauss-2d`. + +In this example the true parameters used to generate the Gaussian data are set to + +.. code-block:: cpp + + // true parameters + std::vector< float > true_parameters{ 10.f, 9.5f, 9.5f, 3.f, 10.f}; // amplitude, center x/y positions, width, offset + +which defines a 2D Gaussian peak centered at the middle of the grid (position 9.5, 9.5), with a width (standard deviation) of 3.0, an amplitude of 10 +and a background of 10. + +The guesses for the initial parameters are drawn from the true parameters with a uniformly distributed deviation +of about 20%. The initial guesses for the center coordinates are chosen with a deviation relative to the width of the Gaussian. + +.. code-block:: cpp + + // initial parameters (randomized) + std::vector< float > initial_parameters(number_fits * number_parameters); + for (size_t i = 0; i < number_fits; i++) + { + for (size_t j = 0; j < number_parameters; j++) + { + if (j == 1 || j == 2) + { + initial_parameters[i * number_parameters + j] = true_parameters[j] + true_parameters[3] * (-0.2f + 0.4f * uniform_dist(rng)); + } + else + { + initial_parameters[i * number_parameters + j] = true_parameters[j] * (0.8f + 0.4f*uniform_dist(rng)); + } + } + } + +The 2D grid of x and y values (each ranging from 0 to 19 with an increment of 1) is computed with a double for loop. + +.. code-block:: cpp + + // generate x and y values + std::vector< float > x(number_points); + std::vector< float > y(number_points); + for (size_t i = 0; i < size_x; i++) + { + for (size_t j = 0; j < size_x; j++) { + x[i * size_x + j] = static_cast(j); + y[i * size_x + j] = static_cast(i); + } + } + +Then a 2D Gaussian peak model function (without noise) is calculated once for the true parameters + +.. code-block:: cpp + + void generate_gauss_2d(std::vector &x, std::vector &y, std::vector &g, std::vector::iterator &p) + { + // generates a Gaussian 2D peak function on a set of x and y values with some paramters p (size 5) + // we assume that x.size == y.size == g.size, no checks done + + // given x and y values and parameters p computes a model function g + for (size_t i = 0; i < x.size(); i++) + { + float arg = -((x[i] - p[1]) * (x[i] - p[1]) + (y[i] - p[2]) * (y[i] - p[2])) / (2 * p[3] * p[3]); + g[i] = p[0] * exp(arg) + p[4]; + } + } + +Stored in variable temp, it is then used in every fit to generate Poisson distributed random numbers. + +.. code-block:: cpp + + // generate data with noise + std::vector< float > temp(number_points); + // compute the model function + generate_gauss_2d(x, y, temp, true_parameters.begin()); + + std::vector< float > data(number_fits * number_points); + for (size_t i = 0; i < number_fits; i++) + { + // generate Poisson random numbers + for (size_t j = 0; j < number_points; j++) + { + std::poisson_distribution< int > poisson_dist(temp[j]); + data[i * number_points + j] = static_cast(poisson_dist(rng)); + } + } + +Thus, in this example the difference between data for each fit only in the random noise. This, and the +randomized initial guesses for each fit, result in each fit returning slightly different best-fit parameters. + +We set the model and estimator IDs for the fit accordingly. + +.. code-block:: cpp + + // estimator ID + int const estimator_id = MLE; + + // model ID + int const model_id = GAUSS_2D; + +And call the gpufit :ref:`c-interface`. Parameters weights, user_info and user_info_size are set to 0, indicating that they +won't be used during the fits. + +.. code-block:: cpp + + // call to gpufit (C interface) + int const status = gpufit + ( + number_fits, + number_points, + data.data(), + 0, + model_id, + initial_parameters.data(), + tolerance, + max_number_iterations, + parameters_to_fit.data(), + estimator_id, + 0, + 0, + output_parameters.data(), + output_states.data(), + output_chi_square.data(), + output_number_iterations.data() + ); + + // check status + if (status != STATUS_OK) + { + throw std::runtime_error(gpufit_get_last_error()); + } + +After the fits have been executed and the return value is checked to ensure that no error occurred, some statistics +about the fits are displayed. + +Output statistics ++++++++++++++++++ + +A histogram of all possible fit states (see :ref:`api-output-parameters`) is obtained by iterating over the state of each fit. + +.. code-block:: cpp + + // get fit states + std::vector< int > output_states_histogram(5, 0); + for (std::vector< int >::iterator it = output_states.begin(); it != output_states.end(); ++it) + { + output_states_histogram[*it]++; + } + +In the computation of the mean and standard deviation only converged fits are taken into account. Here is an example of computing +the means of the output parameters iterating over all fits and all parameters. + +.. code-block:: cpp + + // compute mean of fitted parameters for converged fits + std::vector< float > output_parameters_mean(number_parameters, 0); + for (size_t i = 0; i != number_fits; i++) + { + if (output_states[i] == STATE_CONVERGED) + { + for (size_t j = 0; j < number_parameters; j++) + { + output_parameters_mean[j] += output_parameters[i * number_parameters + j]; + } + } + } + // normalize + for (size_t j = 0; j < number_parameters; j++) + { + output_parameters_mean[j] /= output_states_histogram[0]; + } + +.. _linear-regression-example: + +Linear Regression Example +------------------------- + +This example features: + +- Multiple fits of a 1D Linear curve +- Noisy data and random initial guesses for the parameters +- Unequal spaced x position values given as custom user info + +It is contained in Linear_Regression_Example.cpp_ and can be built and executed within the project environment. + +In this example, a straight line is fitted to 10\ :sup:`4` noisy data sets. Each data set includes 20 data points. +Locations of data points are scaled non-linear (exponentially). The user information given implicates the x positions of the data +sets. The fits are unweighted and the model function and the model parameters are described in :ref:`linear-1d`. + +The custom x positions of the linear model are stored in the user_info. + +.. code-block:: cpp + + // custom x positions for the data points of every fit, stored in user info + std::vector< float > user_info(number_points); + for (size_t i = 0; i < number_points; i++) + { + user_info[i] = static_cast(pow(2, i)); + } + + // size of user info in bytes + size_t const user_info_size = number_points * sizeof(float); + +Because only number_points values are specified, this means that the same custom x position values are used for every fit. + +The initial parameters for every fit are set to random values uniformly distributed around the true parameter value. + +.. code-block:: cpp + + // true parameters + std::vector< float > true_parameters { 5, 2 }; // offset, slope + + // initial parameters (randomized) + std::vector< float > initial_parameters(number_fits * number_parameters); + for (size_t i = 0; i != number_fits; i++) + { + // random offset + initial_parameters[i * number_parameters + 0] = true_parameters[0] * (0.8f + 0.4f * uniform_dist(rng)); + // random slope + initial_parameters[i * number_parameters + 1] = true_parameters[0] * (0.8f + 0.4f * uniform_dist(rng)); + } + +The data is generated as the value of a linear function and some additive normally distributed noise term. + +.. code-block:: cpp + + // generate data + std::vector< float > data(number_points * number_fits); + for (size_t i = 0; i != data.size(); i++) + { + size_t j = i / number_points; // the fit + size_t k = i % number_points; // the position within a fit + + float x = user_info[k]; + float y = true_parameters[0] + x * true_parameters[1]; + data[i] = y + normal_dist(rng); + } + +We set the model and estimator IDs for the fit accordingly. + +.. code-block:: cpp + + // estimator ID + int const estimator_id = LSE; + + // model ID + int const model_id = LINEAR_1D; + +And call the gpufit :ref:`c-interface`. Parameter weights is set to 0, indicating that they won't be used during the fits. + +.. code-block:: cpp + + // call to gpufit (C interface) + int const status = gpufit + ( + number_fits, + number_points, + data.data(), + 0, + model_id, + initial_parameters.data(), + tolerance, + max_number_iterations, + parameters_to_fit.data(), + estimator_id, + user_info_size, + reinterpret_cast< char * >( user_info.data() ), + output_parameters.data(), + output_states.data(), + output_chi_square.data(), + output_number_iterations.data() + ); + +After the fits have been executed and the return value is checked to ensure that no error occurred, some statistics +about the fits are displayed (see `Output statistics`_). diff --git a/docs/fit_estimator_functions.rst b/docs/fit_estimator_functions.rst new file mode 100644 index 0000000..fcee030 --- /dev/null +++ b/docs/fit_estimator_functions.rst @@ -0,0 +1,54 @@ +.. _estimator-functions: + +Estimator functions +------------------- + +.. _estimator-lse: + +Least squares estimator ++++++++++++++++++++++++ + +The least squares estimator computes the weighted sum of the squared deviation between the data values and the model at +the positions of the data points. The ID for this estimator is ``LSE``. It's implemented in lse.cuh_. + +Least squares estimation is a common method, and the standard Levenberg-Marquardt algorithm described by Marquardt makes +use of minimal least squares. The estimator is described as follows. + +.. math:: + + {\chi^2}(\vec{p}) = \sum_{n=0}^{N-1}{ \left(f_{n}(\vec{p})-z_{n}\right)^2\cdot w_n } + +:`n`: The index of the data points (:math:`0,..,N-1`) + +:`f_n`: The model function values at data position :math:`n` + +:`z_n`: Data values at data position :math:`n` + +:`\vec{p}`: Fit model function parameters + +:`w_n`: Weight values for data at position :math:`n` + + +.. _estimator-mle: + +Maximum likelihood estimator for data subject to Poisson statistics ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +The maximum likelihood estimator (MLE) for Poisson distributed noise is relatively simple to implement. In the case of data with Poisson noise +is provides a more precise estimate when compared to an LSE estimator. The ID for this estimator is ``MLE``. It's implemented in mle.cuh_. + +The estimator is described as follows. + +.. math:: + + {\chi^2}(\vec{p}) = 2\sum_{n=0}^{N-1}{(f_{n}(\vec{p})-z_{n})}-2\sum_{n=0,z_n\neq0}^{N-1}{z_n ln \left(\frac{f_{n}(\vec{p})}{z_n}\right)} + +:`n`: The index of the data points (:math:`0,..,N-1`) + +:`f_n`: The model function values at data position :math:`n` + +:`z_n`: Data values at data position :math:`n` + +:`\vec{p}`: Actual model function parameters + +Note that this estimator does not provide any means to weight the data values. Rather, noise in the data is assumed to be purely Poissonian. \ No newline at end of file diff --git a/docs/fit_model_functions.rst b/docs/fit_model_functions.rst new file mode 100644 index 0000000..620c821 --- /dev/null +++ b/docs/fit_model_functions.rst @@ -0,0 +1,193 @@ +.. _fit-model-functions: + +Fit Model functions +------------------- + +This section describes the fit model functions which are included with the Gpufit library. The headings are the names +of the ModelID parameter used in the gpufit()_ call. They are defined in gpufit.h_. + +Note that additional model functions may be added as described in the documentation, see :ref:`gpufit-customization`. + +.. _linear-1d: + +Linear regression ++++++++++++++++++ + +A 1D linear function defined by two parameters (offset and slope). The user information data may be used to specify the +X coordinate of each data point. The model ID of this function is ``LINEAR_1D``, and it is implemented in linear_1d.cuh_. + +.. math:: + + g(x,\vec{p})=p_0+p_1 x + +:`x`: (independent variable) *X* coordinate + + The X coordinate values may be specified in the user information data. + For details on how to do this, see the linear regression code example, :ref:`linear-regression-example`. + + If no independent variables are provided, the *X* coordinate of the first data value is assumed to be (0.0). + In this case, for a fit size of *M* data points, the *X* coordinates of the data are simply the corresponding array + indices of the data array, starting from zero (i.e. :math:`0, 1, 2, ...`). + +:`p_0`: offset + +:`p_1`: slope + + +.. _gauss-1d: + +1D Gaussian function +++++++++++++++++++++ + +A 1D Gaussian function defined by four parameters. Its model ID is ``GAUSS_1D`` and it is implemented in gauss_1d.cuh_. +Here, p is the vector of parameters (p0..p3) and the model function g exists for each x coordinate of the input data. + +.. math:: + + g(x,\vec{p})=p_0 e^{-\left(x-p_1\right)^2/\left(2p_2^2\right)}+p_3 + +:`x`: (independent variable) *X* coordinate + + No independent variables are passed to this model function. + Hence, the *X* coordinate of the first data value is assumed to be (0.0). For a fit size of *M* data points, + the *X* coordinates of the data are simply the corresponding array indices of the data array, starting from + zero (i.e. :math:`0, 1, 2, ...`). + +:`p_0`: amplitude + +:`p_1`: center coordinate + +:`p_2`: width (standard deviation) + +:`p_3`: offset + + +.. _gauss-2d: + +2D Gaussian function (cylindrical symmetry) ++++++++++++++++++++++++++++++++++++++++++++ + +A 2D Gaussian function defined by five parameters. Its model ID is ``GAUSS_2D`` and it is implemented in gauss_2d.cuh_. +Here, p is the vector of parameters (p0..p4) and the model function g exists for each x,y coordinate of the input data. + +.. math:: + + g(x,y,p)=p_0 e^{-\left(\left(x-p_1\right)^2+\left(y-p_2\right)^2\right)/\left(2p_3^2\right)}+p_4 + +:`x,y`: (independent variables) *X,Y* coordinates + + No independent variables are passed to this model function. + Hence, the *(X,Y)* coordinates of the first data value are assumed to be (:math:`0.0, 0.0`). + For a fit size of *M x N* data points, the *(X,Y)* coordinates of the data are simply the corresponding 2D array + indices of the data array, starting from zero. + +:`p_0`: amplitude + +:`p_1`: center coordinate x + +:`p_2`: center coordinate y + +:`p_3`: width (standard deviation; equal width in x and y dimensions) + +:`p_4`: offset + + +.. _gauss-2d-elliptic: + +2D Gaussian function (elliptical) ++++++++++++++++++++++++++++++++++ + +A 2D elliptical Gaussian function defined by six parameters. Its model ID is ``GAUSS_2D_ELLIPTIC`` and it is implemented +in gauss_2d_elliptic.cuh_. Here, p is the vector of parameters (p0..p5) and the model function g exists for each x,y coordinate of the input data. + +.. math:: + + g(x,y,\vec{p})=p_0 e^{-\frac{1}{2}\left(\frac{\left(x-p_1\right)^2}{p_3^2}+\frac{\left(y-p_2\right)^2}{p_4^2}\right)}+p_5 + +:`x,y`: (independent variables) *X,Y* coordinates + + No independent variables are passed to this model function. + Hence, the *(X,Y)* coordinates of the first data value are assumed to be (:math:`0.0, 0.0`). + For a fit size of *M x N* data points, the *(X,Y)* coordinates of the data are simply the corresponding + 2D array indices of the data array, starting from zero. + +:`p_0`: amplitude + +:`p_1`: center coordinate x + +:`p_2`: center coordinate y + +:`p_3`: width x (standard deviation) + +:`p_4`: width y (standard deviation) + +:`p_5`: offset + + +.. _gauss-2d-rotated: + +2D Gaussian function (elliptical, rotated) +++++++++++++++++++++++++++++++++++++++++++ + +A 2D elliptical Gaussian function whose principal axis may be rotated with respect to the X and Y coordinate axes, +defined by seven parameters. Its model is ``GAUSS_2D_ROTATED`` and it is implemented in gauss_2d_rotated.cuh_. +Here, p is the vector of parameters (p0..p6) and the model function g exists for each x,y coordinate of the input data. + +.. math:: + + g(x,y,\vec{p})=p_0 e^{-\frac{1}{2}\left(\frac{\left((x-p_1)\cos{p_6}-(y-p_2)\sin{p_6}\right)^2}{p_3^2}+\frac{\left((x-p_1)\sin{p_6}+(y-p_2)\cos{p_6}\right)^2}{p_4^2}\right)}+p_5 + +:`x,y`: (independent variables) *X,Y* coordinates + + No independent variables are passed to this model function. + Hence, the *(X,Y)* coordinates of the first data value are assumed to be (:math:`0.0, 0.0`). + For a fit size of *M x N* data points, the *(X,Y)* coordinates of the data are simply the corresponding + 2D array indices of the data array, starting from zero. + +:`p_0`: amplitude + +:`p_1`: center coordinate x + +:`p_2`: center coordinate y + +:`p_3`: width x (standard deviation) + +:`p_4`: width y (standard deviation) + +:`p_5`: offset + +:`p_6`: rotation angle [radians] + + +.. _cauchy-2d-elliptic: + +2D Cauchy function (elliptical) ++++++++++++++++++++++++++++++++ + +A 2D elliptical Cauchy function defined by six parameters. Its model ID is ``CAUCHY_2D_ELLIPTIC`` and it is implemented +in cauchy_2d_elliptic.cuh_. Here, p is the vector of parameters (p0..p5) and the model function g exists for each x,y +coordinate of the input data. + +.. math:: + + g(x,y,\vec{p})=p_0 \frac{1}{\left(\frac{x-p_1}{p_3}\right)^2+1} \frac{1}{\left(\frac{y-p_2}{p_4}\right)^2+1} + p_5 + +:`x,y`: (independent variables) *X,Y* coordinates + + No independent variables are passed to this model function. + Hence, the *(X,Y)* coordinates of the first data value are assumed to be (:math:`0.0, 0.0`). + For a fit size of *M x N* data points, the *(X,Y)* coordinates of the data are simply the corresponding + 2D array indices of the data array, starting from zero. + +:`p_0`: amplitude + +:`p_1`: center coordinate x + +:`p_2`: center coordinate y + +:`p_3`: width x (standard deviation) + +:`p_4`: width y (standard deviation) + +:`p_5`: offset + diff --git a/docs/gpufit_api.rst b/docs/gpufit_api.rst new file mode 100644 index 0000000..ce6695d --- /dev/null +++ b/docs/gpufit_api.rst @@ -0,0 +1,377 @@ +.. _api-description: + +====================== +Gpufit API description +====================== + +The Gpufit source code compiles to a dynamic-link library (DLL), providing a C interface. +In the sections below, the C interface and its arguments are described in detail. + +.. _c-interface: + +C Interface +----------- + +The C interface is defined in the Gpufit header file: gpufit.h_. + +gpufit() +++++++++ + +This is the main fit function. A single call to the *gpufit()* function executes a block of *N* fits. +The inputs to *gpufit()* are scalars and pointers to arrays, and the outputs are also array pointers. + +The inputs to the *gpufit()* function are: + +- the number of fits (*N*), +- the number of data points per fit (each fit has equal size), +- the fit data, +- an array of weight values that are used to weight the individual data points in the fit (optional), +- an ID number which specifies the fit model function, +- an array of initial parameters for the model functions, +- a tolerance value which determines when the fit has converged, +- the maximum number of iterations per fit, +- an array of flags which allow one or more fit parameters to be held constant, +- an ID number which specifies the fit estimator (e.g. least squares, etc.), +- the size of the user info data, +- the user info data, which may have multiple uses, for example to pass additional parameters to the fit functions, + or to include independent variables (e.g. X values) with the fit data. + +The outputs of *gpufit()* are: + +- the best fit model parameters for each fit, +- an array of flags indicating, for example, whether each fit converged, +- the final value of :math:`\chi^2` for each fit, +- the number of iterations needed for each fit to converge. + +The *gpufit()* function call is defined below. + +.. code-block:: cpp + + int gpufit + ( + size_t n_fits, + size_t n_points, + float * data, + float * weights, + int model_id, + float * initial_parameters, + float tolerance, + int max_n_iterations, + int * parameters_to_fit, + int estimator_id, + size_t user_info_size, + char * user_info, + float * output_parameters, + int * output_states, + float * output_chi_squares, + int * output_n_iterations + ) ; + +.. _api-input-parameters: + +Description of input parameters +............................... + +:n_fits: Number of fits to be performed + + :type: size_t + +:n_points: Number of data points per fit + + Gpufit is designed such that each fit must have the same number of data points per fit. + + :type: size_t + +:data: Pointer to data values + + A pointer to the data values. The data must be passed in as a 1D array of floating point values, with the data + for each fit concatenated one after another. In the case of multi-dimensional data, the data must be flattened + to a 1D array. The number of elements in the array is equal to the product n_fits * n_points. + + :type: float * + :length: n_points * n_fits + +:weights: Pointer to weights + + The weights array includes unique weighting values for each fit. It is used only by the least squares estimator (LSE). + The size of the weights array and its organization is identical to that for the data array. + For statistical weighting, this parameter should be set equal to the inverse of the variance of the data + (i.e. weights = 1.0 / variance ). The weights array is an optional input. + + :type: float * + :length: n_points * n_fits + :special: Use a NULL pointer to indicate that no weights are provided. In this case all data values will be weighted equally. + +:model_id: Model ID + + Determines the model which is used for all fits in this call. See :ref:`fit-model-functions` for more details. + + As defined in gpufit.h_: + + :0: GAUSS_1D + :1: GAUSS_2D + :2: GAUSS_2D_ELLIPTIC + :3: GAUSS_2D_ROTATED + :4: CAUCHY_2D_ELLIPTIC + :5: LINEAR_1D + + :type: int + +:initial_parameters: Pointer to initial parameter values + + A 1D array containing the initial model parameter values for each fit. If the number of parameters of the fit model + is defined by *n_parameters*, then the size of this array is *n_fits * n_parameters*. + + The parameter values for each fit are concatenated one after another. If there are *M* parameters per fit, + the parameters array is organized as follows: [(parameter 1), (parameter 2), ..., (parameter M), (parameter 1), + (parameter 2), ..., (parameter M), ...]. + + :type: float * + :length: n_fits * n_parameters + +:tolerance: Fit tolerance threshold + + The fit tolerance determines when the fit has converged. After each fit iteration, the change in the absolute value + of :math:`\chi^2` is calculated. The fit has converged when one of two conditions are met. First, if the change + in the absolute value of :math:`\chi^2` is less than the tolerance value, the fit has converged. + Alternatively, if the change in :math:`\chi^2` is less than the product of tolerance and the absolute value of + :math:`\chi^2` [tolerance * abs(:math:`\chi^2`)], then the fit has converged. + + Setting a lower value for the tolerance results in more precise values for the fit parameters, but requires more fit + iterations to reach convergence. + + A typical value for the tolerance settings is between 1.0E-3 and 1.0E-6. + + :type: float + +:max_n_iterations: Maximum number of iterations + + The maximum number of fit iterations permitted. If the fit has not converged after this number of iterations, + the fit returns with a status value indicating that the maximum number of iterations was reached. + + :type: int + +:parameters_to_fit: Pointer to array indicating which model parameters should be held constant during the fit + + This is an array of ones or zeros, with a length equal to the number of parameters of the fit model function. + Each entry in the array is a flag which determines whether or not the corresponding model parameter will be held + constant during the fit. To allow a parameter to vary during the fit, set the entry in *parameters_to_fit* equal + to one. To hold the value constant, set the entry to zero. + + An array of ones, e.g. [1,1,1,1,1,...] will allow all parameters to vary during the fit. + + :type: int * + :length: n_parameters + +:estimator_id: Estimator ID + + Determines the fit estimator which is used. See :ref:`estimator-functions` for more details. + + As defined in gpufit.h_: + + :0: LSE + :1: MLE + + :type: int + +:user_info_size: Size of user information data + + Size of the user information data array, in bytes. + + :type: size_t + +:user_info: Pointer to user information data + + This parameter is intended to provide flexibility to the Gpufit interface. The user information data is a generic + block of memory which is passed in to the *gpufit()* function, and which is accessible in shared GPU memory by the + fit model functions. Possible uses for the user information data is to pass in value for independent variables + (e.g. X values) or to supply additional data to the fit model function. For a coded example which makes use of + the user information data, see :ref:`linear-regression-example`. The user information data is an optional parameter + - if no user information is required this parameter may be set to NULL. + + :type: char * + :length: user_info_size + :special: Use a NULL pointer to indicate that no user information is available. + +.. _api-output-parameters: + +Description of output parameters +................................ + +:output_parameters: Pointer to array of best-fit model parameters + + For each fit, this array contains the best-fit model parameters. The array is organized identically to the input + parameters array. + + :type: float * + :length: n_fits * n_parameters + +:output_states: Pointer to array of fit result state IDs + + For each fit the result of the fit is indicated by a state ID. The state ID codes are defined below. + A state ID of 0 indicates that the fit converged successfully. + + As defined in gpufit.h_: + + :0: The fit converged, tolerance is satisfied, the maximum number of iterations is not exceeded + :1: Maximum number of iterations exceeded + :2: During the Gauss-Jordan elimination the Hessian matrix is indicated as singular + :3: Non-positive curve values have been detected while using MLE (MLE requires only positive curve values) + :4: State not read from GPU Memory + + :type: int * + :length: n_fits + +:output_chi_squares: Pointer to array of :math:`\chi^2` values + + For each fit, this array contains the final :math:`\chi^2` value. + + :type: float * + :length: n_fits + +:output_n_iterations: Pointer to array of iteration counts + + For each fit, this array contains the number of fit iterations which were performed. + + :type: int * + :length: n_fits + +:return value: Status code + + The return value of the function call indicates whether an error occurred. + + :0: No error + :-1: Error + +gpufit_portable_interface() ++++++++++++++++++++++++++++ + +This function is a simple wrapper around the *gpufit()* function, providing an alternative means of passing the function parameters. + +.. code-block:: cpp + + int gpufit_portable_interface(int argc, void *argv[]); + +Description of parameters +......................... + +:argc: The length of the argv pointer array + +:argv: Array of pointers to *gpufit* parameters, as defined above. For reference, the type of each element of the *argv* array is listed below. + + :argv[0]: Number of fits + + :type: size_t * + + :argv[1]: Number of points per fit + + :type: size_t * + + :argv[2]: Fit data + + :type: float * + + :argv[3]: Fit weights + + :type: float * + + :argv[4]: Fit model ID + + :type: int * + + :argv[5]: Initial parameters + + :type: float * + + :argv[6]: Fit tolerance + + :type: float * + + :argv[7]: Maximum number of iterations + + :type: int * + + :argv[8]: Parameters to fit + + :type: int * + + :argv[9]: Fit estimator ID + + :type: int * + + :argv[10]: User info size + + :type: size_t * + + :argv[11]: User info data + + :type: char * + + :argv[12]: Output parameters + + :type: float * + + :argv[13]: Output states + + :type: int * + + :argv[14]: Output :math:`\chi^2` values + + :type: float * + + :argv[15]: Output number of iterations + + :type: int * + + +:return value: This function simply returns the *gpufit()* return status code. + +gpufit_get_last_error() ++++++++++++++++++++++++ + +A function that returns a string representation of the last error. + +.. code-block:: cpp + + char const * gpufit_get_last_error(); + +:return value: Error message corresponding to the most recent error, or an empty string if no error occurred. + + 'CUDA driver version is insufficient for CUDA runtime version' + The graphics driver version installed on the computer is not supported by the CUDA Toolkit version which was used + to build Gpufit.dll. Update the graphics driver or re-build Gpufit using a compatible CUDA Toolkit version. + +gpufit_cuda_available() ++++++++++++++++++++++++ + +A function that calls a simple CUDA function to check if CUDA is available. + +.. code-block:: cpp + + int gpufit_cuda_available(); + +:return value: Returns 0 if CUDA is not available (no suitable device found, or driver version insufficient). + Use the function *gpufit_get_last_error()* to check the error message. Returns 1 if CUDA is available and CUDA runtime version and driver version are compatible. + +gpufit_get_cuda_version() ++++++++++++++++++++++++++ + +A function that returns the CUDA runtime version in *runtime_version* and the +installed CUDA driver version in *driver_version*. + +.. code-block:: cpp + + int gpufit_get_cuda_version(int * runtime_version, int * driver_version); + +:runtime_version: Pointer to the CUDA runtime version number (is 0 if the CUDA runtime version is incompatible with the installed CUDA driver version) + + +:driver_version: Pointer to the CUDA driver version number (is 0 if no CUDA enabled graphics card was detected) + +:return value: Returns 0 if an error occured during collecting of the version information. Use the function + *gpufit_get_last_error()* to check the error message. Returns 1 if collecting of the version + information was successful. + + + + diff --git a/docs/images/GPUFIT_CPUFIT_Performance_Comparison.png b/docs/images/GPUFIT_CPUFIT_Performance_Comparison.png new file mode 100644 index 0000000000000000000000000000000000000000..8617237a832225f7b7aefd52a59e67cd21588d1b GIT binary patch literal 38471 zcmYhi1zb~q^fyj})DQ#&gaIlk(jYauyGu$0q)|FX3KNwMr9rxp&QU5z$40|II!6o! zqn>@A=lB0V&-U8u?B3njz4v_HpL5RpePZ==R4GUqNpWy+DAd)I3~+Gpig9po`$+Jx zYs?Ed8Lk5_z#uzJ+ku11)d%x(lDjgF;%mV?wm2a)u|LsHa*iB|5`*&cYj*jO&_boo}`vK zaenoE+3BB7|G}~d4#VpL2KHS-Vi%OgPW{u%dv*7_hRIp(L z7ksR>tb^i!FWQR7)tx|3!6iOh4Z2Z2Tc}cb%m%8oR}#EOQ===#srx=h^1YDSfNr+W z-drrrKs@m-*WTPoqT~nEcL*g=#>M9u944Mk_h= z%mVo?Ai)wDl-dLi=OaRduX03tzO~J=caN@ef8^G5sY2fD?6^)hH*-Rf-{k&_g2P=l ztkt*Dpm?bJcjm*VD&KijzIs}Z8$FPNvwJQMfRuh5fyQLQ)aL*!G{(JA5#FAo+7s3|q3WFIe$7nAZmCCQP!abELtZEAkoA$)y z;8lh|<3b%7O}OhUp>d@#v2jz0K^mh*TIH;YafM0c8+c|Jsj*A=kZCz3k7=95yoRy7 z1CI%A8jp>7dG=&S@$fe;LtKw9-+0hEf9rp1q~g+oTYQrIHGYuN^PV_P{>v9%d3k5C z_9owSC4>oe%A{-^)OGx&w(GpF(i3z)#54{?F_ukysRf<&RqD##4^5AE-@v^2Wv&js z=spT6=$RgmIF&tjgL0B>g8yYm6XuOst(L#OO8c+@#zO_Bp#~J^_6#s3P9_!87AK7c!+ox(_P$b)g%~9}U)ap9Cuar-}2=>87sm8^ufVFJRPPHQ=pE8WK`W2rL4J4veEj-fA1 z=>keC(ZRWH5~q&i82~Xt*0~qqA2#^D3nOUSPGui3QOx=D5D^eC(aaS+3fT&r>y*~t z`OTRJe$XsdH;UWKK_aF!-gP7CmTEgqz{Ex6RwY*pHAXe{X?Q*g+#YTDQfPY}!_?mL zuPL8R8)M@eTIW%XslBFr<1$fs zi!Q&MVtisilgl#E1Cm1pD2k25vbtm^w+KF1SllN1$sOE+sPv4v>uCt$Cs9mSNi zcpVlnxrhUdyL#GCB}bp}rKeeps+@vY>p5Hl{#5k@)aaO zs6rGcA>=T^RvncxaF8Xk^$P<}TL}n6+~Gg|m$;RyocUm#*+b^&N~nK7==FU2hjI>f+T;EKl8=JZB{7ep>I<*y6H z$_&meXe;3a#$^>~93G~$YBS?<1zNp~(=<)Rn{U--^={GB$7rkeCwn;t4qwt<7&a

iE2Yv}`t>wzl@Ja=n{sGzlP*1R|WDm^2t12*HfZ31uGzS#j* z;R^gvyniP)#|*`Iy0Upl^*Q_iF(gg&>7x)1@dJWKA(Gz~00Go1EBOOwkHT|smH<6g zxJ|(PG-D)1PYn*wS)oR&V))Sa^Y?laxe;`3@8#-t<=4^&sXdDBa68C~dN@Y7m;S_3 zzfM$P{q%fQscYOiS%+<(1w_}OEr5C@4-sC4E8UW`I=nli_+bt0?L%LE{e9Q7oV5YO8=;Kj6C|yKh6Yt7Ln;Z z2A$8!j-~2o@-OgLNAa)6YUy8IELbYrM7N|%lIvX-&#sLU1CRS;kr?( zxv1i%Ny$F&XM}}Dt;F{b{ib$vP%~$V%6af~jy#a0+3*Je{jUcsbdli^Mf%s?K|spB z2lb>1WC=@hoy85SrJU3TV}t%=p)6M(<+@40(7m%ZkT!ZvW;1)=aL<0DqZFFCKdl5b z00Jaqw7}UrmgTxMV3sTO@>)&|aKBU5=aOVNI0ci6_8NXFJ0A<$0H>6Ug6>~$$_z=J zr^JN}UOyD&WkIxtZXe_3%k&c;FoRZm262g1DPM_v=$TBykB{36d*gF&XptAc8>Ui@ z*glFZ&U-HU^FCa6fc%wAP*3?JzD_)gm)8DHSp-CdLi&}*%i_9nR~Yjw|JJU`)t8-Z!R1pjpNzPUS}B|OoSnbpgv%bMh`I^TPv5h9DS*! zisV@Iqih-V1avW0nkvsMaH z0UZ@fY-5hYRK39(;LH2y7B&?S$ z;I=G?fJpiuc7*({V6A@LtAOQ9r>KS$`mD?Vo=i6M*~^uI@AD0h}UqKJIU2Y zTZ8qMgU*}bXEJ)TEM0O|?M%KVQqQ|2%fU&xby<;nbA%Tb?JW>yvTDCN;mLB>#rPcg zh+Cp+KW6-3@FlwG)E?BHWb- zjboD+n3tVw@|Rm5{kiwf`+hfmj`V6_rk%^vGi3@teqx=t>JFrG!&7V3Kc;efrR1z! z*I>5u+thCVexre3o~7q2)dxHwe0BnALOdeQTj}#@?TtdN)#XP*r!oe4k+L_i(pA&) z@kk(c0igljehu6ZoNeL21bhS<=20T2Z3zE!Bp?SyWOlpUlX(RK8|Xx|1&?0~J9Wvr zioN?&C6S-e5a&3WTPfniGJDW=*0wdG$cHnU(`|blpVPEIBlG_B5zK;2|BUX%$4|HS z(h-zr^e?9@v=-W2(RCk?Pt2R#hJ%?~ptJ9@X2w=Df`j~*W;@n&)=Sc9cIHo%^5O?DKGF-N>jj`3Ibw{@!ffeuE-1(Ee7CW| zzcit#`TWSo>+XPhTzc(4_Yr_^Vvdpfp3o+`;7<_nx+9s`4O0Ga z!Fy{28iRt6AjNHu!{rr;DH%WkbD!AyRVkSTKNF0yQk+pQ<%qo7@tW#k>+ck`J=Qb2 zmsf1XP&}g`I%l?pmEN-^dh-`?9vCMEq4y^{Jke+j*ZCgB-b{nIw5y(NsXVZq6}ceXL&li7=1ab{ifaWgyCBDNqJjAm?XlzgM$5-7oL8>r z;%Zn~>XCgU28@Wt7`yK9lDn)zBSDFJv`WF`8k@Y8niNB(3WPP1RU|y-&3NTX49}%k z!h>nkSHYT*RunJi4%JjJx7aY;K=u3QwD4Pnx%z$+7BLJJ4iypAO(I<)Fp-N&I8icD zg-VCYBJpjaJKAj{%0TvG*7(15%x z%&XC!Sdi5KF(%+TUMr))W1-#`HupY%(tdz9j!D*W>7(F!iiqEh(nRZn6t#6uovloP z2cm?HYyAm4rsYAas|B$sr350hzZ=A!P1v2nc2%aof|eQs}rdKHkapQ%rOMR(*iWPI#!Oq;qdc1avpIdt|xFKOd{JIX@5OVeYvwM`X(-J{hF@9g7$`pLD`IjMv4EXV1^udVk4619zP~So-=|z9 zFzi#kIHg_^XMGG2aXAPmUH$nY?y=0w)Um#OstOMU*}I)jlUz`Gp~rrrF)?%f0@qX# z%OnfVs~u!6ss+0D)?iHko%}c&6B+c~>LbW17)op~Ky5oNhkQ~-Lub`qNkfy<5sB)> zZ*rO_QFQYCjW6Xk@xxb2+KsC9WkFmUDO;U4j)4@hb|&}Q9qv#_4K9FfhZk`CLeZ*W zz!uc3w@6Z}Koa7}rtV{f7aEG2AZhj^LHHeaae47jWx|i17$!&0 zItPfm%*d6%Ih!!6vaP}cb^HfPNdSG~zr^P69Gnf%t*#VgU z*ty{8)oUOCX}@Pl1D19f2u`Yp^O3p(2{{V;qQEmT@*<9MoL>_U(VaFAd;Adyl zaZ=$Zht-$nv#0osqAJdZ7yyq^1$pc%i$giDxYE|>KI*GhPdgEL=i8V#{FPAW`yXwt zmN4RKPyH~(FBo8RMR&Qe)kL$2Z%a!}N2TwwjQie^t0CGps~yr9-zdtcSIQEn8;%e1 zdsXCH=e`WqIQNTKfGtjztPE7|yL(u98tw-Ho80PtIz$01#LQ;vduyC&zHE&?%<3!! z%ud>ckX|<*O8M^;m7EI<9Kt)o@BD!$F{o>xgw!Q|8mEkXsZs_in6>Wg#7i|CkRy)8Pj`zT=e5AUIzTUWVQB~ zhtc1-6L*55S$Df>9uPpaeCC%tB#&VvAeyXDTNloBe^n;_G-hNvh*$=2z!F8A$OESxcpoOpINNg(YeeqgTxU*Py zF8}yZ{N%_)Cdi(Slx1w^HsiG+BG0bq;#n(f>|+xm-=DrD8FnWxx6YwsKzi_G=P8v& zz=SjGahI;Z!U4_1N;oJWfBenPG)+`Ye15kMxLux^r>6_|vcYlS0jx%j-#1(Cko5aL z_3;OSCh_p?hp2O68Pp_RCjoV>#Lhw?{vjQKALUMCuMxwKQ~X#_ z@CWI~RUeZ`rqUcUoIx!NCzBhoo67s5eP=YAos7G_NLdsnfnmw zPTeG3n%ioi=Qv7@ZV)E^;B1MR~8*`40 zaiO1b>D+|%TKtI)DiI?Md4tl(SXDZir1$eF4Buqa-k%#X$OiDN|0xv;RSL2wSDj@4 z-(IlF4uj}A-K_3@-On9s-P--ii!~eNT3>QUMDG8Nc;}7?HQ1&*6E^5@fA=-$t)d}& zYc*W}ZwSM*l%3vPmfCcb>u>Ad&`F{XW#9Fz+EFeH{ifHZIm4(BUmoIi|FNiREhD4YOJYQR! z=vhvVLz>3gRLx~6r!{towdN0&(iU=49VE;0>cCI$%q1Y9J>(3evp>e=U(sHQe4Ni< zg$HUP(4(`~rY)lQQVa#~s(<$FEgY&Dj(cXxk1MV#p#7Cie6qc;DuvbgF8zLs)V_)W z>l;|Wgvp_#nCCL3LAPDXmcVSSbq9+UYS^gi)MNLwju0{`>9)^?WBZcC8TSge4bKi= zpWq=OE|CQBUngZp3wpr$TUw#hnPSkrV2#fA)NE;lt2d#iC)BSR#({$kFHw)#1|}S zX9YhcfQ;Yv3u4mOt~E8H8L?o_S9JvU!*Ep;ONS8cyXjtK}di@d$8dst`}Nya9Q4WJ6Db0gxkH$%Ny zJqCOOUMj|HOX^tE+v*GbqnC48GfvV7@sAMk?^SrZAp5<$hwg~?nN_<)!EysbuCut^ zO!ieHP{7*_;w1FQMc9H_XUB@RzRATC{EBV24p-JF+=(5=O~eOQV|{haSvVp}8qtrl zhto560?w4qD6maIkKl&?jNpB+^A*QhGQXB-q7>hvqH5fxA#3PPr|GP}(xPf}6?#w< z{yEI|nVfo9D4UlbP6BD?Y>ReNr&noA7iRTm1UCJQVtjOE6~i6}=f+P3>Da=K-2)!Y z@#%d7is_v*QJ{*6Uf+19MF4~dV8;}Lho9XeN79Wyyd5+yc<$W@+kMdFnN%5~>6`oG zBJ22Fv@7vTUsU2=M#BeTNS;}R>Em48_|Mhlp?-DuBxzBfxK_wtFl>R>{I{-^BrdQ;^d3ZI(u@{2V=Q7bb@K<3CZ|eEb8E zMAif26~N-YvQ~>0MhJ}*PK}N1D}%Vi$o`ZjvSFjTCH!q=nNVAi`Spb2JHt=qrZGRP0){_ z0^BL@rF>JbN8_hf9T4m95q|`BsYxp2i|TKJg9n5CaBQ>3t_3khkz;o=g?LV+bAR#3 zLr1QQ2E~Q%Bm{&2Th0)wGvOH-4 zDX3~V)xdhX=(n>}?+z*9TNH6w_~FsdA-V^vHEZI~I>9nI#EVO&2p&?VpgC0k3n~S? zWPIvq%3Z}=`2}j}&tOxi3W^gOP`Cb(f|-;)-c~{CMbC+e5W+l* z*0)z3#Y2S}sWst|cTU(G`%>TO<@e+!@J_cZ<4o8Rjro8~n(sfuc@CL0_l4TDS@O(PfTnW#cfU?{V8_Z{Azxnoio69lf4fg>b?y(>rHD$+*JK?$qfcD$B z8)^byE>>*U^YNy-s~5PDkvU|UaxGRV%sW0B0^MdDq~wi(c6Sff^Twdfnw3s>C}ij< zOW%s_3V?;E!C;W@lgMD3x(x6AOT{!$18jrk1N}t>O#E3HzsmQOt3dBsa)w-_v@+PZ zZ=;6)Z9t9R==MaF%-g^9y-lde{q1(0*PT9VsWsyc0W~VN@8j4s=_m!ioP_u0XKv|< z>w)VQ0FOy@;xgfJR^B*A6j}#2FBlne`A}{#3d|gDZpQzV-?zqNr`JWlY)Njpd0CA^ zdZWs!ctfM;?I|fQKfJOz%klFx<2D6lRGtUO4U?E2V3Gf(%}YG$vage2d3$D!wD;^Q)(zKP`rw$+qTh-<;Pb zM*25m)ShVAw2!JTB3%}*Eczl$w3RQUOFf+?Z* zAkOuJ(&#&7#Cc$4Qjp&Ea$3s%Vao_9!Ax9jawy$FJPZ;mTWbsFe{)2%D(T}&P>H^< z{Ijh~YHhGXg?fbN`00JB;fA}fShV#`&$6f^IN35HT%EM@O?YT5EL^?TIEKAR(%;pP z%+Hft_nU`aTYI}7a#?5-@zR=9Y%^euiCreiL%d166Yqo#nNN_QS06ONiL_U& zTDOVQw_e+rzr`Tg+5{Rfl?sS?#lHkw@f-{cklpYN$>+0dkx3Pt9-O3E;j!2oZPAR( z=Q`D4m{|NhoX%rOLp`bJFf<$5qUE3$E0m_0ULDCO;`6qblZ?;2x!&i>rKp~^(zSel zOWLT$Am=pA)uhbei|gE=pgZttx@0nn6PoHdHGEalneDkSeC5=c9kw-mwFS-g>~#&K zhvo+F?zfqC<_3>uwar2^f^W0h#0g-98yzCAU`zKpqtikftpAFyr{PW8a0{HL;icGc*VQl}SBliw@T?kc?-p}84Lm9=sf?ICK12!{aZ;@jIzRYTS-wq3USJmpuSt{~Oz^%18HbgQpXmN3d&F~| zA>{s+j2u&HL6IxeId902`wm2Y22zY6wSv9q4mK|v_GOascw_S&Av@WqJEaajFL;=} z2@p5Eh%=eJ+Q+}H*1UVE7<=LJL)Ir?nA+tPt7i_&Epd6LsUX5vtvh1bxMaI}`n_BW zV;zlNgQbn=x@llusfy`$ROR5fVP#Gxrm~ZNN-O8w)9ApT;j?JzQ(X0d`>f6sHpQPR zl6UGnr%d#Q3AcX26=dRne!Rn%*3NIRnbju4;?J7OkeijLZp;$gGHSl2eIf2$Ww1q` z@}ZITEmPT2Uq67ESJBP*u&hJgc6Ty>_`G%NXiineM{m0%h+e5Wi_xY4!&z-97vyXp zex8q+Jjd66xRZwpQ6zWpUHzeoO{4;i>xl7S;St+oxsl~pnX8orrgF5Gq|d5pDc(qO zw&1B35+40vc{rej8Sir{%}QJ7oc-yrIUuybHLs?9!ekWiaOJ16E9@nfldx(cw)7hb z{zNeNU=Sc(1Xv?@PnhIbY~P-mw)6M1Dro5GslG?~c$BP3Ia0b7Tc>T&*@I|-FZ=Vc zV;59(hb7&FK*@nOznq^B8-YvT{`S`};jykb?I^Ac*GMfVZy{AS^7AkO8 zagI1fsW$uOFt?0XA6$NJm(m8`-M$$ge{pfM^pAf8jE;^*LhD+8ooQ-HcmMcxR;el5 z-Hh^P(x6|^E17^ekh+t;Yf#J@qFWdt7>%>oACe}vFveWbKF_l;IlWTG8nb`VfzM(1 zqaoYT49Lz`g&UPX@}t{6#2tAcVBXy!Xs5HZNY!q|u+hHPQ^IdWH~SzN{nSVWP>7WJ zRg#OST(xO_w<{#-Yij2iPawS*$g|Z&8v1>|@|KYHLQnVO?{VJ@5g-T-IRY&{BsykQ=MUH+_PC^h3-cG7il@&{j-^vPGo^Xr=R%EPB%2aHlX2d*=6+xYBP1i zatiHQA$wQ)^2k0AV0iOmG%A_p_h~K%I^rw;%!G*4F3GN~80I^vdQ(Ctr&-Z^RtT3f zs*4Mm5}9``p96o#aonoQdtio}izuNC^`YY6eR?dJc=;ve87~-L8$SPa94K>qVD|5n za?cvKvEPP5EIuf;XIaLCwXS(4^GtPeRqv9t2mBw~Vy=8a%~KDx07Z5}+!}rec6}ZQ zyd-6~V(A?>M3CMkmM8RLBeTC&quYYBfIx%1@&7L}K9K}{y3w|5b__4HC{s4JrDHWulS8O4{esExwBo`k1v&v zEG>%XPF<8uXSv#&RVD#0kLL9%2UVNCxZ%OM>(iVq>rxHH)h+kw=E*Az-`cwu16=5= zOdEDGGnGw^_}i;oekxm>RdnK8n6*r`3qJkg5J+;yqWqVI9a&EEcPN2(K!F!usKl`L2e$6$b1eA-=W^Na6xUeIGe|x4250oWL z*!IsPr&I~9mJd>>J%gToRJXFFjE9_X?nr5wJsu25M+fQH6n3lod*6jNw-?y}ds@a2 z#316bE!21;Q-RK6LPTd+#d%rJc~BwiQl6WHLSAzj47`VmH7ucsd2pA)N4ztAVKsz> zz_z1xR5nzL{ya^-aHsM1`}-~?*`xxwb@UwWO^-2VYdQ!s0$$ze%3R&9E#{w@2F^_j z+EiL1aF^fn)B=_P2h&xru;g;6mB9LW@pVq6yj7PsmgimA%?Pzj+aHA)2WcCnS9U`F z1|6$|`QV(UO_W^L2PmG4eMp9><2n*$ zMnH&CWP?`qBdw{LHBgdS9mh@o)>ldD4E5UFPkE_~q_S|!2c~rEys^0e@5ab+Ky*7M zxhsewYo>~K;4Fn+7v=Q}>$IV~Nd3to5aIo8fdTODI$#gdnF?SBKH4h^6@s7w6E-^A!Ex9PKH&J2@UhwsGjJ)Sgm<2VVPf4dJ|Klkjq?!PT`Di)1IhDA*{$9 zoHlwWmr^nov)hfq6DqSiISO=?@q z#;kU#50geWTU*yCAH7MzQ{`r4r(HFVbjNVh3)>Hw--#(Ncn=Z zT(n&8BHTW7oJ?u+HlK~kNKv(H|J+uR>}nSOhd{ruyUKWkT=XE&?5r(55x{|Op@QP=Tf zw}GE$bg0_94stlr+EQ0u1@S-BZHC`E+rqVxAp}2nQgv5;%P_ETQ6^?W4ew42`KfL;SSUfrI{--Ke?JL6rf5e~5LFN@a- zNX1OP{Y%(0BK^=|C##0er66_Wny8cN=JFgo#@)jYI1moF6qNQ=wiYMZCujI9E^4~r ztIus+s96imVN-`Iy|zl4Rfl8LI@(G3{o}-t=kA5{mD~ZfD?z!Y)lKbB__<^E6)WgH zM)-f>uNepMRhHood5Vdct~FIN=zxNnNyBgJz3)bTwm-M3Ew$f{!@yK$u}JOfU8UGFJ8$+) z0+r9_L)5H?E^g<0#+VV&K^)_o)Xu+;uU*#kcUFG3Rmi8d2cIyvB#0DO*o(n-$|`v# z&gReHz@)+QYW&|C9JqXfhZS=y7*%NsbwD?3^Gav3DPAN~$kfb6#V*SPcjaaUWJ9LTg1a-&-RHh6oPx?yX?rqd0c zO{lr`(u_2-#cTH_dN`FXBAtH%o0cjkr`NoG+ek`)Cd3BZj3|?&e|g!UhPPLY69%x) zqQfRt+EW^Zo4m9077mSX;6a7({cF0Rz>xitoasM@xndsUjX#4+c@OhI7d@z-#7^OV zm~%vld_rcv(^zm?`3y}RJj=ZX7m0_VFa8L@QI$YV*0i zeH~~L)RfY8Oc_wKu9W>v@k3?c)lGi+)duCbgI?(h+}CfsIj>CChh7aWaYnz>iF`#z z_Cs$#(0e=UE2Ql@I}psaTuG^y*J@a^l~Iw#Hn13P3~VVJMyfUiXMzt{x|ekwZVao5 zvl13`kt*~n4&n11&sANflryS_iSK6LC80_6azrxEsms4j zF6jf#9h)wg_tQh4(DMq>hl>B&KCy0{1trkO2c$#zS+4Tm8UlYA<`K&lg9yW_wlDz( zKtsggbzoH`TJY2^?4iNx=IqKy=Iu{n;8Ge8SO}x4Yy6*DSbd+IUZr2mI=IJ#q{Q3p}#N!?U8=1=@^**c7|Bf zA-K-x0-_ILbhJOcy(*i`+JLZ9*oQsOT>p?gb8JwE6{s=R4ZwyvSrQl`hOnEvK zOdgzzUcYl8n4aCR=ge=hO8*VX^oU75j|(r{qm} z%%nZMW#=mlQBo?a;t5jJMHH5H%c^b$orwADS#7q*`(?LQ0w(skrLIr2djfqfQlw|Z zw|C52GyPhk;sWC}+@rdMiHllhwe~D8Q1pk$ya}6_ABx7#fwG&t}}q_$+0K+?RCUDQk+9wUT?ntfJoLHfXn|4(X7$%s6lh;8VfyS zK^0>n00YEIhy=1e&)@nsUkgE8K-6X{&*W(7#`&(_Eq7o*ecc!Q_wjFpeQzw5OfZky zFlN%BkE{tLa-YsR{le#Fxm0+aZMObxW1apVk?IGp%;xZUn7u<*TPVPz)h2SW=-KrL zsntn?IK?o4hjH4gG`?xJJJHI5;y-Jh7Jv5=P2fArs-uPq_1|1JcGfLLtw$2qepjSt zaMeR~>ARH?7?2fK60*gD7p{B6_JQ>yYcXp})lG-^?}1Y;#kmIH-mHGRPeLbUI3({5Mpr>Rlkfv+s%@Uh;8oa?))1W5 zz*CHzK%ykTwnF=AS!`Dob&g!x0seKL=h*fCkjAAwny}R9vG7CwL{<20a7)CUIr!{M zECSf3o_zjPTtz!GB{K9-<;(q`b|OzFL9F2)A@vAZe*K}cYBM-GKmS(uTJrvx0*6=9U*x&0Ltw%$|q7eCtZh-BLWMoo!b;#$RC_bk^$E z5ZCiC&ilU$78taP{9XdlT})(X?abkT%P8>qQQn8PTLZo!jMahw;q~ty!LRFnf281Y z)3qZbV|qPD&`-zEJc-E$Pa)S%>maWp!Xv06wIcKDr*uAI0R|edQurUhAo$J&FZKX#mZZ#I5ZaQ`d%$#5?|JWbd8`vZjgueiqAsa6;2mPK}}gw@g3t*QyWUs9p-s% z7!zeDMm`UStxO2b0w$Z{K6yoc#6iAQz(f$5%c9!0rrU zY@W3zM|H)}u^5SD~K zm6?Ui6~Z7sZisT;A`Y#{4%7~z_nSe}Pho0H)|ieZeJp`!Kb*ZdBa4hNQ2otE7&=cV z%M-I#q!aAA)|fRQcK6z{w1@v!W&ak{ipCbI8I%$?Fmj?7n6f4L`@FWhlDiedjo!Na zwWd69|KP@^s^+wVMAAQ`rn zBZSB&VSVDcY9Axr1+_NW*6o-mm|W*v`st)qS0d{i28tl$;nLwB!$-nz!u28sBMAD; z>Zx@e8KnM8!d3yX=*;y4masz-_Prbs#>r-gknBt-_sp3@f8R0OID>O?2~zl6B{ zK+;|13mD2;Cjq#DCM%Zfk(cXs7xu^EA6FqT&y@=2;hDrtp36nQ@CSk)d6lZ#XR5qS z)1X#g^BEL!+Vj2MRBz4n&f-KJSBOaejr+knY0)U=vO=2NVbzfCp4IF&UF6`Zw@$Z( z5*zDd64Q@_>tueKHO2N$J-pFfy{BN|skNYq?4%?JR2C(KbmItL#V{+Lw-`W$w3>`R z_y2152^2LoIa{7mX6s~YMsQ!5{G@&~S4;bnXsVYyxa9boOZ`Q(DI(|&F20uVIKfPrA}0WpB&E5q1N1*$ z(_jf-)xD^tNJMZ=LQ+CGa=i%4N1$8d0nI7jF#`4xg9BeKjCc%nr=nhK0=XxXX5FVW!Ftw$w0geW#_wC;t4Q>>J*G!{Cb4qp1y zJM&^B76i8CH~Fu-0gDVjWtw#Z6G(+mPpZ2W;(t7dsNWR*ElH|^whe-?LR0?+}V^12xtDNI%^kML(S~C?MW|g z=nW3IDV`uF}5`c%_+$ zH?R~!vhs&Lsm0bC5P1k9l2(dLJ7N9>trSPN}vOpubXRz;^!r2k`V5G-AMdex(Hb zRlD8~!>YLdH=ZV9O$hc^cBmHzMqefLRr$DL+>}gFz(OQfj=D*FVe;Q#SCV}}xfzXc zg_nJfIpHp|u4>C_Fo|7>{xd*2l&j17+(JE)KGph7z@+>o5a*ue`%(S}0rOcNOLZG$ zNi+m;LekmprhSj@&%-;w|5wFKuzS}Ix{1fmQ_^U%W7G2?rdKv$COM@cSG(JA+zV`% zojy<#lF^<~vi7aaks2 zifjz+w}7<+*Lq*>mN={UWmNoI3GF6*3skok>rd58cH6x7NZgsmFDjqg{X%2n! zw|dS z@owR32J4L^>v^2qw3F5hVzX2PesYMHqL0xeM4?IZt32r+%f1(yef9D-P5#;0IX<+w zyG8C9buooG5<%kNE^`_4dtuAW^4WsHS`1mUZ7X*nACt!ptfhjWIkfKiFKu==ruR?F zp+{Uzd)duTXH#qyrCa^dSb2Xpgb zT{PrS+U@Xt17I;s4fiX)2C*9HSMslb!G~%z8g%50Z6 z@>B$Dv{$>OdC=>ljROaYopoq=Z%U}&q)E_IRETk(Wbs=#sjLb1?B z^;2sq?2i8;yE`EV%~MY{($Lse#q>^k?8=V;Yf{J@(_`$TwnlOW@{>felAN$-cDAs~ zDy`UarfzhxVpsk8!ga<%D~SvQP;K4L==B7B=Xrec5c^Jgg^6S>vU-z@W|cx#|8CkB zPrmv;dyutscKhzn_MhJE=XVZx9w+_JsUHd<*=E@e&bCIe&05^Z zdymtV&mn48RDlV%&ya-wU26QRGnRgkVTqQm?5z*H!kGJ1aZBb?^72^lUPSW$9K~)^ zCI+1T!u&HG-$I-Bc7Y<~0*Sz;%cpm>y@3h!XDb%u|IemM!B?l7Qho=o+!q=hhSRHU zx`(#4LCz?{+}6&q%luD2L+oNmL_cNa>8)z8|2d6jU>}82MzShJ z{oe8w>rm%ov+ZK~bUXUMYGK8-%oBpetOF~;keO`J_qKo>`@J7#K7 zeQZoXc0veRgs`rnC~Gvd*_C)#cjsO0sH7BxP`fVWni(=1{qzY1O}d~bVC#+LKnVzAf{RDqDq55?bL^<;Wg*!d-cz6=g%4qNWwOBCyk6G^bn*aH3 z55_qfvsCS-X*F9|L zAzXpX;)p%8SB_s?{yTtI7g9-7#SsB|Q)NpdNQS+24N7_F{E5KvU|7{+&rmSlY=1RT zSuA0XtCGLbF-I+~%NtxX4$lv*43!HME1W+vJ$@dSJs2+Hpu@p@+B|j?X<}jlU$R$} zlbF-I`;ZNtJ_+}yERBDZaDA$5WcaY2V`AeKxb!KBLgpX;6~)Lixt6nB{-~O<&H)JL z#Kw0*w} zj<1@SIsr2{o))6CCKP7cWZMR%>$u1f*B%#4zdaT7lM7E3V_hcQSh0>O)khI+kw8yn z>{uPpDf2pA5s7?n`Dkhzd$P*dMD-SvwZg5|E05Om?a)E&b$L~m*J1CRMJr!Z$O?I> zU;kcz7Mtt;f4Bksm5f;>HQ|t|jx)!>Oj7K9EQ$6mpMFTdrcL9pQmC*kgVZn7xusz0 z9}Tv~H%a+lw7m&9)NR{8ZVkEZg|ZAODoP8HWyVsWLQx12Dn(hcj={+8PDW8=Nkt@k zi*@WGdzPp$ma%Vx!C=hH|N0K~+|T>GzxQ~5zvK8H&vP6%`ObA+-|ITB^ZcCW`8m&F zpK^=o4fuZMYnnxt?(5&X_j?wz3&@iRznF1He*PZn(CI7QK?A&Ytb=jA>?#3wQ8g%O z3uBw_0pCBl96b=WQqHN!ebXLQ=Rgf@iWiB6UkhQV|1RA8g$2lq2?O3C z@>#!fI$wX?-XS>HB5=$=i1ykcZA%@t1nTHal_QgW)u+#PyTj+71-tGsfQi+CCkog; zI5_9a4BIfJ=ZIx#xaaMUY(B;r7`8JkI=SOa<^Ym5w+s05~Zg6^|#_# ztF<`-qkHsz;@2f+b&$^DCc4PZndd6y@I2ioy+DzLhf}@b2T+%A7QGWe`w7MZe=*mY zO)(K;73xc6j>|u+Hv_8e$t$;Y$HRpxh`94^ zmt1gc6&^gSWz0`W;K4v%`8O{HEnbML5bkcs|JIWm`1ROXpfgr_2fOcn6F$@YTW!_d z^v4WyAA-e_J_(aJj}clAe5w8qt@$Qt&^WyhX~>xm8!JbeK?;V~=!vj+yFHTDs^lT^ z{t#lPkn`r9m~AD@iU!I4$C3swxYxN4rGTLs$lGrr8gT;niL;tW>mnNg-XqMvUv43p zq}Fu2KWM4q<%jOOW5uJOWw2XP_}#4zs!rFIqcp4d)@RC{7M?a zI^+KCX8%z6VtWU5J!q0h#nFii=B;L z(nAh@x+TI}tZ(t@?wgIzzdoJaUR)5+{U3JAkc1~&%?nxmE+BcXel)p%Iez0+t3~1V z)^803bsC7Bl_OM=bCENitrnkIhbtIncj<1+9ylI_9OxaxVl%Laz7P7SOGK0CjX4*u z8$^@sci{myapqLygDa1E<}~SxdVjy+`F)iD^G|nq0tX%*>4S%22dAsDbwmFG`uaI< z74|3zCmYj~qL_=g@rxurzU~g~Mv%n|4V6RN*WN(#K){k8KuVK2ZBP+#$yodaks1n)t5*4Sdf`AAYoOkhI1v5`jn$Q%nMypCawC4==>Zdb zo#2G|>EweE>j5_Wc@4XRbr+wI(3P~;pm_Sz+_$&-VMp()dfxAQ?N}CdelVFrwMN6f zl0C)BKFz&aW$K>SVEb%5q)O<}+|o&ZtlfOPKVxJTq_U!@0oQm3@JzQgZ}7=Owr)wZ zfvd~VPA;xtL@|)t7MMM)fhWCN7L#rTMe)MD0$o?_vIF#31K>A){TY-oawc%9?ilAj zU(2rB@vKMV4N66Jvjd|DnOKtrKcv2y^r_RgA2zRv$LE5FPjD+1FnnXXXkjNus%Erf zJ(jDQo>Fi2gl8Xn$B-0(ws@l+lwNsEc9v_$iO;E$0{Wsrh4^$2ah5bln}Jcoku>#r zrcZM}_S95xv7p&h2^^L@H7AfUy`u9v=4I97U}I(zfnn)9b(fh?xQ60J@GWzFXK#Be z=I*q8p^7&Yk7U2a?lG9#L8x^MP|8f-*YqnWdZ2iZ0hd|p&DT^jhW^M8(n;xEjKz(P zO5Q0h4%-g_1D#~fYs3UYqG`35*&m-X+(@`&)bf1yT)Dx(;7sR7p2=C#gg-3|eG)lX z;ihz{%;36)%8IIUUq$WdN-x_ZU!)u4T;xvVHm}`z(844Hja}ui5Bk}wztnXQ*mx$B zE|J-m(+uOHl!iMzU7Yvz+)npG0`PF2yN*-8vLgKmIRO;AX z;Y3(r^FZfW7m+rqk4%!?CTI%{Xj3G5b(RE5@Y$xm65bnE&NTFSZM31fZn&iMf{kpw zIZUa|1QtG6q1?5Mla!fW^PVwl>amx-<<9s*6wSpK80W4U^QOe%?#7YX7P0r4`Rol7 zpV=hKLKIH7aT_!wMpLvCM6;Nkml9kO48ri`u|os>4xA9SkSm5k$0A^Gg#FM%3>K|S zOf4gcPkwiHX|H&td7iR^G>us6U*|-x716#SX%td&8L94TZv&;wYqI}7IqD7(e!yYt z$P%u2cZ**FFb(nZtuhO=zY!Kb3tuaO&K=|u#W{at{{|dZV7nYKq|)Wd-MSR< zS7IuJ*4mv~Z}ue)EI;0)?Ig+hFTu;&$daiWTBAInUtzgM=!I}Mp`!`-^}!U?-f~ld zQ@M}VT&7Xw1+TYas$Rglad3w++R90a*-#x7>-mFGJFFXsZu3CUY0;MtFR@jR;wIIS zE5opatqN<6QLmXymM%(i*|u$kuYDy){(SHb3sWhjML(hJU|aHESOA6RRPnra8nu7@ zWjXn9c}91+DJ*;wNR9Vs`OmSa0fVAFN58C&BnPDi3Cs4xcccmr_9MPC2ecv@LPfprAZo&0w zPG|rsy)MtLszDs)?h?j#Ji&LK{4y zonBQpc+=oF5u+5al@bKk#4pEXM?TzV&5san7U)t8aJ^oC{C?=QA!;#@wMWCt!1jzq zOuYvJI^`XZeEK${NZ3x6#PEXt#mRc1%laW#Q2`JdPlSYX=M76CwT%Vf(%5{bUs_;~ zY^A?XGXL0L9HCg1XeKqu08lgCC*x<4R3DKdz?wEE9|q}25ZE%mn(FcFy}4wCMpJ=o zZt(2=Ro00%2G;Mf={1LdC_Jj>h3BA23s1eyk)%>mTdOa!nJ z^4Y(*F!18=q4ba3Y&b;cpWjv)${4St8k$K1t5rO88JL!Wi@3Ywqsfz}fPFrUO z5A5WfAMYO4p403M0>5}X1Y22MtHHd3CnN&iwnCcclqKZs<>dBs2_m^dZfKU?D??!= z4P6134FkIC6Y%|_U=g)HgZ@x$s^QnO{m=z}Jal_)+aaR z2zXVmpkL;OsAewi*1&_2gcGR?9j~Ptc=nw3Tv+JPJ{KvjV*A%Fp`#zLFa`IuSCX~I zmq0(1U+8bqV3gBn)Nt>W?ql(q~ zR=}>FF)p)N83qOhS{hd5`xP^=pL|ZC*;74qKTcQIm^auxf`)}D`cHm!=yRS>-^~vE(U7ZK2D>}0CRJK1Az+Lsod+zH_m(Zb zA^~Y{iA$I2I+hah(Q48Qw!o2 zM<97_+x#({+MpAasPD;P56xMP%5g8`UYOu@7BlWR=+<78#7=?igr@r2$WgVY9n20-=Z zR^b1DZYkCd!)zmY%Y_)y&j;eFt_~xguAMiwzZky+?>9&~MO@nb6-A+}#~r;cj&N=I z!4Y%GuHKME4F2HC@0c70+a9alyJ(z|(UyR?Ro{Q;nlDIreMI?HC>6xB! zx_;7$`=EkeKw6*iaia42*ABaFPfN&m)Nj0DDM`(5oKo`WcFT2TztzC0kK>gHd&Wt? zB^W2U*rX$FZIF%A%Mr-wWj*5z?S-Hyan+++zMyNhArA49&^^_DTi)!H8};c6qF{Sc z5&N@Pl4-8x3%Fg+Cz8E^9~uoZ{ATtuZG97u%O+$Jmqn$wv;cE1c7f$$Cm676Ca${zJ>Zem=v zS+V=r=NUaD`hFXh_2WcqSa*uL7gdg(dfLtK<;I!c6IWsp#4o0gOmi(*o>bmjd2=f( z_IX~ZCndCz?AV*zYF+G&3q1R(gecVZqLN{R)-989;J3HhOm45(^!Yi>^E}tQ^5&Pl z{G${C?amT)qvp#6K{=y0Lp0F)@%M>rj!`D8{XY7(hTh8RmB0SkX{zb-!AU1L8a=nM za-i8n&Z<=O)1Af-pk7dG0qrv$`CI(ZNZJ?E!CZ4%N0Wz$Y4-Y!FJQtvE1pt+e0n3Q z7!S&IKA9Z7UcvtX2D(nR|M8`p$?}y{r`FKctX}jdpMmyY+D_mT$wun>X-5OzCq`Qw_&JaF3V8aC6jeUZ#?|O6E#L zP=`|Q&mj6gP-|OX@xc3;np;=azj=Yq7CqU_IZEbb)JO7_b=Zl-y8sb80Cgqt<4oi; zb8j_U%O07~H0p-VayXxdAQ+Y%hNPL{GAh+kPF)RUh+l=o)ErfK{|8EHPM^~K0ctHz z5p|6~d%GlFnmd^9N?{Y}^~<*z*x-t%So^N;9xeY|o-PeyQw!2SLwg*KZsLg(td3(p zcHgUJ_S~6Y)*#}d0WMHmV0*H_hAVD^Inv))E+*OtvdEt>8Kn#iZ2?-rlbPpW zRnm|bsX&LGARc@hi2nRc$nTN-HrMAx zC8DWmOX3@T#dlAhso+neo_m{DL6wd|@?27qV{i(+Mt3pJ>z97sndKQo;>e)KRQ0o@ zX$f_0P1gjg&A=nM+>tI7$kF);dZ_ zZ?#amk6Yrq({($Ul2{3vp~g?Bv#EOD*eEC??rYidzZ^P`$Sc1uQpI9Ej=$oGKZR1B zx0|b@yE#v42+cDt;h!oc*lWroFu8&y}^KX}m?SYY56*QBmW0KX^H82lV)_o5pFRr4Kq@ z)dGN^$#!gRbOQ<|IsxR}`?o39leqdKuN}RyQgYHdrPksbXi>U^2@^k#tn_PW!xRO1 z-XnX5HBm7|Xwty778FAfeK5z!-S?R4AN!VJ`~Vm{mb;o9a+MDDjtd4|oMl*$?qOGH zs)Kx=SrHJPfYK(XL4YUm?RzWQaGQ|5Jlb{j zX@^%_g&Jxq!`it$X6p7X!Be-+HklC~8d4BVh64?a8LsEdij-uT==2?ES_ ze>AB~g5X8?S>UD=tbPXmJFpi*!&XMek_M+Yz}TpTCp!8#(B;IL^s>B6qm1-!cJT8A z@EVaxm84t_g^1{MC2ygsPcc$&+U|z4Agzk`3f{hU@FGv>H7G0PJt9MB?kPo1G&sv! z6{Qj3$10U<@`zTjKEozd#f$VUZx?uqb!NkS9oP3wCxz-pPu$80{qFws9viez`+-SG zPbLXZuEc9g>J)y9z)`+t|dTubJ|X>hYgs*$8H<4m`jUY@io6a~$C!tHQGI2KYH0dU*XAR5GVNr01q61~7Z41xdv z=->Swd~4~pvd4MewzVH&$8y2eM~lLjD(-}+guzvn9Hw4gzTo-T{DWKF8!;)N`>=u= zeP6PQ#H(gjqQnqS4xysLKB(>R8G15HvTyRVuFsB1H^3lqZuvnVze3N(q+-mtomCvU zH-N()wo}QQ4GPvAyWg;e^|U=Q*6K1HkGFGydGWDm7TbgOBwTrqUL!uTGZ7Uo_oYqV zLP@Ua9Q~>OV6p8{uakQG`tcYNk3H8;GHB6z`X|NCp#^K3`2R}x)% zM5PImv#^%UgP{~Z9{bBk#I3#mMUeo7JPZZdk}d!jaQBxrgM5T-_SXiG7vi7?T_AW) z89BucURl$pJ(ZNOSsl@=M7Z7Z+<1)AftSh7 zr;&p&7nANSiB}x~K}skF2|18{_eljJ8j4Dc1F{_W6D>?}I%hyyW@1e|coqX}Q2XLz zddTyy3=DTwA~x!XlT<2Cd?BPuIKcFc!%~FiZU^M_Z0j```lMT*(#b+Ub+;MQlZE~q zrAxJjFT`7Oh}0jh?5Jm?hxxP_${yrDl*a=Pixjw*J?8q&!m<3G^qD|u<9&);XbA%rdOh>DBH@I%>0u}9^9q1S&UTYnt= z>b!*8Nv`ZCEwF6EKmo8twn3}*M=761zv`#fDm@o1DOmpcc*e`W`RT`iMie_rQ`#~EV+};5v;G3O{Lt8x4_(}J| z_XhMgWL{o-7?_@lrutNFkH85-T32Y2yW;%KT}JD*($DHRTb7BeZBm9)b#n5zhf^Mr zCG;g%tl>Acdo?)Yb2~_LqVP9MFT>RDByEqwfk0YU&P{Q8O^6i-COEsVOhZT1J(w}m zLZ(rh{r+Ts#{5LSk~LbauibFJ>W7Apl`EF~nFZw!3rD^UF$b_Rq`ZmD5%UMfekJeZ z`sD{i;cQs0>ye%bLMv%P8)sodvqn?H{`D$({xHIb;RS9sf0+EMw87>A&}8BFR)=G- zdENQ7_Hq7Jp38-u1^cg8lH2dC`KiW|6iV10Kik?lq}O;=zCltYvF6*Jl6C8w{^+ojP|3n(#ryrmeUt2ul zrbN5@!AI3Y%eP)|KT}29ASfG5v-VwEeWP6@D2^?;L0dth@~p}!j?YCwOf8nQYju04 zbpYzFH2_!P!{owT2pc-SlD5%bZaOhrs^HcTB3tB-94+uwA@INo{%wbT|CnoVZ@w`u`BSMPpsUxH{FDzH`>qTsvw7dC5L?& zzX&I+Tc66qhc~FCP42CC7>%{4?Uu~RL%gD%*HY(%s3^LAn&Fb*Li$oxQ<7+|J-N*U zX9VJY^G`jPzoO?rhLlOtC1lEhFcc0{`8!jtS+o}O=6QsF$JaX~c|Ymtg}I^xN2Eri zo@@m2YJ-g}_snP;NG>6El3^(+rDLkkNRBbhWZGI~Wk?H(`=}NjuYHFC(zmxwchn-| zM+qzyB*`x%UMYSkrVKJYcqV}Qo86M4FTvy(=%fqT;;ozzYhw5{-WlqZR30eqeRyQG z_|NPBQI(V)CfLswEBJ|ws<#j-#f#=YK9M!NUhJ>5ya<%gP2x*(2i-n@lacFkFo&}I zN&iUR3{#+76O>(&Db`aQFbB|FH4<04h_&!E1@!FvqK4wShHPDFx0lr z3$uZpAQbHI)?a`-DYE%hefpXI9P^3;P#_!+0o0A+{UDMc`mxH-ObOrnNETyU z1UR$qzV>m zb-_gt(hJb9DW3n6^a%Azr4uEjYgz#*1ll;rCjeX)kWb3k>%?;yz7NiyDbIeq&I{Mx zkG!V8cxb7}7rh>qa#H=d(mIxH0h8#GFv$QSV+oV@$ZB&HY3^z8r4^JR#T181>2l!nqk>rZE6S+mN?#gC1*)5F=wL3dh%Yt8U`E6 zNB}d4o{fCw9lz2gd;vUE%(j45vj!cq)2QVy>12oK{JMPvk5NMDgR#PGy|n-GMEUuR zG;F)%R^TW?XBRRhcKP6zCDgqHE==+%naawOZWFgIJfPAUXZ*XYi74>Ew|I;Sixk+m zky@eP*eV@dTvp$BH_7YvTNjh&BSH0#xV!$0%L5E9)tmZtL@5$ivn zZtu#$ilqu-oGID@wIOt2ODRXc0)CW(z6#o7rN|=#{rS&|wmy=E25(i37#C>|f)wE5 zFsIgeiLEW2f^BamZF|lQoh<~>iP#>Ry3L$h#{3>^B<8nTjkVGf1*lo_{>W;|!QV2v zpBvdu`+yYFQR|W1-E(X+Om|9=&lmsCCq5H#8y^c3TqWmcc*l1J|I`oXbfT&LQAHz3 zBvH#?Iva-$9b3}&`ayCnif6v9=}m#~YvZ^A0U*# z^yxl@U(Jb}^pMZ7ATR;NwOb2(v!?d{1jIP@8ubfmL~+M}UKRr0#0@0G6S*AM*%P+F z`W6!lcnbXW9xy?_nqsZ#k=2nuT179GNV;O?GMH1yUJ{;&g~(FBr>KFlvmjNVbr}E8 z56nTtJM>Qw(!T|WUBTcp@AOz3psYE=DGq#T6_hoH?!2JUo+2S@-!s1ZjjVZXbw$Cw zK{(aieZvgV)j(e6!ErvkLkdXcK#9gL5;0y?3HWh~8%hBV&LH9gUGR`;`Lw-Gd02oa zbkiy%>Y~?1{WgLsuOp8@PFqC4s9#G}yiVi+`fm?^pXs*PQ>6YUvDmbOWB4Nf0G0oH z$@yQ+0$f|V@D0I;k0+|bf%;in0EzSHEgbbPG5p^Tw%CZmU|x*utNO$&-1|l8F^fb; zkB+WbKXvenPMyxa6;{d6I!VH!yh!!4d>25XJu08&zlPmFi5$zX);lpb#6K5dm=uwSLvjAuj(Gknk3Qb}f({0ZIL; zrJVHW9#=BY?%ykZkTDaV4xW*_$p)Bqq{%9J4SnyZ|hkWf(Uk-b81t z7Tf=a%J}cjY5}v3)j0<32izAJjsNZu$eR3LD5n2EhuH6fbN(J5nhF|}>r{O*P7W6r zQAGNG#`6=?A_;^;p(oGBLy~b%HY%C z7D`J6403~jbE5YOqD!6u+?k7r#|1xvY#BjZmhRQlc}EQRjRTvS-iG#%UopoQdcM=3 z^K#`#uK7^>7CaY4hpbyygg-b|^2nIFCY)gR-ThIB1wANQ$E1NcyXY+eF;ld9SQ11t z2<~b_$w+!&{miut1#!$5kjVybr4fT)PL5*3o}|ad|2{Z#g#i`?63PDusxKfB1uD2v z#W{n?PJn?sY+_ek%^h%8wSG}2Udr1Y|L7f^RC9OOa@rm1I@9h47(5HyqVuYbgTbfS z385e(ZUU_k@N7!Dr@Zk{rq-@{SWLO!tEYkk9UZOS@jr5ZStMC?FZ9mXa0M^tY%z0P zMhd>N*+kv;zytXAhxseO-G*`v1GJo)MdTbftAf(Se^PTf*e1%0wH0>@4zFQX_1A)e zXZLiT=@$%HE@=dO;e9tdbFQwvJMB~l$V#CkGp_aiYMX*H6E=LuxXcp`I(R z5=vD_@2-fr!!mo(J3inpTb_*I7KQh==E9}MEru^(%Y^n0LQ%<>o!~T>JhdU}?q0hR zk`kSYlXcwW0Y3hmi-2PQ87v=)Z9);u1MG*i-%ONiq&+*LWqz$)S5LNGHYYnnf;}^G z)bAIA(nxp#KmW(S_u9SXY%%T>mX$o7_3rp{5F0!98w}l*BD4k{m_77pO6+}QVoGP- zjH|j!E>mNi!Ql5r?0s5Uo<{zpiKIX znW9N59~V8mzXM|^b!j;9AmJ$cp)=-9VMsPbMlcNOMNRhu-^d)!MxLwe$cOF z4?(hjN3BrFA0~ns!z8Cxi{3JpE&KKdZ<5zh50&sl0_I1QAeWQl$hZ_>&$GD_A4WgY zN=Uk?8mxVBM+C=z`-k|T1t(w;0d*kY*3vegv06WSi>(1s`oS9=*I9y(P^sbHm5)n~ zHU%qi0Lp%?E0|KW=&h4~TAf2uJnw^BS_#2`Oh2p~ry>^BXQYHMsu2zI;>>^&X2?0s zuTK5$+Z+(atV8UF!bbg`YKQ2|CC2~7ubni5cUNwEJ9>%lcIz-ak1qqCD9pWE;J^9j zg|M2JEK12+o5!Q&iNCF^sG$#7bX5*JwVIwx_RhQjdY4{t!FudyE%25h4(Q{8HxhvG z%3E0EZ0sICwZ+j|Fn0e2)}abS|mHZ)NTXN`$qBXfzJC0yW6}H z#?16#XAoK&5}UmbeT?|Hj_=u@gJ0cuW|}M!s(pZ|r>h{rARByT0aHOjAg{&z3bsK> z*jA3qD}P7+6lPMVxHo)GR^>vsZ5+-&^K4Y#UjNmhGnf^hrSy-|-*(kB_zZY=0lxE2t(}s{6c%u9gOKo6`G|Q# zRXveUe?&d^EU;n&fvOvdsrw6455RkfW6BId2^;_x@o``tL)b;!Lr_q3nZNcmzo>7z=m{hn;E=Iv zHMu&s(wQ{FxNS1dzXBS1MM#1gEL5hE9e-|~EF~RO$2ZZ02eOg4RDrNPp)2Z|s|J^< zDLzDi{ReJ2e|| zsf#}T%VrdAd6>TXaJR7zM(=-qguyxeUrYKSFr*JDwm{UpH~ioB>HmIXM^@87D5sCI zvaM-5EpT{|P&+|o!97Y324UbPuT*~@k7q4Fsq3o-xDNQC>uyGOvzaE^3D(W8D9nKr zH(l2F*?d(*KWf;?2?-o`%7$)~gkKw7pfrd6BT{x3PDr-LE+`1w^#53=hBr!Zu)?Jv z;_p1fG>r%XLX7)ks=8C8lsAGeAf)mS26%>(UbR4!aKTxZwX&r2mORuE$>5%zZ*HmL5NTsg6u}L(*(&( z;`lNooE)rZ6-h@q-ubfcW)l5H4ufC^W9v(v0y*0rfRDb4ONMg1^kZF4BALYeeaH7< zRd4W+v>@P=R_n`w^f*)N@?+wQr;zL^Qcwa#8H91w*DvW>3kbvV3lh7c-PW1~W{@@l zuceP_$+0D}E4uQHxILGA0NTNB+PWP9S)f+HeUu=yN- z%o09!1BhBSz)%>V#os8K@{0v1;ptP1g!eU-K{4{GL~+Pvnp*uMir_%%95y7t*6~in zd=Mo_-#CqHy#uW4p)j`tN5cNxdG@V~ZcDdcu66gv%0V7O?7tiZy#mE)))x}HV5Ss4 zx<6i3Trfkx2y;K8R593N9SL64S81PA@s&|S>! zB^>fLr=b^Ly*`pIX0NO4gN2(H-$+Udy#^)|fH3@;)CQkXShQ?}EKj^A13o>#`B!!P zt%`8};5FMEHMkh(#}K@0^i!thVU21#Q-M8~@Xyy0YE zY#`fzXlJfC;I4IP8vQJyPgWd|^%(exr%k#zJT)TU7KlQI|3GUyp+MueKHQvI_!TI4 zjkP8bixI$_wc)3*X9l<(NalXpS#dUgKn*Ne`RQzvOdq3Op~VL=7RkMxAkC|P*r-N` z-B+I-f1=nB!~!%G1EWG3-nfSnU%Uapb<8iS;SGv~U|NMb)KHc|GSUox`6(HQ<+i?) zc4}{4DgX-a8pC@fskEt}Evb5{nt2Tb;<5h!1R9SdUIA~ z4rMeh!2jGA83J-!E!y%gAy4Z=g(ZN3Wze7m?Aw@2?tU+$kLQx2oB@!T#uqL*RfsrI z20(ep5&xwM*9`f*&C>C1Lq(q&XDBIZ#lKSX6mv^xYrWiZH;z~H3g7}Av2!3D+ zR-o{K`2;NQM_qg6ssfQw$YMIYwvV6;%Bul;A+)8>q)&b>;JjEACb1fA<9aOxkmlG) zXMCWl=ZTg?uart3a-7`hrR{VIl$dGmzUR5rH*@k7`nX$j+`B#Irv7V(h<3>B;NURe zBOb^)rLsCUrss$;G)LKXmI}o5W?Fn36Y+vIzLfoi^?KP*6JrtVil3m{|6Oa55IGJa zqyOepZhK8>qQ#-W{h|B3`;#64-(OXDVjn1b=dPC{o&~m&)6#8AlkOJpxekR^d5v}e zUIf;5pw_+(WB$Qwxzo??`5f%yY*qZ&d7-?)#`q)lSCE9w)S`dCbblbl{#`)Q0hnf2 zO3mC--W5JidZ5CocTk82)JH&egI8$HA}F4F;B8wizDh)XH8+ zA)UO6tCd4fV&{%^u)X!BU*sJ9i!HshUc_uZI&c;$p!G zu5`->84>`H&w#oSBI8y}#At8PGxx1vg6>vg`0^S0y)R$FUErIwvw6_FCV2KdaK3Imy-vKTYF51BMP!vU0k^|T_B&!}{mWuj zlz}uJTcj&)NK1}Ul_qzL?! z(`(Zq#!@)$JU-{J-tewPeK4gyBw7$jRO~P7QIjpjwtl8&|e+Z z*9-om2zXqV{RX+IIc{`)QQuC|0mB|#*u$7E2F2|F#=s4NtcAO@%xJ?UmEmVMuTKch zmK3`XzJ#ET%2g4%{46UBO`&uOKM#<|*G`Fs?St2_w5F%bz0bu;D`pqCR#2ze@~)Q= z9l^IRZPRKX?XW>w^f&h(4co2boM~O7?yo}=pU;``LN`CZ;17cm-1HIi&@^h?Rhju* z_`1}c*36XrVpmei&srJT-7hA&F(~<%R11 zP7Hnuay_aAIK4NvmB_~yA#jQ1U0OkF3+hI^I55%oGC>%z30!0-T|D;8T5`PCcZq18 zE`p#|&X%OG%My~&{r`Ei|D)o7Vz;({Z49Ue0A1>U#3U5MupG*oGXZ)e;`>7-kTg8B zbCg1*04;m%#G&}c7#aMLf;$~=w8OYFvCf@SkS$ZEG?(50T${B9P-pqgh0Rfd(GT(` zwW?F1ETDqYyHF_W>4SyuZds?FJA;~~B?vi)Z<~A9hk1n-U~U-`HU}d~_DqIR^rp2qNEcgYEkN**+y=&zD_y9ORU_n%L<22RiQOC zRx{~o&~FeOH`NLTS)(a)LV%Gx-Xf%fN&Zn`M_RVfWMgXg%(^WwhR52@LKh|SGdoBS?s1~3hcrAIDmb_w zivV#K;Qimg;ghI6ITlJz{aDYrjc2l^m1`q2pzq9Pw@ivS!r!4C$J|#q7rgHG_k_Au zE6YE%5R?z$RT^ab4HUeAzIXx!H8nt_>JRkLvRwhirQnuoAkpO2xkM#&Req)S;!=h^ z9D!U~?nss47FR1pk*GNqUZQYc1;wS8N&?^LKBjaJaRh{0~0SzMOf*jzbTLK7oAdGH| zL)Tiid^%nI|M%P>PdmA5uAkvsx{kRs*~o5>?=2>`|u5hi&&mb_{(jm&gYFA zEGOeR+Y#192FV6us&CFc7A>(|UuuVww`-L;kD1_<-dUr^BlpNM-~09H?qJ0>#WA|@ z34R^o>qb-mhadFj@ayAVFZbL&+X)|EzeDfB!0yiun$&=gipmmDl_YVN&2fe@F9O_W z`A#Hgx$ta>!`V+@Y`~a_(o%C>7Cqyv&H~y8K;&GlWALbnh9UBbEx!RcukqkYlVQ@Q z{P}Q@SAmwo`&T=;n+zX_9uD5FHlerW5raAzKv?MkEX4uj5jip5o-TKPxp8b z=yBRJARFKX5hkX%2%?wui_E;cLE9ZD{uAK>iZEPNpgF4avau8Ktn)VG<6(6PX~Rr%Y53ywb|8X(lI z-SIXi`wilsv4{H3G6M!YH{r%`2f{{AK=u$==b7{UxxKYv6CT+79!AHE2>sGA{osqi z4)ercK`t9|QHej!vM(3@*| z>}wP@?m1jJ0Y6uE>OSuI2ha$#N*^jD-YP5tr>r)tj?}y)AjAj}+^ikeuY|FHZ;%Td zI$OU~2hw}Z1<;HTTl}w&8ekq>V-1|VS^m)M&yPyZxNbILo@IqEYxihu0Yra2zO#R^uBiNvTM9?bWhSI*Yh@pK@>juEiAq}wqe4L1- z)80Y0Lm6~mfJFT+_gC^jnV0rxZU2A;?<&lH%_ee!1WJG3m*`Da?99>wy^$8E*s8sp zEZZwT58sUoo~@^6lpy3`_Iiiw2I#W{mh7g117$N_6Yap=Sef1Z$Jj4d2TM zD5Nz}%1%z#0rkx9OWGZk-tp`fhiZ-K96{HF(MyuxJ~2fAL3KiqAa1}6UKw>O}WNHCXB;_(XWdmkK|9?`F3OS+v}liLazCN(aj3q z^(95ep4t8qVtGhh+8ep`jp!T?M6XCkNibn(fdvDKt0w2}On%txHpTf98(nZa z)B}E#o)tL8Dd31MzGn-#(_q25omWELz^SHER_FS>9k=O(cX?V+0^>H@OgQK_-Nl8) z7o_ryWm&5?yUtYT?mvH%vO;)8rfp!@sGfsCpa;w_S-iy?G$2unKjc_1hf#m3Zr{^D zgg+-WVht;oyDx-TAGBQh>?OPVs0A_kFxUC-4pxOuGY?jN#pVqbY6W@j-dpH(l{wdQ z?AfoH@}^#AW-od0b*_=dg-%%~Jj@S&ce-3E_zszw&>HKY7-W9>vqfxXj*5n1mEBCv!b-B!50XZSi?2fBaiRe~3w=U`}Y9 zyV6V)%9A4cFY}Cya!0=VTT{9n%wWtbckt-7KF2SW&cVOh`jXnvW_rUeRlV&>tB5C%XZ&4 z78hq1=e|;Q>*uc{!zP&qvd3)he%ZMH+Wzg1Z0Sx~E= zdsvX1e!1Nv51&LC`k^L?oS@3Zb-I7fux;?P-|+m@BZR-tl#Kj!1(lrTRKEVpGclZA zH$-{=VpmzGKC+BU8g32;h33n%^|RFNak9o#}zk5)$rN<~evQ z+)C;5Q@t5qlM8vioenYs10^q~FHiER@0zZ-xU8Xv*==-B)C|3{tGg*h86`lq!1%e% zeeE^nL3^w(#KnRCuue-2TB>f$t=J6?sQIssb9g5_q>)Yvp*Lqc4M6~A{ZVtJFLI?< zK99P?SQ{3o`o7G1eRuu3sQc7J`%$XO0kpXh;+%dVoUrb+KWIy4s7S0M+r;GU}~QGWy{J^~-K= zuFIsPI1`aa+pMgl^`=l4tPO4?EtjY@fq7))7r z=CQ9txJ8A*T2mCAUyB-+KP7||rJ%}eI~-bNi)OouNoi4b&zE0(zFXH6WL)Tb+Oa+4 zH2G+&s*6YU>lsc=zpho-GeP0&VW@}Wu+mesq0dORyf#%ARs}EGEn&Ad+@p^q%S*pd zmla9Znb5h@F1uC2c>mJ+AjrxwA7z_f8fUe~?7)GB>5?eq)x1J;V%m2c=S+65TK-bL z?#gSnFO2K)qwm5_VrF*Dq@VS|Tv=szpxCt9&%qIP6QnK#_4d+pd1+to+p}yL_1TEi zPPH<4;Lq9+g<^msJBD}8ASkQa^{)R!9c}AFBZsRUFK`c$j^&4_NSc5LPzv~*qWIdAq5?==+V;+0c&DNn^!C$Y(1OD6W!$DZ?V`xrB&AAj6? zOZ==luR=^HSLBV@OENO?-yclnpe`*C$$ z9e|{BkL90fN30EguC9EV8!WFR zl3$dyxBo=l6|Zx-griL=5j3ZdM!Pv0P_P?mfBT027}Igqd@g_5{kjhIdk8sC?ouXt z&r|>53fXTgFi-XyMK`__^zptCuH}8P|F6s42}5V8ZQ*D#9-+ZsTkf*$biBj|`Gqbt8fz}EP3qH48R{EFx)kY~g_OC** zDGp0i(d8G9kZ9d8%2NEe`pr{+eO_7{7*iogb{r=N%~q zF!<$kG-_ef>I*{IQn{l@3egdCN^^PT^WcN=BW9oOTz*EPZIBVmbG?<9y99gE&wz%o z#r*pSnreLIw+4>$-OPENn2q{k>hlaNp9=V0|LDzo3)0fPP7N0$;7qK(=`*z}|volH^k zXQ5P1vTDD6+QTJ&VP@y#b8~h6joXTs@=v^9pHW`iVohBk?R#=CXNrfmL`GB{C0A01F_koYC1n^xqc)() z81R4E28p(rLR%xyD0wsrkwzJVq1PLN(CCFHr!~El_*mHro7D3jI83 z{o}Y?v|`*reGY0fQ`4JR7*%!{p+Lm1izC(KHz#iUC=_+tAz%5A7GQ7O-jPXjT}^rU zQKQiERp3vzpqJq$B|B>_o&RLJpkbbGvcKG?81A6|A+WpY;H_Gw9$rDsu!cYu1C4bi z($4J4|8G@XeP0Ei)-CdwsPy#o^!&TKOk4gQXk`9h|6u*Se=9kX@)>}@)78&qol`;+ E0L-}mLjV8( literal 0 HcmV?d00001 diff --git a/docs/images/GPUfit_PassmarkG3D_relative_performance.png b/docs/images/GPUfit_PassmarkG3D_relative_performance.png new file mode 100644 index 0000000000000000000000000000000000000000..8f2e17ea9470cf96a562c927ef51b159547984e8 GIT binary patch literal 53574 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%0AIBg&^v;6YZP2JNq1S<%M@6;HjV)S0M6P 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 literal 0 HcmV?d00001 diff --git a/docs/images/gpufit_program_flow_skeleton_v2.png b/docs/images/gpufit_program_flow_skeleton_v2.png new file mode 100644 index 0000000000000000000000000000000000000000..d454681e443fa3bf511bd8c6bde102aceb533a15 GIT binary patch literal 83227 zcmb@ucRbbo|37{jxFoqssSpjaY1q3GA!Kioy=8A2l7#HC_c}JmUZw249XpPF$liP( zhwJ@*f4=X}_mAHnzizinUA(;3^Z9%{?(2DcUPy}(pCdnqKp=?4pNYsJ5NBc#2)yaP zPQ!OzKL33e{vxz`rfP#gT)l+*@04L|7aWDSZu?ZlR^HOU)-vZtQ<+{G^x6@PKcukYIH`#q9xKbXtCQJ~l>>@;(x~dwqsE_h$bHX2W$+mul?WPMd>4_46N*E9GNuaR~`O@PwI^RE)%BWZIjX znV6YF^i@^AtyPM-C3_+gzrVak?q^|Pu|IsOhCx8M5Kk?&tE($1X(}lx$@eN-QPGy* z>)${B{K@_Dob5@@lh3&> zyLrs>#ky&Ii4I=t{1$MySALX_Mt0A=($#fxCBmKC{}vmYWmi%oneWr`6*}`HyWy`d zgN>c2-&roqeaa%VG~21AP|kKWK?yK3o7MYchZ%9#eYsr9dCw}O_vU-;_s^vV@*8W{ zCi({H_T)vEFy$n*6sjDM= zdfW_7i5lZAGM>cWOB4|(<+e`m zExWT~b5$SqT3bI@qdB=fu^~E-6(Zgw_ZaQ%%uNc)YrzQIv|thDBi9+;&a-`1Nld}} zoW;=4u>02WxiSk07vp0OTc`wul{6H(zSOpsIS;68 ztBY(>rJs18-)Uc6y&7JnV2;ZBPD+!pxit7ht8{uzx6V(wNW1#*sr#WY zsr-C*UeN?o~nztd*Dwnl$=tu<-g;wZPpt~$Q#SQ7AI>58p%wyn0t$R|fd zN1L;gm)0X{+E#kVtf=($^-W55QpMRr_-o#wzi3-{-%ENZpec(M)zz8%uAtGVT4&olBxSE-Mamw!El)V=Z@^ z7<5kcgo3@jNA;=nJvrC1t?h9m15__f=ZAJeKAX+VC+mi)stjCLaKln~{@wSuupEu5 zt`@^^+en*Lm992DhND$wHs!3BZLPuMeCG2lrv0qTqru!xL6sgPe=x{c#i3#ZZeA$q zOfSv}j*c`pHzSwirUkCC8^z^2FZ8hxIBQ#2d@jdB5Y(I}^j!P1x++)A#WeY@1(>_e*kzpz2 zw9{_t((hd8%h_02s0;Z0vnONM4yOs=A|oT(Y>QeYU>-hR{m9od0Ky%M=unu?WutT_`U6 z!^6Y7n^Z#>Zb|G1R5fc7W zu=GWqK26v6G2$<&)V_b;v^QTmm578S@uZsRzkmNcL^vf|aBcB4l)EXmH(Om}z5CIB zi=Pxs-`6%a-T3)7u~CDuyB9{}#JUYfT|}8YZi$FqI=Q!}-=5#CJRIc;e%cbNEVs`w zB8R-1s9gq&#!m6@@%R}%#JI|K6MhX(a%!qf%zjcCy1+1Ye_x~Q*N4e(bfN(_va(l+ z;h>L>CPdt+U%q_tA*IVeLX+d&U3qQm>!-nYi(R2yf2Qq*EfH?U5BT2TD>9`!&FB96 zNtd})sr}Nv;qhP>n+@bbtM7P-7`M9i1;JfVQBhG-BMU2aKCC7%U>O}cJF#;wI&m6J z7$1+;pdcf=>v08+#x>A@Ir|#VH)s~kGXDI@EH0?}T|8`})rGHZGVLYDs@wvoh3Zma z@jtqM^OTh}{=110hq2B!+r3_8?ax$v&WFP@YHNjr^$%sXcQ@(!PkRPWq_cD|D%|*X z)4gj`J5?t7aK7xT5{IA;002vivG@Z<&8I3VDrSrL)+e3W^T*@nB=KNYtqK*p{NUi{db4fXIyBZgOb@2MA@ut8f?gY!$B}yPxx3xxf*XHIojyhlG}GA}>+dow z$Ex@omR<^;MOa<F)&WTMKm8@t${;+g3f+}(AL!`(?-AyeZt|0E+L zWR$0(HsrEARBWlBpy0^8Lxr2vQIc9(TF%bSN=ik^K?PM(9v&WSY;3x;Y-|ET4mgY? zeCMz8l%c`Fi6H?unnN;i^MxEw*ZdR$_d)?S!a3kiCmDQ!qY=2?3IBuvgs-sR|8>}Z z4nVME5Njgh#Nob}!yaS9y1L|0W}Gvsy;kqcBRU@I_D;^{uS~Og zZ{Km!BRWQV?`=)X-%6(lE=4S z|5A7J%<(%B&^&v5lJofQ;!{*>zOEf#W&CAEG~Q>P>f;moUo%I3!}}}?Zv>uPOsW{I z7%hGE(t|*YQ@9tx9n;Y8%%=dI)EuRKe1td6BPG6k&Ib=Z0i<9LBVc4?oW(=5rssD2 zo}9en1q+0V`w#1l(+DFR?&%n=s90}ee%Z2vcYGs`mrORCD?5#nm=FlRkEaMd)ehv1KuAu+vABiPT)(%yt%nV%O+I)y=fe+ zCeD$bNlELij*aHHlQ7R}V-a)oQMSoGJAIFq@&2;9W&`tfF@14)x zMR6nM$%{cpmtW#@m}j?b_>%COLNfemKRGSt?{p?!dgO_>XaqPKzs~s zd5J?3{0=3wWlwdRUhJq zHxVGu5Rg2V9u;sl`{MV6=9~~`kI>B2ee*E4m0w22G=i)X>))bgVx4r>7AW+Uo3bmF zbKFxDA|LWS9;CnfnM5GybxBDYjRCd^kBz@S*)+WV%scyK@l148&&u4W?ypaw-WO6= zYH^SeF>vWh&nH?W7dg$3J5MFi5ChuERhbbHht1&DmQ40 z$ySoL$?BLJHY;W`KeQ630#L!GS=uCX4mZVK@Yz_5ZWi0S%d4q{H%T}~vok1+ykhtN zg4tNmZdh#@EO4$2nCdhM;9$|tQfRKWO;6b*5@h>=NqNt*DpxdIB|lmf%_IZ_6%afN zjYVBuJ<7D-{g3AxPO22OPQkIP<^rvW87^+2qqjMhLSv(q<_hwciXMDY-uMzpA;fU< zdvcp8yN(WAkwW(AlmiiEY`^T?X!WU@`Li!@I`Y|7Gnd=Bh=fJ+*)?&*=5Pv2W{><) zi+XUV1CUKMtH>=mdVKso`ulkVqU7F_(}*FOW;JHKdEK7h3BT1yb^_uZVpFMjg5oV? zKhU427JMG|LC~#gZ`0o%KQBbnMbrIS_Me_YhNimwd~yOIdhGsOi>f;>0>K2!_oc6) z7>V|nJ2h3i!s#?)#phYxEl~moDB{<~-d$uYDvN7r0aNH6H-_)JbxgsG75TsW`5b61 zBnGSjsD(F){Q$Te_ynJr+|hx|E1Av9tCiRGPk`8i3#K{o*mu~rC;_H7CF5&vTXQT! zB!fgX7!57I=o=WMgpW$@F|Z$3=!})}jRgZSk@xL{!TB^jWt7(I4eO2G#08zm>#6B$vC6)Dnl_ zbn45Of5Q0x5q|mFruxfBuylGL0DbM)rx&> z6~iPt`Io-*a2b0VWy1Mllu5@4`s(t}31t}^q(gFcshp73ayia#N?)JfUAli<^FA02 zox>HIi9h!rK%M!27m(vN^uG$xzq?gbGgOY%-EpIo*s+(E%UW__e!rg59^MgcoPQ5U zuCFgVqKVb%TUbutQV^M*Z$+U?Z`f596*D^%C=}?BC}o%1$qYOqgR~sUe^ZCY2`CjM zEux!c{zj<9L$UNUiRN+H+>Bl=d}0i%L!ZM=`%}JbSaxoPUdlP31}i7*%p0NfnQYN#&xTancs1vbhFJ>l|ZBSo0_SivL( zrAU+h?1S-BXR7xXhibD6jk4+>D>{G6P-yQu zuqGovMg+Gn^+B57u8E`RNZCe+J((bNvZmWBjgR@fuN+^NZ}Ot@zu%kH31~wrrsstC zT3ludFXVFy$kgJtR8k5J)6b!#-^hvx!Zu|n1UE5hsV19CRA!juhzQu7X^mXVM8_hP zbEfgnAol7#&muUw7Th?it7I8SZafePv#i?qkiWaES?<1ty`i7xxq5G7rRL^F&BXu# zgS^W|1eeYRTovvPPd2t)8AG1`D$Yz)|1De-0$*`CQa%_ zBR4afYG{iyR0}nV>nj7gJ3Ci*_lU>E&LFY|&s@23W&QLxjYme&PM+G~jL=c%EcWWv zP$Qx12B-`(Cq6q5?+Z&x0n*66%upUKHoiT*94!H{0OAZud4}>$;JPk5%FVqw?nWg$ zHnEb=N3%nuQm)($_Hj9nSUga^1yIpH^MQWvM+WsCOS}Tj0wwnY;?)cn`Sf(lVi>wR z(`pFMJHK`H5K-TKqL@X2Dglh3jN##GpPcXyraO3>jqp_RKSc=N)Ap!EnTF31AP^uo z{R^4?576=d5y1WnoBXQ+os^cv7Zha(D~YukK`}r@v4V$RrH?T~Soqh9X!vI#=lN%d zC~xW1MAC>%%Mk!9a5*DE0HC&~-H874CMiK&1QAJ%*~{KN8qgVNgbdzZUQSMPSfPIM z^Y|9`aKGq@0?xz3!y~aXkoP>X^Vu;hhpF`c%Tu{7pHOMxZoq{B0b@xc)+@h%0Z>mK zF1FO>;^MLk4b51 z+>PgI2L{U8HKTNZcKn0Xt7&qjqqy_>Ozb>LfIL9=_x1VosZcW>h>mDtp#Ski9pLlH za;_XT*z`*X2Wk7~c|t^lxSZT-h0|KbNOJd_#Yma>)l&%akFY8t#Gf^Y_~iC3W!RMf zC4up>|5^7R?-d)H=x&H$R6yie}uwzsLM zTw8lx|Aa%R%wH~Yu8gs~dHm?y_k0Qfl254}A6_djIxH%>dg=i}Soil(17apHI{Luw#>W%}n1zdod3$+x(-8;8gcYrNU;K+V+CL9?4V?ISetv%8TCrp{$F=jel21g$ z*p!;%`J%6sy984#5yPvZ|ELA$1J1{vL_fh~On&0*E@)8#ppP%oB zQa5u|Lqp-!jB<(1j5Lb!(xu-R%rA3%-1=>OwAWxi)!=zww_Xj?07By;omAa1_Wu>r zZ4QfK^9El}?=Ro0Ho*T73FZ$w-963vGF6pa#-vAL%h)i#lG!-sIDi*WCbX70PLg=M zXm+j?ym`|TstUZ<$S1E}LAm=@#%nh(uXH6Qf>R&e^G!#mAYzI9_v9t~3sm&YqGkmE zTF_Cz5x(uMZDNP`NSFOYUxomuHTuioS&0x@tO%)`WinP(sfgF^PgTA}(;!%_13{Yeq>mVe}Lo{ztt{5 zve4B8Jm2ILpd$H_Ve=nVkfJ|vctn-QME(aELxbG>vq{CAcyxDq`BYFyrNq0(|!%j%vXb z0^E8jOf*Bb?dU)G^L6E+p^S19H0T}!DZ+3dOAHvNwV8(1-3>N!>%bB?Du8veMLM< zwdoG42F`Hy1+B9QhlFdz*R6Es%5|-IG6x3-cFErn!`0nqAxREtF%a6(mE-YlXYXiB)t(T{I@h7HPS#PI9hB|{GgM62GppW zSfTdQ6{h9kQa46E_3Ost*pX&Yc?K7Gw~F$}R_byE!3C+2@UD;jrIuquCaS63Penwm zU?qBfm`pdvLVaK+^DWz5v}xy1YNpk`n%;e(q6)JVZf&=)`H|UWW1gWIU`mzDxAIM3 zS9Zo71&Lxq1Vb3@vLp+qtr{1Tovu(M6^Vm_SO@y1s#4qRQ27=K@|WLGayduh#cNQB z5#(SPnWi2f1}jQ!1o-*Mkr?2v4*^vJa^YX^_I@=vIn3us|B&i46$K3jIJ^EH=xL(o zE~)e;^4ga0xpF>yxYn1YzO=NY6nma1`fp&0`hA~2K6-~Qk=@D>tL#7wz}0@_hVebu zf$3JlOIjPw)aG{uk5%orBJ;qq);F+Q6D!Zfd-MAX8B;Fqb(^%YQ4OAkA^#yiq;{E% zEG0D+iL{w+G5%Rct15A6q|Ce-xc?B!v|4gaoFkceh_+v1bHlMDvCI_od7ypN0JL0s z>B=+4EVr|g9LpznpUl!chDD8=f37klTAa6bfXK#tY{qzqAP;R;-hEFNvp3wKCko2ZP^!Z04 z3Fqx9?Xjx_Vh@iti>uQ~-y4HH!_Q$Z(K~nf{evK#^?^8(oJT_`y&Uu;uaE-KJY37| z9#u&rv{ySo@{eefBY{rk?(RN64`S6e!Iuvy9>r~|2Lk)*=AX1m{?dg zz-E{c9VlMLGRUWJDovUcnk#CO7dWGMx!!`X8`(N+AfgW1L>x#;-@|_X6oE(j{(W*b z_yauyLucF0FMC6fU8ij0JW`(|z2VXTr+8#5EN9tzFJPYxY~j)f)5XL?_5aWr@U zU1r-F!C~@EtSCzfHCES8FUTF?w6hvo<`Kz`sM)!9GhS4RYesn?Qm7!I^y-|NXrb&pFdUrmUe$iS45Wyfz9D7shxo`uwGrt8Y%)QD~$)`j!gr>A{z{` ziR(R4k#wmSs2;jQ9sB-WcZyt3F*_B#a2CORAD>1+X0Npj#_!sBuq$t^uC9&>SP5%l z0<$!oOwb|Vfe{i#Dw;|q`}Zg-c@vXZb+)X^ZrLk5OqJQ2ULIq2b$txQUlkt@k0J4* z`{7!-c9J2xf7&K5$Yeu($xujGSfn)F=EB)J>4-zJ=VTH))1uL2EYd;xH^kfnz%D<}iD*TbR2;l*^uV$0B9GzMmRM6p=xk`Fxfa0XVsk+A!9sH-k$<_ZV zQ@G$4S~-6UDng@VdP;>}4%)x)CbDTWLUdMQ(qxac&f6qXt}5hbvbARpPLZoKA()mR zio8_x>`|DK@+-UOre8SwGRt@KjGnBltX=Gyq;hDnPs>o?(I+n20n6Y(?Vf6oMdCtmUGI}kdUJEs7B}apTlZ+SMB)*gs4dbJ* z7hCtOz!0NoSFX_>|IS0f8k@u*a|p|^s|}X%!gk)n4GCO7Q)-1bFxPF^b1=}5M(Y&o zj(&TUnmW8A+4Sm$yr5sGDT>;-TD#bCtgmh7X?CUdRW>b34o0J;VJGwsZA1i3^MCh1 zMLbXe2Qe#FVou_ZoSw3}KFa#D7P~bHLRDd|i%bIZhI4F&p9+sHx0G=zta@{5vn>$l zn(MR==P~wJG!hH$MDT#QUEHFaG>asXDX&c0_zfSaDcz>(w}X|&v>G%hI^niR4e~Q z{LOVhq}f--N2C#+xwOOpi$p>~PI=j(EeG_ON|(*tT>(uj-Otc~Z_Uf@m^%}4NCTmCZ~F1O*c7W$}>BfFS?_~tXhEO3vRYzPHG0ED+Wrq8TPBG zkAzh1mxfCRKYZdP9-_W6RA_92*?vFp=g&Qta_dqQKh=^-_@0Q1WV2Ms1ggZvl7WU ze|8Xxv~0>G&U_zs4_`rAmA={o5-t>`5Gi zUSn=9Vtnd9ro^kiVBVbBa;LTY6rgx+ZMt37-4IE@7^=xwJ=$1bUcPIlxM4rSFvcqCeC3u|2ZhfSCBVUF+9*8%28Mat=Caj+W3-*Nz+-K z;JZ>5(`$zo2fV7#jg$^M^3fZ6#MK)cSi17503-m9JB}^eYU<@SGn-kFw3qZk`$1^5 zNsk(k^bhS#Zcf0^(sdmUJ*&+jMU3k?nz*o`GcdhKrCztT9Fu6iEgxn&sdI|O^ojU6 z+DDH^eh+ratG^I@mYvinMW`NPscfA-k%*DllaY#)!w!y&jG&OPfKTpx1yGjHjdr26 z$2Ww&C${Zu)Kv4oSgB4Lx|`a+keG0rgozMlS0Z@9LxN7E%%+EhmM zLVb*7>JTP*zS|9H-!dH_Z;uVl=M7Nl-OSM}cU^9eUzNjtd5vm|uQkrsJd9fC_^vsm zAY~appV4cFn|{)5P~5+Cbch-&Nqth%rwC!^v2FMpTj#y_@<-Ho5 zoBvsyodA*ZhJ!kUC?-}~_M8J`JBdPN15h!hYbjDDgGTH@rjc)}t34h)dSqtCTngIB z6&drRcKV^c=rzgS{*L;m54r6C;^vrtl;jI3GWz~1R?Lm2U*dL%hRVWoJh|`v#j&e8 zSM{sBT3Vii+}T@SmWB2JBH&T^IF;}8cN=+TgwOFTo+9Z-z|$jZY|L^Q;?agP{fD_uPz`E75YT%!Y|xV~ zjV=!2{w31dh`z4kd&f8657CA zLoQ1h$;igm@4kn$wZtR8$;2dS!HD57Y#X+$+Nra5L*-Y@R(^=&no&gwDd%WOc|+{P z6f0wn3NWMKp6P$@BZIRGHds7fRNw6ZH1!De?r)O=_F`?1Cz@4_>GL(7YIHLh#>{X0 zBr`Tl6vg~2a@^m9NvBQ*nkW9*+6wUXHQ!F9IEAos&f-NI=ZMK_l3i} z-pv`%f{bVOf1lAKHcS%{!pT8dUcFJ;@+r@a9V0o2g^Xwm&xAjD{1|7gBiIZ2_;RwK ztv3Xo+K5~uVw~Q`DS1O=i9q@?KltT)Nrv)NpFVwh78Di`mM-}C_~0TS^F8S@ubJ3? zLJC4TTP-;|yDJ0~qK`#Vl9824IcmLCZY~yHAAo$A|A+biLs)r(JO3gsa@n%L{lqUV zw<9PfM*8GF1MNf16I*tAFSnFBfoR=V$OT~GUE;S5Q&s=aI*Jv!hyGOhZ8>*eU)isMDn=8?Hr1Ah)oWQF z;~f*H5#>p63_f4ys=Am10QSIyho3)+r!gL}_2r zDz+H9#BZ?wq5t=^!VY=H6(*(8Qky-DHcNPTCX{kceano^0kxsndn-QbT#`#%fA>$J zQKo2~>f5((ZwF-fn4t>?hlc0`-AOf1A%vyW`jt6D!oo~a3)~KOX0U&57Zk`eJ)ozj z2Z^J%GCW%Y7^`jzI&`es%TViu!$ok%>vY%+^>=PVTsCCOttQa=Xf6&?(8u={D{lY`=R~&$G*i-rp5<8d;VFSXHvS z)B7QI1Q@mGAZGxMzLH;IH}2BIA3u%g$VkkxZ_DAiXrl&yJtZP@aoGwAj~yG|E;*|| z3EtibVaS){s}XL>PsT+S83-8JjKwx1!g8qok<3E;y8O{`a#AC=cQ+Os77WBIg+8X_ zN38jF{?$4+{{7t<{PQ1ud~^`3u$-0HZ+?d^U>NI?jmKv2W4q(db#FJfJv;31+~is; zvnCk}1=3%5do7j?h|z$N*sG(?Vq2bf-SVqc&^&5CZ5x}9^@2y+?KhWrZGou=8m zN5oE7VF)O<0?l8Jr@;drPSFXiY;Ia~*Hs<*=H5_mZfbhm9KNOLYr7W7_W^>Ic~ZFt_TWEtFO; z+LmdC#=~J_u4_`L#S$BL%_`SlJWGF~qM~MJY4@`(AdE0-l7+qg`8RkTyd3y5!Qyz| zuq~!{!L9HPahaXVPmFizMe@#;x$f09Hb+N$*}6&21PFIIkWju*zTpsJ-X0?mHNM%m zumROZ$bJ9X5g1T~)>*GI%^{elrWR3V;%(#3@J^dT6Xs{vYFpA2@VJWDz?IJQ%sOZL zNT9XeF>6Tkp&}C=W@hG!CXpX3&1P*dH`B4ace_79Jf~{-8p5;hk~t|UDHg^YASEWE z-LyCg2Vnf_F8aA=Lxglyt?*@D(vWihehSq?FGWQ;x!)l+?v0fbD#5?N$hgnRsl*z2 z6nF{|XLRMvrNS{`vMX15^KIM-pTr8{;9T?{$tmr!snPGXn|gl{-|{H}|GsKxVsg_m zb@aew7&NH998|6MQCirBvjFh2>MFNAD!wqM@tSg+(@+?7lx_}>3$q_+XW=$&XBodi;F&hw{G_TF(x^vX+$yr%! z_{$Yyy;gxwlrPv707f=G<+=N zah|EvY7h5=l30j}SzAcfuKL-oZsuWX2ufwI<^DVSHnzmh*_jzJ4^D<1-NpSKnfXJb z3UI7|-zSYl%o;wTgRX2def<&`F@U>}`qe4lvzdK9W!K9BQd>EdZ!oTuzOfS_q2b!o-Xnh}Cntl> z3uX?VL{5CxNst`IQF5>T22m;#p3{9?>G@2!exAV`tF-v#vgq@A%CZf@O0k0T80po zM^0WliQrXPpN7%Edsz<|yq|q_5;e!JU11W)Y(rPpodvWFPCUAgMih4^dPVU>L5;ck13h@ zdBZ@^18MKr+7^C@+wKt6V(>7!LwL^Em`OP+ff{fcQJmNH^Y~yXw#6R32YCj+z`UMPvOj^uI}zR4eqetzhB|Lm0->RMSfO;n~a*;^54f4PMRJZ9Gp4* zd7WcXUMD^|H}{1#w#a;F_89%3zG#>N*fvFE{7$$8RDS&oBJTD5ovWT?4Jgerd!GQw zIHu#<&7wcszbtHHK~+hByZhWbfaO6pDjQ2YvH2S0b#McC@@)RPAo@Zg=(xU@<$?P* zvLy5>WOHmCth&@iGzE3|O}s&--ViMCeSJkfGvPtGjn7>X)|rK1WSXHQIT>d{!$SM? z5;MbGHEGxw8uBFcH^?(twf6crY0U!PGAH|M$Yd>Fz|G?EG@60rCCRzr^x%pji=l+G zBI53Fb4fQOQeiTYM&ZF#%f3N>qpqTNxY}l|fp& zkY0-s=O)P!CPRgQjI#cD0TWZSqXVbe*kKqtmPt8#>NqtduO4T|18GCmDrjd-^DTqj zx_#LpKLATXY%nupNPh!dq4_tscpS%5&raZq6pUV{U0FJ1q3(-foys!-Izp3!q8&oJ}utORa#tEAttKVlv2cFb{xy ze^CaNr6@-`+pzQPF9}S>9cmNaB-}-CrGZgN=DE3$u%h@EyXEn~(C9lDN#*R#Es7T$ zs-tD9342`q5o&+dC@Mf#Q+tvni7{*jPP;f9x*g6|Z5DHKl3Z;FEHdttHJBtqJNHRd zn#f1bl=sfB7zJpo?U48zfQ{Cqf`_a#-(RRU-kFCtLZRjRFj8o=lCHDZ!IFYbE$IL6*hdN9|^PJiPxZI&j#W3 zR(|{TEuWoSj2?N(Fu4_#bC`mAA1ftlU>|oyIG5OqdM)&WL?z#yLXXtfPv&IRTFtAk zy>YNhv9zVxzq@o&R%b%5REHtT7<7Dr@91AZ1D4d=i~4iVClwg^v-wagEP;kPsg=C> zHxPp}3(&gk#_ z?g|HMC%sUEgOrKRE;M-)NN*s&7!ef~86m&ushU>= zW9RpC&_;YV&W=wppL2Vuk|A=HG8AECn2eIB>oSlk50UK3ZsJhU)IlY+W`wJ4jNk%~ z-wG#6IXZZI*&k(MeRPr1i^+~ccWOa5!OG_5!xo^8ovcpXcTFuk<{G16H`#*y5RmRA}oFLQ%@VDj+hcl&tK#}3Ql3QBY` z-|jZUz9v(3q&@g-P{O6!AfgQh3cE?timR$f(G;RAskAl58XKQ=)mwYb8%_dpM zZb&|2l!PvZR4+D{{gEBy0NDU#p=xxwAxU}^nBZoA&LVwz0)uIh{W%`Dn%xLSWpkAL zLrGtIAKzH*7vaIGXk-~qWv|M}VrzWR?>Y+euvdPxEO=cNl=Xt0Nyrcb&oP|I{&{9^ zEU=eLYm5;&G=@%B2*!fn(0*cWR1nSRH-q8PD@qf37yCRc@xiDNGgOzE7XL4b=?vlr z*#vA*v~I{x?%WWZD_DzFdbI-XlzOqd z`Rw}+SVVeu3NPZAs~K1nvqjn-ICZgwJV~kwaM*&fo#FRSP&K@Xp>BP*oZ3yk?odKX zd>#ZcRw4ee&XwPbwSS_n&o_&Q`l=u`{Ddw+7&r%&5c#ANGp#=xMYVJRq zHr;z!p7E$?eC;%$6FMU3VyH)Sc;VGOw5h~4k{V*BY6}f`4c3tTabi%1G)TXoX5ZYf ziTM<^=OBNvKPPtcK;lB5sGh#)UtUQnP7Xlv?9wTHTpUV%JY=(i{iHC9YX3mGAW0Ny zt8T~sqcHuUNA*N8t727tybpc5h4+Q)2_xHIBGm;VG9-hP{2UkWH?Biqhu?1gj%xJ1 z6~d*zj~m#v$-2*h_fnthn3fx$;`@4gQEA(viQ0mHNQS$-QLT9@qzP#mf`!U^n*tmgPi%!~IaWu+9e)Iu($$zMycw4y~zt4Ap zX>=>E<$r{M0)(Mer-_t~8 zPKz}NmJD)K3%A$Mtd70)2x18;N=g}3Y^9vWeVY?aU(fPrKB;?*KrFW6LK{+@7A=~l zA^DAkKY$DM3kM%LIesmK@O(I-tA|iaeCNU6VsxBe^$h;X zJ0}jPJ?cDU7Yjup%mvv(ZfY19T&fC$-2Y7|{=0*m9QNOg~I#B0;=7!fg}` z0JU2Dr9r`9TMlfwJaVfepr*%+l-oNwIu5M$mX((70F81ug*yS!RIaBdg0H#(OzJB~ z>hU200s_GHgt!sHi7$n5bm5p4o!sO9YKF&;(((LjKbdehBXgk&vlp5Lh_}h0SzAFy zJA3glz2c)SweLxbi`Zn~#DqHvKZ_=w?c-JQ|MV%T{H}9a+#*+gV&%lJ&r!n9#3lB+>2Ut(owURyh)!WJw^mjnAxS&;NpuQ>q4i*2Exrw8Dykl|@!;U#8?$fOv?^x*?stNZ zW_bJd^y4SG-`I`wf4z#QCRdi0mbSOwM495*;;HfA(;(Vv^Q^NHH_R_ZchJ+g2bxBH zv^CSxqB=MxzgGA#Vz#GG4m4g~sh^~x|I5>FSHg7D}6p5}`RuVux3ixF17rvcg+ zz3+h3NH}i8elDJ;`em42p6~9@JYWzgd3p*bJ5>En2zv95V0G&JaKgz9gt=$r1{(}u zkN)cB5@v>L01`@N0)niMiW=;lz;Gx@sm1)A+y)4E-rG_ykrJ78_llf7HfwVcS|L44 zgFgMy4<>IC3aXo4Bmss^_8Z^7c4bnjNsd_m5cmN(#yQd!xc_Q6C&%6@6YgPXjxx}PXNf7lMA&uh(sba zHy>tuyU%dxE>(OVKj$|jBv`S*s)SC zFM%I}p_?71xj8+f{gu-$C5YK8;!baMIpZ>c%<<584qG`yc}{(Z*sLm>=7KPY#14c& zHnAF6jK`ujPF#Hx4msZCz&qFFwwuGX`xH_Elc>VZv)gR?lBn>rbaw_7!rtK%XWHlR za9QrD^M$Gxs=)+=I$lLW3?o%-GlTmOgQHQDX?L^{q$pt*n{#D5q=L;9as#wqH2IE(0=ogGXcM;oHk&xf?z!mjmR*c(#hJp|i*%1P2fJkkyX&G+wBhLbad z^hOAl2S>YgJzZ;WEw&iRn9~4LJQx$(7KIV(%YZm(56}}jnb@MT`j9;Ks!Eq5t1+^% zWfpX)d@AHCda%n>jsfbIk3Xv9g&)s^4um*?sp_sKXe3;Loug~a|s#^n$T0OPx^_>#2+3ti8Aot zXH8Nsj%l(VyzNb2Zvfo7h^d!qbGG7lsG>;$PGc%w2It$$FE0{Vbh8(Y>Te`dW%Q=+ zEo5oHQ_&3S%2X?OGu`SGb`-!-mYKJn2X9L%p>k`A3QoF}nvYy6 zPNPg7eVMO$ZobvhvN-K=nXI`+S-8vjEXq!@o9MpTA5I1P7s8Tz?R}C~GsrpS<)qAu z=4F*w8Zs2*<-HV%lvtR|CA5rx9@3!EHie3$CaC*)=*W27d65>yGwLBmL<$=PJ;rTy&1Nnbe{}=vfoV$p_qLrl zj!A%2{}-b^!9&M7EN*8}+}->4??XI?mASCv%5l~Ah7TTkL`_ZISOPt29Us?IY9@aD z%mn{XTtXs+35Nt~l1tK(E+XT1LPJAaBe~dMyNSI$XE8k-?Aga8$Qm!%&x$g53%zDu zj+g~-iDd;xE?q($}a>?D%?@$wEbw7$~^zc}~~Mt0T6sklqJh@%g_~ zaMH1j*D3hpO=qO3V~<4OGO!CliLrV4@WkF_xB^~Qm>;e+blgNjC7((I;*IkwW1e2% z4-Dq$5)y8by9R!;IjNe^PNNJZMsCCTAXlgM0ws4x4_%OpxP^3Sx@$#wv&nqbWmpoL znwppouFi)A0LM9|$U-%kwvwVSD>ZS|t~<$537MG+=A_3hA2eZk=sqS7=-JrX%dJKW z66@iOfX^QUWg2>yK0`U|#Pz~;iY`y+ySN0^aPC6})yMCwdmzhAC*>q(K0Gf>tMe81 zm#CHp#T0iH7?POVUv70h6(&)%Fl);j+<5uQl|i%Mw<91zjtb$_3upX*>p3DaAWKXI zB%3T3mW?IkWbgR>lKXQ=9RP)IN^U1gMOvq=b#1|NJmxGQV9NHCZI$G|FJGpaxM@8> zw7Q_JgsWK?aPIV+rk;IZo@tFu>7c1EdrTYUf$(DL~l7VQo!Uk56b`OO!ZhfC<7J4 zJA8XT)eH>fLRSkq#+t^CjslS)CBTurh>N1;?|yU(Pk2k~AMF(ytEIL^&~ojW)H(Hl z5Nn23sqg|(Je>;|NyhMRhT6)9H&`ZWA^nf+0){<_frE{dXwRfD@#b@ntEs6m=!D<{ zd?xfwN=s!*04Wm)6&fF-Y>LJ!tXl_VS21OKyK6tyE)nBZUB9#Q{?-M3Nw66p3W&!4 zDxydf69OSJ>p0UJlOoUV)2JnvV4$LWfzN^{4N(HgIO`$EAit4G4)d0zJFlAT(lK9V z*qMAF6B|haA&}RY&baU5D@dS{T3Ru691)UkcSVzVZ{F0rvwiCIT&=8xnD+wKlYAU< z#of29D3Uu@|HImfC29h*0W0(n$9VlRKV03$fLfN>vp+-qa zt@9xbF76}{y4)Qm>>N7CzCS3a|4tFZa5|-rR;ktOgH1v+Db>U;)@$WV+8=vD*|b&sqsYR*#Sg*wC7+{!K{SCfb$88C_2aFE%PC=Yg;i@+ zYObzbLGgq0DP6vj`=?Kzc8mb|nGDRC^ zM4tWdK^28xI#%X36#gXMP@pL+WD!#q%-w4$uEoe+`$W`p&2Cs-NGdu}82giRn*HNc zes#_{CeiC09N<9lQlw_OeOUD;Zdxvcf$RL%mp{InQ>^k76rox)#62>sH{&OtRGH22 z&Bq7jR2)BkE@<|L2fP{s`W9wIVAemZjq3N@bIhhn1}7Qw)ORF*`kpzwcbddq|!Rb z+GmxGzD5xU(|B1^Uw={SXx2%i$3~H4sa-!F{3Z1stJMjyvc(C%PM==9aUO=5aIl}J zY*c)w)NCG#>k>@({_Fq~{c8Kx%rMEP*!Sk!P$ov$oTjjwFmR-?(XolWF@gpUUL2 zAuq6%hk7PmZGU6=pyJe@>tco-sq}<4tljK-fqsqM-Z_2I)t{HbBnHOq2`*8mkH&@U zm6nHDjpmef!+qjoxiy^;>V{J<1`61?TKw5n-$fiP7iGV=rbJB!+m1TN@dGMm;U^yx z)&{DS_tBbyemg_AUtN7 zVf4Z8?Vq(L;GV(?aXx2iDs@!qyAc-?i{yLbDCi!rKn9R^;vmGz734|K}^=A#qgX4#WCuox;eB=!U_mI`q9#`hmgyx;% zM`r3(5~ZA#?o*S&yVUh8Q)iuc{o5NU(R@GJmD}CIpAKwx3w7m0G!wydPl% zIT%&@&%Y}!7YUnV%)x8nhn%fQ*hPlFaCS6Oa>y|T98N(-W@hnSziUTL6CyI7Z+)=}+n|>V+sR{Q!A7mv52veP1#gw)eDK*@ zVYub|&R|LFXk^#8#&hE)OhiLY>XQ7^r@x*2UE``-chpFhQg_ujggMsAca-9Xllu0_ zxFU58c*~6X>_ai?YaO~HN%$8+eJ&iZix`fz*{ifuE?aN?$aCD3oHJRk2s+2q8@l_o zSyWSISyeUDLuqzrOZ60AvyLH&pn9h0;v07gboF6B-`~&kmzF3H&_IeU3Sa4660Wth zGjKfhcV!Bd65YK2(p%1KcN9p(`=S;3srZjZ;qeYA+D!$??Q!KXqFVUwP`sTIl(zgdGh#ty5#Gip!UUC@rL%EE2JBj!8uG# zO$`=eUjHOU;hebW=v`sekgvy;NoL&GFG= zl>eib{!1uqthwOfiT!j$SD%sbAd&aRt@W-KrZMRnVEJ28&WRr#H9C@(l9H+(J_gTR zZNv82?r%5A82|f*j`>L;RivW3s6T1*3j9?AHta1LU&_h zOlD@Lh?zC5TxO<4*Wh5>lm>BgT))7`xZGlU^3{tUflV|Qt#2_f-Sh`1)JLl`kn(%+ zozAbXuMf>{s(LU@r!B8*_U9xq0UG())LHl}dAP*NHKY3-s6K&BTPo7ULt<*s$^i~kGwd^&@*6tYpC`HS8zr~#$9FNl2{QmhwJ#v z#a8KmKfB{Bd`4jE@FLkqNz|mM{j47Dka*o2T)IA_nO%I9v@jLO5UYjlGY|{?plYMs&62g-bpclODMD2ILZ*hSN=Z$JAA64FHOPRgzQrX@+a&c%iN@}(Oa*7x{U zbaZs7{cx{2e-*xmA*4sfnFEoVJ5+i5Xs&~TUaFHN;Bd17b+k&ruM=Y9;;Q`b3tl{y znen!_mK=tgVg|;QygaV4g+QPJmgt&o_<4AFdD+KAq5Z3I*3)S@^25(kb<=ds@sYh# z{dv9=7i%xW!s+_@%uJLcJ;mI@;u%d+28^Z7rGQbyb?&2~ug!57_uZMfj>M$b-eMIO zl1_Zi&7FMc;{C!~=GBP?fu9#STxpgTSenBEuxR?EAY9+!_(4VrQ`__0%=|FV@jrum zN#t-_y^2wV1Oblp(42?f@2w4sj;%Q<-gke%#65GjhpxN(|CeT5uqD9Y|BJjn$1$D}+gA5*L^@2oUjeHHZV0db8y>KW}?j|$ssQOTL`6OKQBEKbn&&vqQ2 zG4oKsM#{>VMfPmcKa;9sKRHPz@9yV24Bu>6J?Aro&d0ZHd*nH{}}QW!^3Sv0!3 zw6O4idFknVbB*)^(P}e68sgwDs5((|Rf)T!mtGu^H#c?6?0k!9U&>!}{lMp&T#n~U z`U^kKE1X^JEl}p+rQ+aLI7?ysdEyp#e&PwT_=~rk6-q2SJXXgW7vG%EzFrhrY!Dn= zqp6gguk;?h^;P`WP^qj(+3peADIxZ?JH_nIv9f!Hh9oJ|O(@3}zqx^rFODCCCEk+L zd;r~*kYwSM>WagD9r zkG+g|7{(}IAf_!L(%Jf%qQ-4GGJaq9d39Jh!C3PF#M-2!%kg&)&?5Tscuf;e&#Vg{ zcO6)c+D#|=FzKBMlJgLCv299%O}eknD}F#^Ew;NpdL-=kHO|))97ZTPFJ z&Sn(<{@PZ>EJ>+fPN4yhaKR(npzGPt8I>wuC_ct20%g5e%;v&psAv-)dYD&S?1EL6 zFB=hp)@Pr4gd`2b>}vMn}_{)_9D$ zP#r&cE<(-ixkpv&lO2{jLj&nbg)v8jnqp0+NQx7>gVD~HX?i!k@QSV1qlDD%}*>-77Vb6zK)hO#EJ-E=TGjQdh9i1%hlnLV%aj zN5wgR4Hm_8B?xLQnHk)VNkroqoj?2I7C%Fi5{dPFpZDc~^m?N$3{=P|xX0r=so`nZRw3j1z)arT5iOUo*WEXAhUCdKky~-M#cwAEL4_l3m?~SRmb8}3RSIAfoq7dW7y?w%o4!m!a zZi;BuS_oD-jE0XNj?Z~|pFPjOHis(OqcNusw|E>rcQXWh z%P8~;VHQ+9DAuv)7Rx~0m|b5topxRPzKq$^8(H#j+lqc)T#ESVKxlMJogu}?R;@O( zIEG!sCwbo}%G1@wc2Ge|ZR1YF9Bp;FtzYdgPM`8iybQUxA#!y%svv+@q;~)GnH~DK z9pV?Lc{%r6%C-hgjVGgB(dp9U5Q^K*$|#nlPN2WnF6h07R7g3`;~1{ix_qbOr_)$& zZulbX7wa~dU;GX&eq#sk3aZ<5l26vxxU~+5lDnKkTv>Y@R2kXMR@`nXY)5zG`}|mp zis?m(-lbjuSj+h8`!PCVI+fLWE;Mx45#a`IyZHs~<{D8oi5Q#Lk1cT@<6?OYe@|R% z(08B$b`f%s8v?o29_kif6Xp;+Bt`7{>4daKKpji81N$fz?_7L}_2JN`Uj>(F#Jt_s z9jye>s(XNLu;i?XLlj&GyK@SKG4Iz=gcQBL62;MnN{Wt{ofOC9nxU#s2A)rzi~*q# z^xEyL&BS=8Eij=jw=8FeRVmePhP&_)>}sPSueg`2z`Nr_{TPeu`}QVyrewEd)CkSp zxK!2)R_Zpt&HdliC#+k`GUY?GcrIzbQb5|t$?U`)bT_kje(5WW;nGty;zb$Y!ZMp} z_f`drl(v_ghNxyPm--3{l$)OR^G_+8E_OHsIPCp?zes8q9ZjKXaxi-2QfMK!BpS zZotvWifx%NL~t5n>O=jPJUKy(MSpQGKQd7{djVG3vb>IZVj_CY-06U7?$OvQ+n}_v zca_ikET@C+NvDa&?V=v1GVBlU$h|z|varO5&@(tw6k?8z+dVD44KZ9Jvnli<-U#Ll zc%A->3KG?xU84yRU#@)BE8@^A%*X8rUVO+^TPA$QIc&se9dl<(JdSm41>#+?|0 zr1(UOdcIk7F=X0v_0UUg*U>>Em`+D3;8$yFMDoGG0dhW)6;!Nt&u<7&&CaR#J^O&> zDn?9L=g}=X`$c4T(9D_+TP!ZBjM5>gcOC?%J zb46S(V$u=V0=#UotH{mxmhlqPfzq-BR(7?dCAxL9HLSnSLig2GYZjmU%vXl3NY0{s z-CVlP0O>?NDzvYTx{gTn%Pc~)MWogcSx4vF~l|NnGz&em%Y{7PawAo zVkz1+t&kf-n1W&E9oaFLh*t!9OBL^a{~3Qrd!we!ogu9@iQ{XS-uVqIcJ_MzNELSadINTUUqk&q;7Lv*P&}PvNKa2q{p8lg zyHwb85OhF?uq1LX5r*p|DXg3}RWolRBZL1>6u)#|-lbrfC5utSdGemF?m$Q4g(M1& zEi5(|xB=$c_*U~?DFE$zgMBCi+dFbTOCt_v@TT)Pe6)|i)^)@OkFCZ0~G2~O_IiIk6oQ*d*uylpkGYhe>hEd zdwYA@jr5)|^n2GoD*V-mBxdWm_d`(jf4W8-x#uZJmOA-=GK7q~titL@yV^hWZeY5` z;6&g_vwh?Ey^oG6a5%+3D~HAReOTwXNdvV$`RTDcq2La|=Q!uc2HTrdu6C!tYW6HU zMM9}aFpd}A*_Va)bYHKT&j){CupK`0Y45NS`tvn4g)H8`-w0G{+@kAzJpgQCLv*$kXfxjs ze|UJv&DRY$J?RpO^9N|6ubM2XmLk3nXc@P^xTy1F(47CT9Qv1NQFa#}ye61l`7ISo zPleM=ynt1H;T3p=0XM1MZ$7w3NzyOD*oOL;qilDABsUXI2Z(CzhZ<+Hm}dgd()Nx3 z%3(|FC@C_M<~_;}w-a;YA&i!z`**&cJ6t*T$#m0BXL8M_hrt~NS4tfY{?ymOc>si& z#NXSun_4XmLG(#RPTrL)Hn`0&IW8MdBChBtI4IQ_7#O&<N2@j$7Z)wJ14d>%&E!HNuGm$zPN!d$Cqz{6Y=2)Yv6=un0dokF z0@W085$GNuBGM}Gw(~Df2XF5}GMNX?~z@02C-~jnm3dWnpn>~v2#*HEV!QJP$ z_yAvg71{`SNd&jQUStw4U{|Pr+-SdOWklHJ&jvNK`s;V)f%CWQtjRv7j8Le))bdj7 zN(gyl$(+I?EL>52oR6!+w!(hA*onXq7WDRRajIw31U~1_p{oY#uQ_NyR^mF}Ns~N= zBSgc7V^w_EL$cNn8x>&*$p<^jXP7!Dj74v&%j>hO_XnWPhPiD%ZG{EJoruPlx2~H6 z%$Dt+F|F%kI$2s9Fd`pM?kbrq!C^v>`z(5Dr1G}J)vJvvjFYhVy37e;oOhqLWyQTK z$KIU8$I@zZ^fx{TJ?|j#X=#W{&^>!LCM|Ez??9jI*X!=yR} zeh*(T(ssM~tmmmP(|`WnVtbiykh8;TS&YFOMz1J6Gzu9?){ z?}_9RrF=*nBe=o_@^>4_3C{A)Ph()^ z3O*?Mu?L?0f1`rt^lSn={&QlUwWDu^9p?JsN(zPkGfgIU4!x=|In6&cy zXQW~jyKo!)L5(qe4@RAqI&tIgpYO`++lX+GGMg%K&gVu5nG~W!>lp0eb2*+I{r*|R z==a(LzI6WjTXGg}X8=9bE7x!2CXe2Y&*jjR%#I#lO9TcjQldqI<9s;~i&g#b9bNcS zb(G!rFM?1ok@K!jiy}!jZJ}I9oY>PJ+l4|Vj^F#_76g~CJj~rajm7+ZnHVP*&pPl? zOng+wu{Qx&M{Dw~c~ezfk?=DfOSVHreTrN8SY6w z*WW6e@QqyKM~^CAlf6gP0!0=nf-qhI;>+G4<*QAwfrC5yc@8c`l@88Vj9^& zQOg~fY}j>Y08ek83fX!4>(S*iS@~&9G$`5avuk2-g<7mT2CG+oE&iS3M_pyH!F#sy&J#KQ|AM0+dw@EV_O}A&fs8BZZB-Ho$pL#l-!z^bgbW> z8_CG%!<~Y4=(~z>Z1nsNm=bzq=CL`!=)73xjR%yxX+v;O;H zt^8I{Q*?2s)IuR2fYwH?miZa{ik<(Ns`q{>T$w`*K|UAo9yliF{@4f~LX;l;mKIm73U_ zW_#CoSAamYE4AOrXUd;RdvE(BPV|rt;Y`DWN~}7=Pglih-h89`fJng-DHCb$zET-m zH|>Ve=%A3ek&Ts)-NdD+dWrbVHhcg&q*WeUdD^W1dyJhfaGEivtHMGO_08sP+!WRMUL#oI-uXXajvc^Ey-o{h@mIZmKIUNYfxITGYe~%=U{~ zXyqQerF2^N;ib@X4G{z~i>?&=BiB}4z{m*Ht+Sp}KQ>*mU1mGRtoxTw)p=@Yl0KS~ zRSW_^UsCoj>)}69URcp@Exz->*$#RgKYQld(R5)uZ*9m<#de8FP)gxZ z_^0Sd?h69({zqssPlL}}7ZI<%3=PF^ZsWZ+`i^k25TEt+^+6-?^=paUS1Ul%mB*o) zE-mIU4n<4VQMV63PQ!9#3m7t0DeLn~b6;j2tFaS4J?s9F+u80MUCPM=)7<=gd&~~A zA2pMZ`J=IAf9s3b+a_y?IcywZsae?QurX@y>FH@Z;Rk$r4y2!=j20%YrNOw{WzW#_ zR{I?cSXEUa{9dB%vH@qu5X-#orzxTIeLWo!dlsReg%)JJ=H2eMFdb%RD)(I(=;iIa zdix^5MB=JPr)!bzyNi&>bG2EKN@B0=ATrJ) zem9-m!0_lz27Tnxi_vkTTKmNjr=#^SfFVV43zSq;rYL{E$7zj?blr7or0OiW!)-e?-7LkmOp6b2c-z{BnymTlFcdDwYAU`)1hC)-6 z<8sxE0>Zy&_a}X=`S{#><}p8f-$xzOUzfSXHJNZw<;JDP<#lu{xod9ivHbd4O%CIa z{!4P8Ro58e`VP)xuVW=l3Tb)rHg*=qleG$-4Vf zy)~}cHvg%8Y|w&Tu>tg{Dh^$`tf$->CV7|}Z8BW2B)`AAYN)&Scs?LqHW)EcI6I@w z6nHt&n^!~}tG+gC%_zKxs7JD^B*@$^Hn*xB{UIBiENHSuco=_ECzGLQ)~AW@DG!0d zFd)uVZs91;|2jK6eoojkFYzJaL>k_P;O2rn7;g%BKd?JcqnFytWx=ntCM{iqi09v2 zNy`;-z##Vd<%Kjx+N6hN3I=wVO&#h#iSX!lprj^WfCQ%H$A_#K+lUpVRh0sEMnngi z9b=1Oo+0VKEOK^L9gR~JNsZ!Abr0UVz2}XRa9arXCLvj@@!fw) zL)IY1{OU3it*swfXOpcrQb5Zyti%;t%;Mlwj;H_CmUS}(D31rLRB>3fdHTD{(QP*U zyWF1#@omD6qwj))wYa2?+*rniI#fG@fPvo`SqO0F$_itC9SU#+;8b~ON^wfcvgejLE-8&oXbC01|s11!5WgGrEOs2C#W@mAkM&@ zIP|obg-~cKE<)VM((p&K>U*mlz>(Hig*mU%2XPJNS2|BAugyjFDedOPS z;()x|P*vZ*>7&;b!Rz+KV>;JH7wZFLKY!`OIbzPSh&%l1E&Ve@?soja-h1QH%7XHC@|#Ky$N^TbH0 z^Tha^?yw9tuOGd>iTskQJK&K^=`Uo5yIf|8)jFi%%2rAnh)%>s6j~k*T@*BBFO4rQ z5RaQZWFUONJo9~Rf&-C7Lm%XF9m$MN9=Ch>$I%&5hi^gRkj|d+;gl5Y0-AdEqYnkY z?1Dz^&m+cqFaK{Zo$vy_&-T&12sa(S``S{0%Fl<IHgge1N>5o#5ZY7#YDg@ zE2u}6IYNRI9v(_1(T(e?l_nOV9`ad9tH!O&46G`pF8OboTb#};{?<38)_OVKt9Ve0 zp4WF#Yi!^6u>Dza5QHcCWC<=Gh!G9>E~}x&V&>&&W-LCx>Jh$H7IO=_D8sjqVDCOrAywHkMK*C;@o)F z>~!KR%gS?AMJ}(G$ds;FL(^evI+>n|2N;A}dMDH5`yX2d zw2xYrD@ciEE{2>~EzX_IUSszZ)`_d0%^I#M^<7NWD@|UeDw_yYz&cMh;3Wx{ZvE^u zP5%`fU;$N4LSeoyWF(j&drzg1?i!b$hzX(nWdHhqui$ZEe(7OoJ%>9rtK3KHo{Tr> zuTRGGApV2gF%DAv6QhpF0nApN_>G@5{afc)!c}rqQ+I(q9i;iBLoPKY@e2dTAu zx8YMffCMNOD!24eby!anjKmNF)$SJyWGtqXp;}xg)CFzpFzb^bw4LW@xS}QT=sntR zH)JJKg}8?Fbr0&2E#d=QHw}dm@`t@1yiFmOcogrX&A!TfD^>JnZVQ>MS@uP=zBC#2 zz-c|j*!_^+$91##0+$xHgXVSPW8hk?t+yPZ!0d^QFLiS2EKFuuF1k$;!prtt2UaU6 z2y|KMbfiZq{RaHSU6>#blQS!AHCIwAdNRr`C4~ZYX(itD&hxe(p!)J(KSC*KUY5X- z32HIcKQd-2Ridja5w9vP)=XrrLRode2_r{XIJ9cV4ll8ssaCvKi*K`YCtyEW{wp1T z15UNhnb`fYLkY13q^$=O*IbIZ+i`MEM>3$=^f?d(u>k zdbfD=XV%wIPt2+hucn$?5euR5oE%bj7b3!USIf}m71Y#qtAq=kF1P0UhFyQ7aBaTt z+*`|*2a8zY@*gfPCL-(a_zv}?>JLq|1d%&`p2;-u%RrirRLW9Qp3TPvhJ^|N7gg3b zA2)*m@~9H8e1$`W|2Jsah)FcrFfb@1UpRTDDEq2hO_eu*GRLKl*5>i4MqK#zty*y= z@&~&>FrP1vzRCz%%a3&nr+ehWD=h2@CmiEahl%N(r^fGZsC+|0XK}W>Q__`~UBFsJ zsOmJR3uPY?qW}ZfhGrQ5#g{sa$CaU5Q{!ixPeA((P_?{BdgY;6|8!n+56W}b2W?Tx z0yUSp*1KG^q;%m{8*Wv!q__DZYl^q^Zpcl0rJ53IyPnNj=y5!JC-P^}zhoaIQIJ|^ zo6Jkko02fZ)iy%`wsSzwCX`W1C`Y-1H|6%YsL=;s5;dP^O_jGZmzg87-z8upw2PoR zshZqoTH%=NyHs<4>;_1bx+GVD8?*rVu+vzfQMJ7yEFr||oLQKTu}riP*9A*K0k|s# zhEiX=U3iUdnAH1L4YyYL?vLD_Zr<^_b{PCEqx$kjTMQCpkDp?>mxQlqvR}53>dx0M zft8Fo`1D%AAwtHyiqPC+7HcTcleu&=-&P)JZjX6d*SVdlJQf>#{o~2ei8g<3|#&NH;oo+?oMnu(J^Jgx;j*1x*C^r~Wk365wzsP8`* z`>MfO8|0fYh->Bz;T6T)xa7FIqVIS_le@F4EmJvMBM()0HOcXk!{nK$W;Ht6%X+O3 z$2CE%%Pn9L@vC%umxfVnWT{o;+1A2&HA&9<2gr;K z@3jPOV{Y`gf`|kHdlp^k6RiY%7$ub5TrE{nS(RsnAX!EqtNs8q$ppf#`ydA=rAh&cWV!5Gds6UtJj@?>GyCEp$)8!xEP4&5O*QPwjpQ*t0Qk9Jw zf<;PeE_bTM?s8>hCrZ3%n~zgN^WsjnwmZl5><8@H@%?R)(UcArB`N7yKWw*kJ}cmk z<*a8Y5I*AeZO=Z^M;x(QE2Armiq!;*bM=Qk9Oq5Tbq{t&f-RF@R^ji}Fg!Q3_6~D8 zTAk1hvw`?k_G4!tc}*qe#A=8FMI`M-Y|3JbDI&$-!-plLvJW3xw6|Bcn=Wrp$D#hF z7ebo1WUb3w1?!mSh|yT|>EDIQRqsOzf6J=?sI|es7%f z(Ty4#8^!|12c@7C7IbU7;iS3bD(iUjt?d|F+eqy0E@FIcW=tg(QK4GUE{*NXy_T2H zs$3T4ag(0VLvTFl;pQ`NEWBVg{C?IjdzN`e%@$jr zGY}_R9@;nXWJvT@9jMahgHVslSe`!5tawty0-te%1K=J#~Gv)>=p*ghRTUx_ms z9RFkfK>4_3@@pif7Hw_!nacTs1NK0pM@EbUImq#@oSDTzC%%VQOp#iLO=ec3eS8Z` zrK6#3A;+d6Snv&5V2vE;_sF3~H^ zHPj->o82YtHIJks1ocds(AES=bCe-F- zWj!JU+;(pxSRX`~af$j2kaZb8c<}AbnV(QHbKJbyzU(vgEzW&i=I3TqRBEcz9fpoM zmP5Ay*ls?96p>KlT?VyeFS~)Y(=6f}NXxQ;?tOZa<(cU!!0vFQdq2mA zhb_{6vFN=Mg|taGjrIm9arRek(SaGVN-2zMHbXyJ9qpCsA0vYdUz!$`BHsv*29Y7Hf`#@-p{khW4he9Z|?g2L5NmDig`EKsPud4~ zEcJlT7TZ-)GFbEEc%=oU!bf^w1o*uIu;H?#OBUoO>?@ukDPP&NUfO^zFGfqC!qS@M zkl%RvOE#j$g@w^C@VIV)QI*ncKg^p1ZX**Kuy}lk@2|f;$Q!_+??-(bUOngLiXgQa3DfcSOYUd5P-vv65wAD2EB$aWe**n)$}3fo1Na%TxV_ z00OA_6SX$M17;bQ#E64TK3W=GPZad=+#k`Dsc<}M@UNl2U{bbu-*G&@8=*tYSDa`M zfLAdbd$lrwlD#)YZ|Xc(_aQaa6wVoV_H45|UAlwr2(mMwMk-X5*Cnewx^r{B3^ctq z14;3gRnW55cc&+aoQgSDwh(ymFPuV-IgS5ZmgxC2DNWVJ191lsAf_#u=G=n^qHDln zx9T{u%HjFlqL!``wV;shmgIEBi~1#qmsX#LF%K$`EeO-2*bFZu-JF@-rh#@Ul7Q?k zXjGArl|?cV*rZAyg_VAS`m;}+G5OB~)T0#8_+BSS9u88A6{nC`LC6{3O%B%*i3 zwvd8efsBOet8Z)_?UNpk=H}iI_0j#HUu2@@amsJhmbAUKwRJS%sxX00=leY380$FT zZ+j%2BAbfGjf1bakBL$WQ3SHG9C2+~Ok-w@;OwPo)6l5TlVz@2aDma$+EJ}pPJu|d z1r>t`9Nx#yI?!&Swo-2ZKij37h-SZjy?==GLC1m>b(-l2gU?wsjuc(JQHp+*Ur_%8 zeGO@--&u+#uViHuwW~deD{~L_cfcC+*)iQjo>O1-)O9L5)q1S}<$ZJw$<*1d$}Lt= z^tHQ`b~tjfGDwQq!TZ%~p=^Fm@baBc-QC@=5fL8=OG;;_!{r0n_h&!@&Gc)(zg2h&RT|B?v#iiHj@2<)|la4Q@>s|oC z^KI}jQI$G^`{?V0upN6Je^B4clh7nLCN)6wWkOL=k$?T`D*xj+tdC{*53zM|0ViIQ z0ro#rr91AH+uy%`4-O7KPeZeo``v&Xd?A4L6yG<(_5Ax6!BSXhu=mnDJz>=4Losxv&`Wc#V!w`uU|zoqdt>qdszQ0%9{#D??dcKH& zvGeQkw=8^rtG<(}qZY5nK{qd1|4V`LWy1s@4d7tnoqu`roOE4u*-E_NoFzU+By5 zx13y<@l1ZfJ$cjR?@uXd8gdI|0w!zK?#m4RwMKtOIdA=i+Pl5IF!b_mOs7{>Vc*v{ z*0qcU66RJcIUCAtKUEL^Jlkilkk{w+gfTAkrFIcq<}8j3&7;c7(fBHl+FMIgQ-!?` zEca!^oft4L>Tpa7Dyy2yM_s`_7OSRs`+>uIXPHFraB{YiiQNN+!bE9&Yjpaa-j>00 zKJ4(X=OnhYjACMCyn;|JB!upAUT6pDMxvn9MjkUCG}3Lg z5WAO4O*AZR{8LCc&(c5p(;aFRrnGceuWO?}o7*9dZG2tXWb72H=mEb;c-X^|x38S( zM82+VNwf4DFv%~Vo7yFwZRHMUfJ=y2SuT?UFN*7RHl8@oI_SGFDAg}u)v2uwEnkR) z$W}JHNcqsv@Y>MiFAJu-&JE`L#!iO&nWnW9DgZd<~)mud+0QQ zr{@gyu;B@9)nd&SYP|e)X_5HR+v} z7*%y#n*xsCM!?cxC>mOfmw>`Nj@R&-q8?AodlPDED8bu7wO&7=O3oa3**Y*dT8%R#l-{_(T_J-9k23Y~76jAcB7aeN9IRJg$kOo-0EdiF}vz zUak)pbrJ<>BEJfWle^q3N}lUSicRpAU<6^DG~71y-m!9P>U7fn`atiVLSw@!K1tL3 z4p3q6adJAsbrZi!O7!B{x6qwT^^{T7u54Jlv+2=E8KTt!?CYf>i1!Zw)o2lBYP`^- zRIZy(;j9x`dMO29C;v*#w&+bR#v~>#5WwT66l>MgJD!{iRF# zzV%+;bMCyl>IJSS;>~^x^0w9}j+!1)!lK%G3#iGowp9G_iv9{S&>G^xUzcV0ix($# z4j9C}!j}i@v&{J)GT09v-p7D26S9SN73Q~4n5?miBPT#%v>_ADX;*#V2PW#)HjH9d zj>)3~gA&Vv(b0@u_o)ZB$$p9bZ0hiRvZGMo!}kmzB<=#jyHrBwQ=$*iL=IQ%y*msG zH6^y;F3}&#=R%X~SG7f`*JjN1d(PumSavgCD|fVZN*h*r7`l9QI_b6OO_YJhwCRK^ zbFQDp0nvBJps&%xsrrD4NPrpOL_3d{@!6XG)yvwN!Yw!{iy{h(Qku;9$J_@4@)GsR zo^kwt)T<80{;%cUvQ4R-(GQN{g@N9^Iw<+4`xW@y?)+vZfuX<{M;|ukJy1Oc3kBY? z@j9?K+mQY@*}p%M-23~qZb)8AEPwsR{S1XI{df&DHWtAgg4f-)eiWE9GrMg-8Met_ z__E=lwaBqYnyToAa}*~~UB8@5RDHKUF5Zu-ONo4cMDA){AIseDXc0QPOm%_Sd@T@R z#3<48$iO38esAqx`O9+$f8a6suWly_+LeRfOt-J#7m%Cp^SkR;GlAA4nwAp?9l{fI z7m^1e3;(x8srdbj-msD5XNs+ZzX}<&>ruVFfXH=`$0FMG=W=#s|6WT2-?)Ct3MdXX z-0*W$z5gmz9(Dw*eBEuIP~^RFmv?)1{P8Jh8ZffO8t^@*6pB$6Z;0}j0KY=dt`tvi z5-^`}ckw2+J2=`CslG_Phstz*|M3I0oT0CeHVe1G8bKwm1&Ypd+(wn}^ujUpWqb{G zL`UG`Se4m6zn$D?P)o@bxheAj+-EK&sQn54ilaZ@SE;|JYEfBm5KD@EX5q9*6_D|d z7^rL&4!e>;p$ybJweQBY4m7j1Ya|i2reB{4pua;QQ1igRCUcif@EUdlyff>|vJl z<)vY8zbSd?l<~#y171{GRX!-X?Nc}@&m4n^>mT-_CWZbxZ?6n%uhn?0KIQtObPn9{ zSNkd)^~U>3)*CHtNO{?n zXi+I_{nw&`UBYIK5~de!n+V#PT9)x)+aDkY|0j&Nw;_ zqYlI{g17~&j-ZFpjMQuO+mXq{b$ErXjVUXAAbJk}OMEfv@!wAV+fa-h7O(Z3fKC9m zJ>ltHZFS<>HDe&GSt>!@^3F0}CRz$pWK*~}+4K$ji**9IMFCZ>+~4KnG)ht4%}h~t zbkX*}`TU*mz~;>R$4Fe)c>@k=l;ij-EL&9gU+?-b#hbeAt`HHE0sk!cFAMNj=G59_ zRTFR9tsdxvoB@f}!{jX~gKP1-zt6_A5L}XefxgS&bVqDryQ@l)`UPKd2{H8);~aD$ zEmnQ^3i%bPy|rP%$CLj!kPSPSn_n+V6Yj6ia#kW!+?s`RRxPTX%vm@^G!G(e@~9Pa zjH+B>`s>fWecX;21VNb2Di@es@??&-+b*DUWNl3~W3zVMLD4o`kZ*?E6_k4N!hWdG zc%)16U{rS%2|ZHU1i7?VmFZetTm7I#V%pX4GZptFaQ}z!^8<^}b$pKaqoRbAuO_rmt z-IUD$e~T@L%K9b~xLp!i7%(~=aqq3jWy@^(bUWfiFt(c7=Jf`v_&{=^4P|8em(5p< zFQ+Ac`Um7Nv$^1N6YPrZ`2uY_pYY?i#IA!AgrJ*ZEYm0^Eqx6GjRgL2$RkFAo&jm1 z1$r_TNBJ%~an=_pVQHN_i81Y%A55NJs^A}aCEnlk_Liafa9trQ)7r7Wjw=WZ@NfX! zBI2We`z!wIw19B=cV};qj*f}7n~tyrJ9Aq{mD(LUToSm9?oJWk7>MKN!^bSVmpfgF zi)WP!jZJhtz5}*CwZz-O=g+~;Z~NjZBS~d=Y{9Xdy1SWRDiXPnE4#|&Y9|K$xR zHhLKxoXLgAvuHc2T_;e5R73+c{eDUM)Fh2I@`Ds*EU9jUAKv|$kKw|clMi7)&b3n%W8Ot%&Mqbh+UfdEEuX%98+wI6q6sY1S_4#M zc8E2z+Rruuf=6?|E0mNzNbP&6ftPJdU2=}?W1GCsqtEIW9+?KXzo;HyqcSX~P*60% zPE%F%4G?5?)dhG`9e=;RSm5_k=C#Cd4Z7SmbY=`xiN1({ff9&W#owVJPp#Y+(}Tzd z2M<$QVAG7;dcJM>zbJd_xTv@8ZFsN{QSewOsZt_!00Akn5NRZ(MY>Bm6_A6ZbP1yL z&^0tjcMqM?LrDxB?;5!8-+RBm=l4AC=Q)3zau{a5d+)Wc>sr@Zt1fetFMmkUVDP3A z4M*ZhjAoZL)cEx*;Ps4XU-(eJC!t1KHFP|B5`**w@z6E`eiwJ(ACJLHfe zu3yUuT=s1ALJg8L54nK%^6zs$F3+;#g-d*AEhvV6iv<%bmzqmKzCn*8F7%qx#%)4(mayAz zAePkX|J?f!zTYd41kr0>e}9~$)m5UURu@-PDb6&kyi}Oz^81z0dDg1{8enr4omCK= zfW9cu-NeSclNxTmCul!?@a!QM;2Ld>dZM?uMNHc=J3XCZ_EGS7sS}pNW@(6Re()oJ zX3atF=b%cbvAVwC#n=)`@1KF4CtYdZ|L$U#Hx%j)Dhmf8M|#5x0h0Jay?J2}R-UjC zjI6CcGH5O|6~HB~z@r5g7yHK43$RmdI$xYY%-Mlau{)PhVVD}@M|t=Or{b$oy}W>3 zWzDHER=oSLEi?RQp3Q=!a0`*5-IaAhl3?(YXX{j$%+i|6hfCZ@Vwh-?2`2hJtL#V-8l80#*j& zu!1Z|`6r!{Dj(Qg*YvoJMfpIwZCikjI}$HnZmREX;#qKrc1e@+Jx;Krx-5<2~QRJO_}7isDX1P)ip)AN7s1Ba(M^~ zSAVYp5jdpOZS3JdvsNC6E*>;OZP`d~e+AsjZ#Z;LR2~ECGg+cK~s~66QyIQkk2*M%B@8GL^)_AQ@Ydj1S@y$ zSQHo3JHJDZ8MVqS><0d{+Hs-;6~CQZKan}%e>A>1!Skn0Evk5-Aosfq47W*4NWjd+ z@L0Wy_VX-=-VpL3#Z28 zWR}s$Q_xjB6!{n*59q_vz^zlSM_;zLxA#hzj7R8XCAP2LDT zvX;h@lV5;J#GawMgRAz!Z6I!F_KZFAD)T(zaX+rQQ0vssrU66ISn!S6nZu$NspcT* zf_qG7E+8@kkf8Lu7JpRyCexZ47Ba-}?A*zuBGUFq4n1Ig3EA0!(7yiU&i(!o(nNp6 zk5`WmU13v2u`eWo1{?$9Tc|-^hnGj{@->OQN6$430-b!3BJC>^ac>f+@Gmrqs+#5?WL!(QLCbTWLR62qPGj zo<)WcWc5tj z0+V~fCa*8$|{FdmNm ztGPiknA=*zozpmVP`SB~5FVsSmz|IH84sc`6%~_3;<_coN*?p&LDE#pOOTear5hU0 zb;eS-;e*hya9U*s?U>BD;7Lqq&nSu&yd4A_Ke9R)%)GrpQjw%vU*BeIY1;!mHu`>1 z%+6Bwt+sX34-N(;?_c>xju@Y4DQjG`QO3L1&;hzHSg!8moi!(fjd_`fh$~Y3QPp|C zN6qG6q1OAuE{nxv;Q$6D)2+?cib=9Mo%3 zV78A$Na?UM6J5GAR>byA7EgEtM@Ti^CU-)1M6aQ}Z6qp3tKWZl_{kEu+Tkz21JLOp zQzH+ip7<~QfxKv&kE@Uk=jmOY1C849ZqhtG2HpxTz~@7#e%)p(p_iMpGf#ic_aw8@ z$MHF=KkVHiFHeR_rHq!pi;2%c)#-S;NWqh{{x2IMCOJ7!6T^8?A)=mvV!uH zYuB!k9%iU^n~Y7Kg_~ad+mq#htO}N{Lg!&&!8ShaKL5UFK%g5xW4zvzaV09fQc?Pa zLkL65ROTgRW&D!Qe~OzDGJR_p3%z3!brhFAEXcbQ$QgBH>tf?(a2?>J-yy25LCHeVRrF0s zLqiPoi3C>`=@9~KXP)_H;{`_Hke-0XdL^=N_2Mv8fgmx4{rS8wQk(?{K&R_pF0~!x zdn34_VRtiu?GKprq4gk~VelFl)c%^Fk}~0eK42k z`T3m=_pl#7e*Wxa*i?XX;3{xZbX!1o89BSrisc$W$YSBlyY>Zv{s978Vm)sOzCjN` z<~6z5#qGYNHYz}97SsCZ{?Z^}fD);@L7}t@&2;RCF9TLUgP?5}bx<(+0$K@npY8T1 zIQW@;{j2!qVUU_}+*;a&2?wLOw3V{y$lt#Sw^VvEmGbtgJs-FKE zHtNyz@s8hASu$i9RJx#wn45utfp@6Upp06bZx+s;xga#) zO5|l(;Nr5esp0=w+YC@lfkdPVJ*OrhlA>#`sKw~3+>WABF%jTIbaYm6+S?)Ewl1Qr z|8Z_&5UQ&oTL>b4V?hzMx-+^EVl#{}-GwRG5Ae-p{RR*yi%4uTVO8+$%g2I_+h^qE zza1ZsM=qc$LjSr=mx2|zAg6o7j!G>4MxB_bL4;x%Cog;~QWF{SuFHBdW!$`}7k^w1 z)AYadg^p`M|HnI~o-?6leoue?(AbF1JIYfy=61pKn8p!$?eloeX??Tqd*bNY{v_I& zYp!f-&S`5-%OC}*`z+sD>&~4!s(Y6@zLQOG#R^6qnc@XZFE3M&@d-eqAvL@oCZVAr zA?sT;!4L9@X9<)T)LlP`xF-_NP0Iaxr3FRBn$yZ};I708m+I;dmV_QCYf%a{C#@p>)85%b|g=HmMX*2yg2Qj*RifSxo|*g=^MZFFNiSic zTG*d5G+Mn^fWItzo{*I_D^DYq;+jBucYjbqDFdfcv#aCO-tSr=N5NLz#7g!{8Wt>x<*P+fMxrdymfczzJ~3h;bCn*NrlBmDcNFQMQFG!)3J8uIfZilmcHo) z*-{fEgT@)UG9`BE}shj zJoHbZ7A8iy^x-Qh$;dFTn=OQwt+9qybS99v{$zQ!*u(uiR>gep5g!!}WlBd6q0C(2 z6Bwip!&-jvArvOc(pS!2JgiJj7`~VPvrW(0amry)A!GB!aoAC4MIhwl*Crt&J-B=N zF*dtH#+wgg1(f#lh#PAsesw4_J==|QP6{$@)Cy1%6uPBWAvf!cB5@=}NR!MT5I*JH zMQsK%IZn@a={|YF{@2jG1eN~GeIjk-=fbo20$QsK$sI8|t9S@#{#%Ghlgc%=u$XM{ zB1ReQXY((O6!*-C>8!5Ub)bqh;wt{)ii24%@>>$#l#xqVPqSwBSY%p2-@Q&9;n7L&2EA4(=RHZ}(% z=Y2&^KzWKFJjY6Soa|lY`@s^X1>6^jG8>B?E)3lu<1WycMtE1|se2FG-gO)uLWwhm zBs%8Wuu7`R?daTdyp52i1^ysSJ+5AYN(oielkAaMWjvQxm!GS6j$HxYR}j5-vwIfM9~6W@nJ)9@r&=$M#% zzex*2@EjyK*)XoN9RbiPv0;$7pk;WNkB;s$5I@t8z~bftcdUQE%v>~sd!rm!LqgK_ zY6l(X8H6DrtmOahGf#BQ-@|)4+T77GUTl?YA!%We6FXMMLkoj#1vj_0y#2nlOP!>gqiPbe8>l2jbaUw!Hr?An?p$q82(araPk%rHon3ebUb36FlnyX`^&Gywkr zO(}4{IQr{Fqfx@!&Mv1f4`OIkW(tEjwKjw&lU@12wWm?y6$>#^t?9v*%c@a}|i z7nP)>JWC__8^^~C`%RXBR6*3Pt;3Dx3Mn-%LrAf0$7N* z*>`{WL7z#W(1XQR^!q2j8q)#YR|W{IX#@r1zMU*C6!^!UvZ~#V;|9r;c#?)=E*?)h z=;H&o5cY_!EML35^Up|2eyref z;vII`Hc*L&_Pp)s*L6}YzD5Oyl}HHq#+6phM&aS!G{rfQR>%EkT)4NXTItyG45xkb zBTm-|H@$J?%KDZTq}s5Tm?&$z18qC+=x3XY4EssbqZF$PJY-{Ia;mC!tL58i+GUR| zzNX9eF&PCaew}C`!bd!=3cP|aoQBwK=3+~2KczvF%(V-xr}BGE;FLh#yEMFEGD$uW6`sqA5+$ zs})(9Le&bnTTtGnYqNTU%l$OIv0*|~+4sVR_zFS!s6$aBb+P>>os01JWSynH^RU zIc{u3^<~-rP>HaZ^>VbqE^`A9_sX?Y1APYfyq1Hj9&)1{!*kS(;UdyW)Mz=>#|JNm_=cb(w8mUd>_22|SMDAsFByk*!HWSU^ zntKpW@!Ytu6AA%I!~gGEwnJ=%m{;V{9h3`?u34b;WH|POi&EbX4!-nobgRd5Mhl!p zn2$jSL1a!etw=RFzv-I8OcK`o5Io$p6K{R6b@?P7mBZ~koXZ(`h(M%VgKd?ak)L1; zrxCnYUx!ov(vlEUq{-;>*>Z_szHaC;=Is<2=BOs4zz#=!FJ2r;|M|1c*Um{?2UJ^A?3mH7#LCIWlZ{av7JvLmY${#VsB6=8Q~@me`n#rpY_JiaIB(mH7=T zI)dWPbhNkES!s^p*HN~xh&N=4hMQ8nrJTiSuXS)8sc6kV3Y(KL1RWNl9uoTapZN3V z>9G#eXb2du)FGecg}!cGB4E2x7D9eaK)g`l(v4frYz|9r$DZNhO%<`a#?EJ&-rnhH zW|d%K=Q7uU&aV&@7I}LmqGg?;_h;f-+gkVvwf6aR#zP(#4}HgXKZ*^ehTo^_oUJmO zkYG%(d{stnvsl9?3>vRnM?bOt;-a98;j+g<^Zv$`<)Lp3S&Uf~mRQoMC|Z{AP#5O& z^?sC&yE2hE$|SgZq%*}iFjOb!9Cm>$o)V7E;Ix9Daj?qG68IveAC{)ayQL^aK|-;R z%;bX5a88MHdAxhPNe|jO(r#Z@C*PjBe(1368#rxZoSAJ|$Ico5<)({LWAsIY-&NQ; z+3`!!K}iy;-OT!ak~6M!+a^pVo6s0iW3Hdc{zJ`h^oQ%`^~iY~xLJ_l zo*R|Yy>}9hC?Vi4&DwH*Izx5Iv|qAgsjtAu&W<>8=U{`wa(pRP7yZ@>7PLiyMnr$5 zJ0}>0k}5!Di)xadd^J1RKY4FIX;dmsRK&N~ud}Ur z$|ytY`>g$s=X#_9TFSo+LB|+COz-$4U{&*Z+B43L53lMgoA2evZmjrkZ*Ect<<-h1_Wek3Gn1isyjVzbV3%k1{r zU%!^ODH_Ad7>TEj1K&9bq0JQGYpV4Y*X*GO8{d|F*e^nWRlGf6)%Ggx&9LSQ0CofgxcUbxz%It z@5-10c!$}62i4j7Xmogc6oeYNnZ?Gg8i{X>TMJ}s%UC44tbh@^gCrC-qYtHhLdJ?x z>n4*3`k@M$hMd3~6W@p5O$MWw-l&9!cxP4a4HXrfgp@~bnhHpL?=RXJuHKZO zZ7M)?>Ra5o10e^nj=9*zY{f0HtJaApFGq1(ZvFv;3zQUh$o~xwPVnd{(EJZ&_TPZj zt>mlieHvNCw{|9uN-t^W_KXuZSv-Wd2>Jv+M|1SXvy`3d5;L^O-* zI@G@3%7BZJZd$!V4#^qXiwuf(c@xQ%sh}>DWG{QNF!FRfmgbuq>|Gf~{iWq)bTCx7 zr_IR;YQYoUXXdUx-6}XPXSwdYe}#X4Mu}krC>HGVL7(__Ri)))^-31n^0Km9^eo*A zTa8^mbGfiT?h8;$bT7k&xi?qmBV#Gmw=!P#*n1mhB?GzAag`W%0dmzucATqYv<^VR zb$#2N-D={y-n2{qzN@TGw+ssS(UzQD#JRywpMrzc5BzS774YT#@yP?etL?DY%OE4Z z;`8N+)Z0fINv_;;+tBJ3sG@jSv$MJRJ{+n&Asv&cR{^2BGGp%vf<7=lQ%cflzdl<1 zg!e<>zdnWp4-XZRz&p)y98e8SJ8^DqZz~`c)5@Qz&FQ~KiZ|-3`{d57jB-5hSXisQ zURUQ?0)wzC43{lAM>C8Ri;r7|7kg`tD~?^fhw?>EE-pDG;}JQ6C?G(Di(M@PXX0J8 z6FlCa6Z^mZ6m~yDC8JcRG3~bLo8Q^VLbUf{AkO)zfEZ_fXtZS0>Ae>ZV8t8)FQAKk z!5*_AF^*nwG@T{{Cz?^w`epbOhDVvBkOM29$%>}Z_;gMCK-13nGto}VSiEbk_{VPpF-@-~vrspS< zZhrRK4%`oHpzO&Yf{Ts|CmYaDYz6o}DzsYe&XLvZA_+1D7~y4eku8*pYsr&NP@yyG zwWfC+P}_R-^U$LLC`}9}2n)x%7$k(5;01WBG(i~Mv)9zOwU@!m#AE;a2B#iyOt@Aq z2yj$^pv71ELvsZJS?K<3oAZqNtZIdm(yh9BiDh{7SPUAVx)RXGNzdPer8auGHUC0~ zcPBmqxcM*4(&W$uM_aFAy&0Hr+Nepe3iFj)tRkv0MX!_0U`bz&0U(lV+T5%ZB?^mf z{-sh#2N_rS?u-$+NsFHiymrnC7e5li_v%P!Y|&&6!!8GH*3?o%UR1@dxkBhw243yG9%8 zGoLt4@DZnymogmf@d46j!GF>=kOmb7{xov3X9}BcXhRr5P_s~4@`ZnSG29q&ed*%=C%!tn)ti_$4AD4 z#$$Ylry1Y>&45phd?&m16lWn2{G^~frKo^G9g+i&+8J0_`g)?dlYyDt4zF)$$m=-? zHJQL2>F;UAmnVXbS?>j-+m_f6PYa+-;pwPhPyKJFcYeTaDNng=CYx5wmqO6yV1UC( zBs7?uoCHhQ)gt_c@P8!Y z+RtmB1W)e2u5;8M{kRSD;cWgo0e1qA=g<4XMU7+jruDk+L)qDXisYQNmo{0zR-T_bnQM! zm;jl^5naQsQ>r>QmjzQWp+wRct#leOTwYo`{|m(nm9bijYu#V&h`5y{laxH zd#y@%e}MP|f>izR66gRnn_fP+bPi$8deY_-z9B5%|8fic_iO6(W&iz}`p?@8-Y4db zvWx<8R_=WR>=<$0;4lJVK~{=*NPVU4=jHM^XO0bxb+TZjTbiORZ#n5X~+r0PYwa%l`)qJFrzs@# z)oAfX-A3|al)ZPvP*AR!=yXzFs%fq!k;2K|)PG51aqC75KOpQ_Sr;s3fkO}dL9)F8 z?u{2-{sRD`AV`~;scJw(!WXt4jcDCmUvC}>O|4Gs6W~9HBVn~!9FmWXi2X{(n1i}< zO-iT0*3n*MGN{`JbO762bb9Rw1XH`~csS*%o%d^aDPSM3Cu4E0?ER?XxC%?7p4l7F zRvFNw2p&OHy`CSL05r|tfJiapeRv-q3LRg+&*zT;V9pfksFcRb9XN9E67@F&otTb^ zgsB>f?$GB>qI)u9SwdpPO&%pp`O1b?r-&z_&DRenk{%}iq!txPKTk_r3H3@t zvNGJQ^X*{g^1yCw<)GGYiXegFYBf&G^}bM2k)C)_G6d36Ly_<0r|6x%J!P+6+1=dVnR7tuTMS6%Nb>VTB_+#LyefN%j083E{d|OHhj3l`90R@X!lOBnT-^O z{Jcqg_F1zO!8T%17s1q!^k(V*g;0& zM+{J+Xc(*_aJ%tEH0{|ll!5y$hcFX>vK?+Lxtk^?cn7tWiE#jtN8f!1S~reoHLfx^ zDA>khxtUUTjiw`Lc-i(5XW=6|+XhDmWg&I%O3Y+|k>aw+)9n?h=0ib$@|ki3LhdYX zkIhcvQ$2B-?u0G&s5Dt=sBn{vyMmL`dNjCzFI_kbcP09&sH#53-Gaye4RT9O&tQ@p z$(1(O(+Anue%h2Db1S*4_m&lBeQ7xb)P8=^q~s$mvWB^>mmU6o&1|S;Ad8;7ovzrz zM~I6>ev+^1`{+vrv3+}AkVSh!MDu+Gi(h{VB430u?LSlKOIQ3YheyFXu3C~Xv1#C) zZ5eFG%fV#|9KYaoP-233K_kk(?w=yV!}kCO78YDXvl+QVg9JzNkY<%1OWZisi3a3)Hoh13YxKOfxB;Gb0{xslK{!atpb=qT?^~f;HP-x`c2u z`tj_!=kdXu;L^G8G9V*}FDsD+Kyez>R;$_!KYon&-SlV1+D|T4*75Vl>)qWl0*4*0 zN5L=f+|DwfiiFP1wVmmVK+C&(KqkB zV*1-V*Ey0T$b5Vb1}!XRnVpUgw`o_Q=Eq7Lk*!-?SI5o%@Zm!)8(y zNWhg-RE@3UVhp|6R9b(b2%eUHZo8f!(xkjf*YTB7%_Ir#L z(r?3>4Y4xCiawh_2z%(7#HZ?0!Ws9IUYZ(Ku<(QUg1Lz`TsDdQ*G9^Cl)+oV)uPax zzPi{HlP=d+9}rjd3y;`lWn>qG$0vh_sm}PhDkvyI->jRMpR?{;qNF_P@F6L+UViA) z&C>h$4O##41(X32x30Afk{v`4kZMK~z~<3fBW71gxnSHU5;~+xnMvX=e6U=sIto zm96z4Y)FXuBU4{arKY6bX&V!&uwm~q)m_FVlh_1xCEO%FotfN!P?QM%k!?;GwX}`Y z7*#+I|8mE#%wnDE&?uUt98WXVv|OKE#Y4U4ccTa==W+54fhPT`mN{~irXqt6iQ^Wi z62m0Fkt4;L#g!7iFl!WwCPk0Ud@In4V|kaA?Eqc=97##epUamQn^9VqR)-2BYfO;L zQ%a2A*W^ZtN{=DA!ykshv*la((b37XF|BPqMw#75F@Ku|jl~h(Vwymlop?1Zi?ne0 zgnpi3^7?fnj2k|VG+kTU_<$E#$RZs#eSqvbHil)tA-ez zyzDX$ZKctRzoINQXm*-;^~$epgVr)zv6aTKequ7?*U;Aw2VM$Vi2HB>a#t!b=TaCiS#xL#ZPNa)~kqzkY&wm!C#EGWM!Tv6BL z|LcmPAZ@HsW-yj|T6NZPJWFF57!4ycj&4&yQK61eZZP9EJU&k`vF2FN36pxY0jSv( zYUC2Sy;_7kUy66x^L>Thkx8uH0^`%EqO5lbm6Kn*JL(5< zTi$_y04Gb&c>9`MK*ahft9)N`0393jL;&##q{Wia3U4+Z5}%5td~~|KncK&O^hPLm zPPx=<0a&hc_^5HfHq#AZxr2j)*EHp?Aj88Ifhj_^J4AyaoF}WzQC_tE_}=8wAS;Zv zW7Vi|`g^W3!JBm`eT{mnjdOh0w@W3HiYt|xz+v_rYab1<^C_~D6=RoAvr67k09>Xca-I``}2LQIyr7d z1m8~I<8UZUl__+1T?d&?sJ%RxJV#dQUNFd+9_&dWc!S<*LtCI;(u#A@PmVQhKixXp zUPY!5L-b*x{}$qK$N{MH$5(BDDoW0@CP zl&LnWSqvoj^(NTaA@Tw#PvoTk zfb;*S^yVLNR1P5`;0F1~N1zFy=jL8N!CI5wcsB0v;UPK)pf=zSIncvz(t7)aSKxoa z+he5QX5owXZ@*LT>#Zq>a9cd#AT%yQLRTP9si)X`A1n&MFt<*~+oPvMgv7*pf>07o z4~qH!Kp0OGuK&LbgtQkY&qJ{)x80vWq+%AvtFErj!2zy5cYG^f&$*5OVq)Tx8nvpM zK#*u?nDUELc5WSZzR$5U%rpT;1V2oC62bSc6i-MTW!_Yvpiow$eNn@m>-2?+@Z)|Lb&?g`L@ zgoi_yZ2@lXZBTW$n$uNKEO+m^xl=-8$22yr_qy#d_RB+@e7|f<8dmY7{wWO7cidiK zUg)!66Tvl1MNACRR1PhjZ#|)v_6KpR1Xr%XRikVGrcs3cU^MZ|96~V>^o|T3OzVfw$$=J?a#rN;uuP}qD6GjlL zHuzJR-1=DzpHE+If$N^8CiTKbi`5Skd;49$6xdC$2QP(=pvzxc?5wVy-l7ZCa;JF1W)LO1 zc7cYDu z2D3nK=WH9p$B)nYsCmE-DB*dQp-TM|*|!J-*j+C^^Rs^C78D%Mv;Bm7TpSB9&7DU! z2xsYaw6wJR{aZJd*Sh)H9RmF8bm9YL_Es|8xj1cwRCT%5c(ygtl63aF?@M)_7e3zJ zN`=O$$%RsXJX#tX(-CflrRMREp{ssxW%*9l@GAL}4@Kg}EdyJt(Z~HM)p-0X;uSxrF!9)g-P#m>82r8%D5Qs<0*9`e=mh`_FIX)TPSDg$uGdsu| zIa+I?U+b?fmz3qFeV!w`z?Kb_mKHgd!|6Vj|iixHdG#Rb%+AyT_cVjE8fV zEA}b>z6GFi2+EG9Z->2I(%hfslG@X1ZfTKceFyH2Uso<(H21uuwmV@&a|N~7BP->} z9qS{2+`L0S-b2Eo`cAPolkMjF0b(+J@c%+Rz^1X@IYmrr>iD5e0Y)Y zItiWqAxUS!BsKRqr=fmGV$_Q_Vp_{{8f zp=!vV&r@@vANiukOb%6*CU-YT22d$fk&%&jcpv*3Cc*hMUb*V#P9GK)B5|9$X$)m( zZ(oeRL@uQtcz5;X>R5SymWG*C7i?@^YfP_n&7WH&vaSk>M3k+X^bv25Q@$vtPioQI zy%OqgjpTrp(=Ks2s5Gs#GH&(wBx|457CmKmMP>pPQfAgCg8Y5 z%06x1Lrm1RQf&L{z!Q>TzJib6ZfuyXZPg3LwF(Tv!%h++ARs(@)}8-5@l}W~*VHMt zjTptknUt!*ZS_c(Q6W9VYz0PfMb6r;^e$7yIo%)MuBS35vw!Sn6dU13B^XafuREl5 z@7J|YD3d|Dj{}d7hYE~fYtMJa|5V|w!g_QyesDH<`n0On`#Sb%X^_577v!(Bm49R~ z1)aH4lqwLnau`i8wL6#Y{Zm*_t-|om{;xahk#(RqRT>ZIm!#h|6|G37z5bRPS-x6< ze-u*m*kND;{Wkd${&3H@y}{n*I^!|Br~6Gt1?Z{p6OYAkt(FN6Nr@6{+YAkqf`7eX zx-4gvGmq((itBD(4&F+tY_bDXSb2MU2L#x*Tqs%@>r=2bWc1!@4rS~+zZt3xO-GQ& zg@yHpe`}7=MB$^Q>kF5$e=>_%wU7*3uiEaYBy<%HOMDY_oh+Wapu){qmbWk(%7wHZ zOTSZ@H;AwuDO!6}C3HnZHD@rr@4dZr#dV_@T5y9o2yf=Ocss7FSvOoLiv7EDg_xeI ztBXsRt|vU4Q_e~S@$|5#hP@mDQj$=I&=nrVoy3jGF$GLp0&VW|XbKOu=d5WG0pUh_ zzb0{80M-<&pl`~l$kTUsQ3Iu`?&VY~oCP^u?thB=>n_ChWM!1sU+}FZMELoYuUXDS z?`y9#Ew3i=4W<`%eLh>%=pBiLeiSb6ld!&aCEq7%(`JwoSIkFEJ=9SFg}hx5E{B;W zb}n%gg#bE)Oi~^dLQX|e(%%VRD&p^7y$|11WZ)Kkd<1P2IxtX;M~e22?3zvuz9c#0 z=NI`$12t|ClXYQ|Owi@i;_vg;!OD@0^+_ut=JKtI&?gy-(o^6=cxMv*57 zTRuKG+k0&$V&d%;FP;13Zr3^JQ=jcXH{|zGt5NW+UK0}pnfiSBa_#zc>ihRU)Q3i1 zGo;04s-XOjsZi3&GKH4dD3`Bi-%lmwaw7(0H`Xl49+Ln4oONWeRy;Vc{BZOG>b6IR z74LD-OFqXfV}e|ViO*9F0b(g=W{c5B82fHlOW+H@jD1Pj6gr?K z_GwvSH-Q39byIhbv#;ho`C=msOxd2Vvhp;l;>;^SO!I86lDnZ_#P)#8cFQoHf@cp$e~~xn^Gl6|`j3T8jAIh)SBSgt?}XF6Fo*J6*cfC`g3vz)tzy!+mpn zkdu$I%vhAS@qWCTrx0{`UQ?K3(V~ycyr%Vm4V7oOyl#t1R!kV)tZY6k%JgbF}ZQ|Z@h=*iCqHQBO8SDMLPpmFbxjHp4fTX234Hvf(6I@+5aLP5(b@*5qf3ha_%`SJ^bxRTm@u&-Ngq!r$&U?OtJ>_AQCEKx8e*X2ZH?D+pTSr)-Z<2P z8oi}*yy0PUk$}k5m1(=^#$%^x>Ep)N<@)_At&qb-GqbMVj&nyu^{(0#V*~zj;9*1V zdPh2;$83+C_sEo-r-t`Sh#lr9LGl(DJY>Mune3HhJ#29QBNAd3_-to{bqM2;w!dYW zyVn4A8K%9=kU?F64wQ1MCm3rp>|juUFD3)uOWZc}*-k|k8OQ@W=qyuN?#RqK zPO{Q98LOBBvF+tyJ=G4-FtE3t9De9<7$Cl0FOjQgJpG3a56=W!6XcU<_*|EC&J5Ix z#YkQr1rnpler1wC0vKd^{6I}zv^geSV1EKMy){X;_KQL| zjitP{tpDh4opx}q6P6oe66^<>i19{1=oM*Czi3Q?ugUP@q?(n6!(5|Z`5UWsK^5UR zt&|X_mo3LdYk>+V-2GpQ>4@D^Y>A_D{f^au$+N7i0Y9tDY3R)=<|s85Z8y4vhvS;} z({E|Kc0S*tSll-KE-PYhtA8B(kYIl2jR1J{)^qIjdP*~nF-4$xt*`G5ZlL6;uf*}$ zn!-#hLkGamSpFN&KYYkHc3ZmJGyfhB4=+j#y zV9yyiV4OxLCftbN@}sOkM5q->#{>qx8+AT(9d()e6JJACxW8AzWsgbt)gib&v^s^- zG2Y*9JMO{t=YkAgOBgd)KPT^6SO8`xyhRhi`&8c&sg+g0s8}Ld`UesaMu+qlcZngh z$7|6o7Dp+#p68omi3R`;%p)hKhpgx%l>2ET=OSPLqN*bZlY8j}1xbaqMPDTq2#O># zmYYV(V8vS`B%^)j#FMO87iHPxt<%~|(!-lC$rof++(Bwd6cp}R`_bE^UFa{mAS;lQ z7A0TMZpkV(6JO?$Q#kN)7D@5L&?&Vbu`f(n<+dP)iNXi0|2+L%tDl}=O!NLyznbIQ zRs2Domhh~J#}-N;y4~hBEyI%8!pc%;Bj%dIbN5)nSTzn!R>U(h63&O>iKjlzc-cp{ zf$cBfvsSD~*Qsz|cGQ^nugqc&!_Tl4diwPHxolo%5|@P{8ymRpEVCB*(BxW?utQ)B zQt^6IFsVcTt(x9Jw_Z&RdXD1J3laW3#JF%)u^Rm{rBScqpJNLjT#wB$5!XJpvj{l)rr zl^k2FOmfAX0zHJOxcDJs8EU4`FM+;f z|IEKN01I)O@8IEM=OM_qwH4+)Z(p9S&7>Rcx5jB}!$@^!&9_2UbKq>yMqVIx>4WHM zXv#?cDL0iXZhfzsJK>K9NdrV%0tST|4oQS*+gP@EH5LLbH-L}1AD<&;Ii6=RrgW@X zYE30!vK*nb0Q;wZ2F#IDExYoLv{=FCS+d_;Q#Ced`_qo{ppigQ+H(qx!kPQ3Wqc~M zkMdiF9pqG)0M?5~t#b2r1d2=szoF?gM4 z7~h_8cb9x_#Q3U(dmqwKGzPt*v>{W^cBVJK-x?lx{H3rY*9KUJSQt}RhVjpzKNpJ? z&l!QT=)iPgk>^zj2fpI1kxVr(Mv}h(iSPXRCm~;REA)f9w(wNkEP=A$w#@xpLN&H7m_@7>uh;bWK)hQU4jr{ufhR#b^;QjG${U znw2g$+sv)IIbDNkGgw$yZXUtj?*tSYF7+0=Lcfq zj)ArI0we4jspF<`Vj?nLnfot~_4XDXW{~D`KhKJx?47;qI*9|7%|5TO9LY@6_ueOhiU9ohXD7$EOshS_rU*9HqpID_w0zpRDBNu zB`IQ;{VxU+kJ(K0>+iU&(%dJcqB51IZIZO;WSE(skZ$`@FxuIz_n@XQ=mJ3>fhC|` z-c?KzDv3o`trJL}TA7B*ahMC3UQD(g-rbO}dgV`PD$s)`KlV)JPS5?tAjq#RnW!gr z94>g}32pdM&o>WEPlB~e77t-pChDc*z+vFcu?YDb8}Ni~f!2P!Wapu7^CrXUZ}k;l z0}i7n>X}+;H9HeF%PvlDmCYs-T(zg+!1Yd-P#9VBJ~yL%!S!0Vt+6riB8Xtj$IR=U z10tXHGBjjmPmU4C-n>6UPhUMsKFEQjplK<5hM!ER`cMpUWH!=_SfnTU1k zz|=qJxZznU8uc=ronLC6c0s zMeWd52t;PpuWWIGwGDcy3}w{(ujL_Hvx63Yib&Y>8LySq5~;HI&*6!*n(P}qJ?O(D51#!YQqf?XO!vdkQ+&nRZyGrVVPWc0}5cP(^XwqER$ zFDUjU+%h5@8tU!~D=dtSFDona|Ewb;;GnSJ;9d_o^j7t=BY=ZS<^}TE9w7_*r`Ct$ zT}@Lma_vWbf)Cpj986lnLNZENRj}ElQ=b)7Os-TbisZ|$q0)Zd>$S?y*t#c{Z*sY- z@XZxbk$jT@eaE8%&*|y5CDiJoUD?RjnubT^Ev4P7>ro%rS1VMtJ=AwUx3#uT*Iq{N z|HzuWSYDYfLXj)x_c<^+x)f$H&~Bwl+IQKdSn%n!Mb%|K6_D04hKlXT<5FGP?JM z#`?y_=(ekpjoheTIAMdKrpH)R?+}Ko2s^$T-UVulHX-AvQc~E>-XVohOl-O))yh|u zlV4m~IuNVFXddJgH3GDzel2D+guKMz*sh1!kiMEA^nsQV7g|@OO2Zoe&v&~NBpSI=kb`pV3MMolh5jg%_`@p2kRb8(s`lh5=W=6&x9I?Jiqbuclp1tw7=+qSLh z7^MBqq}Q~dqP1Kqc`MK6vfrYi16Y8$6X|f(fg{=^cNkwn`Af6-Zg{x%!d>#-F1zCO z%uyF%*+DrGl%+1a$&i$Q(@m(VGy_*;^mupzdh1%T)6=GLBdb)VAiIR1z)S*#T?05M zh80txjZV(&caiL|m=;pGGaLo}p(MFH;}NZfIm#ve(1lm}vCDx6Xzl1Zg4h^zqvjx( zn*c$ntE+=olRdVynQXIfkCR=4Xacw`Y(A?PQ={$L%ht(;FsKss=E1{bXU&VQ^v9GzEtx#N^S#{>c(@={}Oi5-#%Q7HVwIG$-@$KoDo~mB-jjYagLaTJyG*2TUn*pGUAo{?sDW0?JEgSPAA)LVHU=xX z4J9q464oNEURM4(mte5`ww}Y;GzYo<=8NUPII;%`Ua7fak+up{C75z)v;2h=1+&aH ziunoGV#@j!p(|t)3cPTR?~#pscyvxSF@1&jS^onh9@_ZHc5kYz<&ZLoio6-~AMn>n z1$^5D*Md$7WTe4h|HGv^c;g82wtP_X1(Tv(_ga;B!Vdv5LHpkx^{BOH3JP_ssx@AN z`w|=`DkXb5ojbC#o2jxORN)F&IHu?X3qLyAI+m;{=J8_Vjt}VX-Fwzv%$Sjy@sqnh zxN(ic=D-YQ3XXx25y}{*M0M8kMk?>Mp0%GCls~z^!|RY?R?JX4stHJrR|C`U_XE*l zay84Rm`hyvNae5j4q#kU(pE-;Z4l?>?vv{B$~3lmRj=UK-@;v*{5yD(O4uLXsEn?3 z9)m^#^T?s3DI9wu9@Affbxlq(mFZFAwHXR7P^q}AecVWiW5Aj<>VdnCTXIV!{j=q1 zMnxJvaY)zS$Cx-0w1+_!UByvD`F`HfZbz2Smv*H}Q)Z%;O*y*(C`=&Ic({KUQ!M#J zh=>w0(!2ClI?*!`cgPQATN>e}f1|3p1L9o}S_F{S1O zQJOH-q2^uL$Z=N7DllvB+2x_G@$_m$t*ab0z{N zH>&I*)`^?%z_(6W>UeT@^*e7Gec5A4s`7HjSM#XU--r^d2xJe)AkmVva!|G3O_P#x$YOwv9DgFbSMw|`d*gJ$Vh7lb<3p0 z-0Kk^eVmY-%$JElVpYEN4W$j8SNnE%JBb&e78!4JBPRV`#P$$XuTn1Wp***mH!_WJ z(~7bDJ?fT`Nijp0WJRmdiRE_2h9;`O)=We|dC{p~k|O zR|Vsn65ITDJ<-RdnTNT>FU0!;PSl;_9`rr!gKW#BvYcD8hd0yMJ2sn=w!C?(m*suZ zI>faS3*1BBDpDeM>kXpMZ#u^J8K?90HxwIe^zzXP7g#4B$``nfFK-_``!cYJ9l6WPex`rXHq@qRiqbqkf%V`0~~n z8=Y1GRa#J-^8)fW-KDl>!^^^AE&L1oy5NsVTZ@cY+4dp7tCv$>pY{Kcb)I2OW>LGw zii)73f>LCpDhMdj5fDLokzPU(=_T|cB}Anuy(3*ZBou`Z2rVkoo0Jd;O{7Ue=ry#n z`Mz_VKWBc-%r%!|dEa-hz1Fjyb+2+r4KLr3q~Pl3TMb~)y;fRBA-@$b#Ft=ng1?{NQJK(kI}^7=y>)5N0N$qiJ*N_3?vt){>4d1OsgUI10x=V^yE$gKlLU!1s(y?mhp`#O<|wFwFUh6V9Q1) zlPL8Gpm305!hZmx3oPS+wWrkniXRwfqsx^Id~J>mGSj33)+>Z-KV6`K$d^>OpP!%! zr4|@en1=N2MFBD4+(ET$+2o9({=pyMv-GYyEIo|q$`DNv^^XA)`Po1Yo&&BP{c|G` zqGfR9-%x9u{&`WnAF!8#m)Vs3z(XCefB|EZTK!RAG)ZX#RHQ%sw+3k)X|QE`pUp7- z(dNmN88t!T)R9WlMORnm;7Cf1{lgQQA~r2VuHCPp@j)H+a#!i7^~Y|1FeUfLr!|U= zp}od%)8Qp=anRRGye>Oke*p0e4gqk0)YMcZB_-s{oxVw(B?9x;@Q`;9YYv-kFd5E| zF%S(JEQoOLtet-2$`SnGtn|{`L2TdXq42Qm6Ro%InSo}d*IsRt=5%K)C4Xi=^BYW4 zb1@5Ped91EEuc?nnzvTD9KUg)43r+L9-Z-7^>EV%c1u8 zRwTB-Xx4J+t-goO+@Hn=Jd0K}dM|0)Ws8_qrji+xL-@m+3alTFU5c#@XDh2yRnJTQ zB|f=Q0!9dBCB1%AR39~g%~Zf#S2fLP!!)hr1iHF1|NZ+xIN~y7O|1Gse>clPWn+KQ zF8y$H?-wOQKjw_Y=!fx|A%J87112SdjOgIxlD5lLKSmxJYqo(QOes%8TTm`{x<3f0 zIy8VoiV}JQd1X+zJO+N^L#Jl(iC<5+Ie;#x$+{o?}_RQEv# zp{ZfE2~*jGa74^qI=urZ5kb^+3oIMZ6`uP>>?VNAppi!gP7E*#mZg7mM`?oD-UEF!MEPbqPP_Zg~_Rj+eXI)1ROfG5YuKSQ;?A`3tRICq~$;uP* zNvYeAe>^kAgm30~--kJp+h$RcilBz3)(P_Iz)q;;fJfZuv!?i2T@k~Y{a)Vi@NH__ zWP=1>b_);k*IW)9`?hY_`X?ca7^sA)ja`=j?WK}CoMiwvLBb-zIAIkpjNL9VGc(De zbg3iVwcMOB=j)flE^Jxs)I;4OaoyF#Vu8!939QVi7@rNa=J=- zH~r^^u%J%)b|`OPVBnk@QUG0E5iUIZ|D5NpWqNL9*-n>;FF=$zN4?Xn>BAyU{MWnv z`PvuILur)q@o9|RTq4_d{TkUer?h+NKKdFoaMKTiFx2Cb zI^(TQR{#()?TmsWli?~gDw$?zc9rIh`Axb0q&h(rzT<0`?fQdeNTh)M4I2sC&%*1i z<~O9}3BD|nd;4Gncg|ViZ{<3fE@)XBEbp=^?)EzXpLX;?F8b&D%(jx88zbfL1EUpp zyKHt>_m|Z-<&{N5ui8%{aASh~2&W}=E$5|a1VFYlDRZl;sY1Ec0{G%N9iJUqYY`?I zkK^MvGz7H_Yc7jcIyp~G7#a#AqQ`vy8-GjT0rDoAo4V!wa%@rmXPd@7Mf|N_?d{$B z8Jj0G*n`Q{QxEj(s4$C@dL#?awqFS{-ybNCevES^DOS&$8KMo@Df_jr%ITJ&*yXex z*6~=-X{P)=Pv992VcDdltKAsM$7h#KQv93i#h(DLm71yv1HFgSk*L&Qf_TY-_`Yh< z6K8DY^m`v&bsKk8)92X6fAnh>{H||JQ?~uTL*!teSJZ05 zw@pEB`!SLPQfW7vQdOWD5b4>->m8cdjOd|wT*qxE_)E;rztTLmD9J}50Bo1p-cYlE z>@BgFevZr}xG_|Kz&eNI^bffrR~yM*)nSV~|hvgW6_zuJBBMGuZIb2C-gvRwS_M zVvVv-RglvbyZ2GWHdg`#`-RDjdyJ)8&pU1Ib&pQ_a&(1haTa{6A*#nq4=HWjM8*Qs zLSHN$EoqgGI@NH55*iB}E1jJg(Ohr_Xr(D8;ca9Lf){h~{CQ4EPA%sWd#B}4!y;xo zlt`%B{ueK90)j6v`aRB^@tw;JDC>E;ymBkzxE?l`SF5D|zy0j5F$(G53YwiK%u(Kr zyhHfpUeXAzyR$1x{aM=_bos%Tg#iP2m=XM8y)8;VpvSfxZZ&-W~% z6nni=ZST9J9z23>UdVF!z8v|?ZAqNQ?)`k(A6tPLS-j%rpRy~flGb}(foSw%*WvYq zUQLIsP$HI4U!Utg;4>06J+|Dv13VgrrGgOZ>0)|8A`gKONYe0}J84pV*tM{Of5}!$ zjC$$K3Y~CHGgz&en5z>yCY1&oXR($B#cj!!J`+ApbBsBDon%+qCpI@VniIy$?WuRd zOwrX&#fvrF-7zQ}DPkz?t;n}0)d@mKH>*`8Pjm(hbgVSk&5o{)eQ`=>wnXVuN3*PZ z^psvRu*)ldDssM00U&-o1ZyeFhuu>RwyfeG(S0nk2!E9eG_!Vb!A#uAfWetB88WPG z=^Ay78mU8#C{uv_L72BJ;{xtl7%p@-C4JMZM+o%py%PPRY9%HfgI4v}u$}F{P;nQE z^VP{}R)B_{^J|Y*ijl0~Ef09g?R~^BuJ&wX6M1VMTA^@{m{#;sXKF#a*nHTQ&0ME> z0T!^-uTGkZpBrh0QTuf}9v$@UVgIB9F^Iy6dU`1|1Yxo?t_d^jg6B~tRZi51IR$!x z5ePm-*rTPGTlyb_4X%Hd`8!O(#m{~ze!2a=61yIa{Y%_Hyz`F;)|XfjA5?}R3bLzGas_UM)9!A^UK1MT~#0|rMBM6#Z1Pcb1HJ@P)-9#S~l zK5SVGo{DZ$l1y$bBb2qQOK-lYVH0JzYO@Lgqq&$O`(&gi8C3Z2^xek0nbib=b^{UeYm@h*y+V`T1D8Hutg(u{sAQRqAx#<;gG43rh9hNPkCu9$V22oTT3yTMmM% z&TzDWnJgg!SV(|~0`^|)j?JtOpr4fJpm5-gL=sDNP|%Qeh;;ZlaVzbg%%bq7@hkyYik-Ckh3@c{6ZUO_5=|R)$p)vDc`=~EI^Uk5e!1*&~pR?jqL}M^i@c!HZsDLCq7am!)2hJNFi$L3kMd0SO9{W0r zO1IxXMHgeMUk|*=U=4sy)a4!@6nO5QgaW~S%;8i?_ukvk2SngxPHD z?8HQ8-D6|x7eIi&vyBBjd13{~k?Q$^ol_Nq{{`~Wt@O*hCiah>S}*GE`wM5PDwXQT zdjWB+-6Pg_|A~#{9fn`oVA2naZ`JK!@me_&2g~2>PG`0T^(BTZ_Wl=6QumbO1h?{O zOfdS|NI3-mD!CF@Bmg@{Rp%_;(c2sS>B%+fFF${DyH}5?iLC$Kn?9y=@|1g)F9Q<6 z3lC^-je~A{#m;Jf5b!rgZ{q$7+(p}SeJdMGKCFklY%ONuG*wZXEaKj(yHCtj2iTIq zdVM}SH1u>tUxv%38>k%=UkB+&9~0qU8sE6PTQaL>%p>an_^KHb?htpgTBhmX8k@*OlGFUJM>0X#EG@XbA2W9O@fXgYXORthCM5-t z2^(hFhG2VqpQRg~yZFY>VN!MUi3tbQ^K)Urz)byrt+%)69Gya@+S*Dj(_X1@zRnVl zhXAPMEUw{aIJVfy<2>Yh9SVj~I638;hlatR=F^_XIQmaf+W zyVzGi=BPQH9Jwa==oFI!2uCRne&yV?>B*`dW;H&T-ehGeGNZ&F@RiJQU$fDddw=cd ziTk&*_I1Q?$~C}D1M`ljFd*kJhhAV?QhWbXkcQlZ%KR*GM-X3Z!eL=hGRIrZCZEe^ zp=dx>6URiAJuy`rDRH}N9BiQ&rkdt|R@6L8p4w8=Bglw*xE|O8K>GxU#;IU(XXQb4 zsD;z)C84%~=j!&DM0f02N)C}_{-EaGA~>? zw3TM(^AHH>OF2yySM{%>%&n_+Lsb_a?D(6Y?&<#$Hp+K+j9q2*A^fYGRVGPnyS*7H zxW{fL9moY<-^B3L^W8qa0aJ#%QY0?INQyt{VWPbniiad6BD5!Q<8;5gXn>g z(SQnIP&*ntXYw7Gx_4G4$K_-%S9Ofa-EnLMZEPv;CB3EX464_aXI4Nw0L(PBQP4L7tA50y;FsO8gs?Jv2sNSm-5*}R70S~IR2=A~c!K|8$;P ztuSic7_^{E3Mmi$xjYhI7X9?ljkJDYyK?jxa$7V-&szJG3G&ll`hZSvc?ZnraYX`w zSPX-E-vRpih@VB#so@@pHKk-cKz{M<3iU2>{I+jx)AVJb>r%x!I3Fk+Tkp4w`#PU2 zt#cH;1hrY>q=>EhRi&87we|I}I(K=Om31)VH5+)@v-2S{=V()ne^z8Mq_Ya(Rw5!~ zrfZ~hebIl4cx=Jb!-dQdzb9bURU#zu0BPj{ z*hj!+00?KQcatyRst3`9NExZ&63&EJoNB?>G9`x69s~`*3UY2@o7~N~tq1=6i;(f1 z%S{qqF!Z}@yoJSABDa+-XLU&A)XxE@M!poXuuCK1@u#1}P(A7(4`8l3oHREFD}rZR zZv4kGN<;l4HoX3=c!^o1p!(HI{;!VRh62YVz>+6mZbG(NxN1X&{|lris6(B>KY#pS zrL%_r`eY2t{ERuUu?8mOB3T6+W7y;<==R_bt9+=Wj> zxPpaDX$lc)Af0_DywiX@;(aE7@vX<*yQ%T+ca1rcG8@Mha1yaHgrJk=bJ<6=-Nl(r zr1qyzpGt^Md?23tWy|B+e=X?8>;aWAaJl!ASmz-PK^4A-g{2pV#m$2uzj3G?(ebAA z(Va+^IKaI~58`diBeL@>&PG^}{uU9}pMjz8jO0PRT2W*U{O@8T?7J)bx>>$}h4w%|0OdP53SgU62pJgs0T^a0n0nh^a(yYzG&a-(ww z@@H{A1}?x#e?(c+_|zCDz;Y6PUiVCAZ`&d73?y&uf2G2_T)_Hnc@H*BcE81BE4`Tj z(PzXh#5@)lL;Fdr@CY>UuJAG`Dk(AB@m8=v^=*^2zJLGz{PJG}#Rt%j0nOYCS^>F+ zVwUxl+g)3WVqG0;n2rL_GkCb5}etCemH$&k#Fli*`N9PRu7fK7Ub0r~ITXLXT-<)T(cbiro+Y zru2@PfA@&>qQ#4Bu6f+H9sMt-Z^#=|*^G3eu9|5e#{&b?>NBs$Hj+vBa@ZrzQSz}s zr*Ml>+h<2i`UGX$V33OSzqj;$V)=O&xi~q?Ofg;JCquJJ1Yc)aQQM!S$eV|UkhQY}K{(1y-I%Q?$U}u~bumh&TR6^dre~+zu*&E-uM-$G3_HzMaQHqLT!E=(p z(J)p$Ia`bbp_tfK##^NJD@g*(PXf0^>3$(sVT_>%Xq90Z_`Sy`i1Eb$)z^Kp{FJA= z)&++s*m@(P=oa^GTvliUV8QMjoq0Fw_DWAr99EAJyM3E(0vY^xZ1~^(7VGMAPbdHn zPfX0tne+n~XSMTK)z64-e)Zzlex+q|hHs49OK&ai zgn(ZJ~PNnZfJ(lvhp6pXEp9tSpE%p zC^U&qyv%&te^0ryEX`8~4^0ZsOlX!Bj4e8eZ>l5Oq*?eh1`>wD1`uu(FyYE)6(>($XGr?YTi_nh&qkAD*Kl5g5ziErvUW0j+KuNjqbi65*N!dgSj?Z?X2(($dBGd%S#hw`RlI%&{G!84z-;5>P zCUk8KqRd8{%vaU`zkq2M|2`1HQc_!1Q4J0ifW7+Lou^w< zVnL!^b~B)wH9-1sZMbh~w43Q|brDW}D4yhWvV0he_>dI34?%?uKy;+*M4#mdS)n2p}_ zL#^&EnS`tk6mb!WN#^5|#krOcHi(^_!`WnWR3s$L0hbwCRPv1$@?t`Ph1QNXpoU zSSzpeIeW?*@7GLmM4U^pqd7_zZiGd-h{oX1P}T!($?iXn#D+IAGPUd6 z)KqrYs0m-yi>BG&2jp5ws1djLjqc#JP_2xqPT0GS=(N~ra^G7kAMZ#f<&x-_nC6vw z5@3YWXLRVMrAYX&6sk|Nr7f|5iH>J>m@@GC1{KG^?hq^SIQhrs0)#d>u$1?)d4=x8 zXS2yS70I_Uva?-$;(txtmk%2)T&xrwZk61&2dJa=Yt%FgN1Aj$-eT;pNzwhpi@5ZF z-#@)Mf4H)-V|zV>1Uz12wanC1&l%~cqf%+ciHG1m=!rJIM$=6s5>XTr+(~K*9oIk? zyGTO00>pT@)65C%@N^=>aDYM-?(jBQ!tucsfIs)@cl$lSu_a_KAgQ?sHVs=gPRw^F zvH%CTk0PYJ*}wyglI8|+LM}&wg)ZRVc@4U>x>=_QnVb$1Tq5P^w>~5v9#QLFBI_M!(1t!2|LPSw9Jo20cW21G)igcKCgRhl+#&{Y@x>W1 zW0@u9YObR4DM(v!AtehVWug2wsIy7|{DB#vU>){0#(=}!;3edM4#iijt*<}7C!N%J z6t~2G{;Oio!en1+N{Rsx2|96YZXx}=SAJ~CWW4zIwTi5E z-z(b9?a#X(|FZIazQ>-t=41ZTDsbmLsox2gmm*f!^~b{c&N-Es+nw=_@@%h(y}2Ukcv80f0eYUms)=#?{~* zS}qokzk5fu&un6D-Uk_2xS&>EK2|5_tBb?gJ-m_;e*a2tMtCqG-8H2#&YA#&4e`$p z^_h2HD9*n)lOZkTG2dkkV`c_!f)(tFil#~PTIoL*e=A!*7IPSbo*C@}MU7jWZ3P5OTZRs$v`TUfgtp`iOCl33 zQi_X69bxd31LLmiJIV3iCfnQbTa?Cw6*&-pJFkc=IxY4`i<-&Zb9Z<5_VzX_dv0>i z-VhnDZ$;)sj0+BVOTK4-;^d?-Lx4`kc)%Dpf5ZQ-AS0b7b4b7aF$jN5w}k)w__}_4 z?p&68{8b%33 zi~91iGIcF2u{&1_3k!J_E{Xs&+3I2AznK}!TSa$cz}&w3%=4Rf?>;2G(xquHxHto7 z{V|b{z>PVTq1*MH;9xr$=r~j*I@z6}`f4Z+t`|hvVc}9%B3*Zgi!$xc)b#q}P)75; zMcI>5>=Q`CPV&kctR0B~L(h+|P!q&A|Mp3(ioac>!Z2uZii#>uFTI~50Q-qoV$d`H zH4-(|cV(>9!J_qI$tuXxO!3EQw{f@BV`64i=y=&AmE?uto0pzOd`&02=EWH@eaD4Wy| z+K2wRk&nwWe!2*5!NT8v{=-9w_0fu+RnN z+PY}h)g{x_)e%>mmgZWrkJSqNfvd6G|1{Wiuwk?Gv*ab{sy;@HTv&eN_WJjFmBov` z+?MdBrlWf-%)CP$IX=^0|0Kv$PmRXkH>~UdBkP#Mz^6Q*FYgIKhOMBb!DNr zKsQRFmT5YojeAXn~hEs*NKAGQQe2Zs)f80`j-maDL_J-$C zn+D>2-LpP?V7ch0=m1rLKYNRRKPW}5B+x=3F=!bXnFN=cK*VM2?CI+20)y+tZ?ixO zkY!PGmFk~=DWe-p@VQ_aUg?Fwt(^1yQ|7VKGV(WXLi0t9CBpg<;^H+`!hy z_L$V<7`Xie_!@_{yM7_w`+)uLoQ$A8L~1s3t`-#*j@8oZvDfU>JQe|MeM=^Do0y(d zT`v4tDib99BuGFO|NbF}UPvTgTXfpx>LqItP)_ad$KQ&^x&Vh$K} z*H*zR=P)<@&nw+HYEg3O?iyVqAk8EQDJdzUc=_`43YY7Ea*=RDpR1y|<$Yf)hQ1QC zT5+h;p_QWEGgMzrzYhQ2nu>5bwvmMr?kljS108X<^P7x4lbOvA`H!|E86AtY;Cj6* zGKWE6dYMD1R}sShCx-2*B04#0ywzmlSPjK0WUU;@d?)=*Z<4x0yXr(qNeKeMYv;gp zhRTgyms_*5vo~AZG9s`?KT!J{KVQrEcqK{S>xaq(I=Z!8Hm^P9PZPloat1D))ULr9H?KwZkDuEg2!VW3kz((ZZ?rveJd2Ik^M{pAUiz%WrN4%zVC5#`~kKR zSDbKXY7(3J=Kbs)_h}e7KzCU71Zf55d0C}g{w{235K9$|^e-&UdP0t;0zdt9EFuzP z>`6v*iVC#6@d*iUOxz)chN;=v0%|rdQhb6?$)-|J0!QPz~~+{?vN zi6|-gH;&K9`BI3w)(%H!w7todYp@|;_^dt|KuU(lP`?p?59g*{6We%2a%~O51 zOlB3_t)EeZ{&`a86;XE7OK}xU;&>js3NtzckmWkw=ZY*TWm~gYMK`o zB_^bJ=hz3-%(sH&hdX4;?@X2&Q6A+f7%RKdqtR30(9R3V9~eNj)gTrH`TwXY)~?1mX!1HNb=8!Tnom|D)Uk1O!_L#};9}Ig z4z2Nj)foxOlb9SDu7RS0f|9lxq5E3k7t#p7ponbz+$hOu{VUT?%X+ov{^?FYyJ$3l z@#~8W@dcV!JP(WQDEgv)CPqdjVSM?q17A9q^!&!Zs}=$3eaFP6rrnDB-jj-|09ftih)RA`LScA( z=W5KM$Rp@;p-+Fpa_*LM2!_~?+iE@fxKlsVCgx-P*^IK4*nj@w6p1u$!OeLdGKB*t zyiHbE;20yG7MG0EQUxf$gH;5U=Jylm%ALcQU*aTmCYL1j& z+*3WR2Ik8#Dea1aQsy%Jfs|gc$)t=1`SOSEn;Y{Ka3|J0rUa>Iw=OO~IMg#A+rWRw zxVp-wO=UJO{hn#Dv0*b7H_&w1se5vB4nNRPyI8$`d1EU(AnaG0bYc1T2V}4$L>_E+ zebE_8Kv}mhjG$Z>&++>-BZ=+}cBm%!p7OqfWl6%xVZYYg;tU6uV1>V5i&LfQt-k3D zS)0GNYJ?q@R8?}0kApeGx`t$%s}$LirUUT*VSN05S%J%<)fB8pH`-*BcVEBn%~4Rb z461W3S@{==_IFKUwsDxsM|SRjsI*`Tl@L;87aq4LlaKF#-p4zpWmM4j_%E;Dr4Iy` z`_#h+amDm**prp6>i)JPFE!6nO$HHR;Zw~0J#dR`jRz5gdpf0euWD-@`0qk-x#y^< zmAQ-30TtFu5#=3m-%Cu4KtRpg`0w!J4-AXl=6fV+y8 zX5{Ze7~VYj4^lenO$ovc7J#=fO*fJ0KqmxeAghuMYlCLF)8cZ7g{9Rx!Ezl6Ge6p% zV!^Xb>%VjZya3uqBFZ4v^FDfnvK8EcMt|Yv!~=Mu)G($-`R|{lAs!|sCQbv@2hRY< zv#V0Kg>8Ma$qM9F#j^Wzg-JL8jV!m$F2bQ;e-@`WsF`$|Wlr5C8s^*PA#? z^^PtUO_xn`yZqsYF;mjtQR&o&3EYf|`~-0I5|>tQTDWFaw@#=;$3jJU~>fATT3^ttJqhr$J=Ex7l}>OyJf70|TI@R3rz39DAh9 zIKPM$Z81JQ;g1xBeRq2mbn24Cp^v+{n0JRM(HWc~%}pdhwbW;o=_rFh?U})0e|-_F z+tcO_|CR_y+Tz^wdUV&Q`%Qh05L#x@6~WP65L4{$HUXjQxC;~y4q(tX3SrA%HWu3b zLkCq8RB%xx&L;}(VK4C>ATdB%>4!kLg4-*)&Bg7o?e^cFp^+P#3mGnhi<6e};fN~( z;7R+7%G`&$9`A*>m5N^V-rg3FHLP=dHZcham?$XZ1JWg6;YxY!eNE1=wj)1-Q9?4q zLYCj3Iky7VZ9xp$eyLnjA%N$EZ@-uG!vZV~kc@6<2(Y%c1}fP+Zen7>wm)XMXkf&+ zps)};8qDD{rnD4vDEjm+)%Du@LSead=kaso@o?h1gSMC`pnvfybx8vCsq+v_e4?JJ zuI!U!;3sn6rMY@ixcS;OUOk*x^^x^{L*Fo8Rum@tmq_q*dwm`LhKlN+^|x9FRgRNCTt~Fn*X>6qA zF$d1`={00MG1Ib|Dgl@BKuJ@>K(z%36PXbDx^%?gXI^L@7AW1|J*cUvafuU}UI;&X zW_q^5G33AE0s=L)h<0S=KSO#^USS@wfRP@cLxn^L^7a|zOg0EwECl+kWK!BjfnJ>O zj!UDfWg7k)U3N7!hwoM4d5Zroju#dHP_EkheuIO_-l@c%k@Jz@W9gboFceI6Nc&o) z@zW}{U)d!7Q895)*fBV>m}yIH=d`4gm`JecuSHaz!^Qk<)fepU-=|p^P( znUIjMkfNC`T9%na&Ny501^tKFV7xD^{D(gF4N_h;-#%Bt*-Hn+$+=t4Pq zt~Lj%WV$ZQZLg3^=ey@{-ShETHA97^ZF<^1Fv6iY>kIefDE0X zmIkhuMwdEmX5-!1%Ch~93CxL$eci!ILzx7e{QLfW4%A~4kALqniyeegJb&-{I&JB3 z^YY0i)6V~%19)KqH~33NX3DF%w$@i!`O?4${n0M!_k!2>L}0Zg3X`ta`GMi@mr#PP zNLBE0o#PfYDNWkTbJ1bTU&BJij1PQQ_Yi+ay!OHh3JO-%HXt9-3~f>!-Q8+hTE20q z+J=Tai@GKzj`yMC-!{#_RoXYRvLfTRVVz$=4^AMrR`&2@e)%s2T&Y3iGV@&^g8HGw zQ6a2S(*Vi$PZ+EaQydT#6)kew-7mxtJQz^6jJdhFpkf0ugaKU|0PFo`-6E5q!!$%HTb)|Z3;OrbV0{$m! z>(tfFZiGGaty|W)77)hvJM{*%NaTmNE-n?G-j05P3{Jrqi#p=*A`bbzX%4B%rQlMz#CzD3;=GGhIXS}2V6?ud4`wy)u z5P}Wy*;eh*{Jgv#hwQXhucA5`Co^RTl)XKG(=Wa6v$?Ucd=QEjIJhWdJ~d$VV07*} zfbnfE^t!rSVldjV)y!tnUx{YDt9b-Ad@43>gnPTLQo6yNeUnFe*xkE!LgCEhNJl)! z{LS`M0=dMLnYKz94j_zRQYJmmQ0Qj6UGRfoFbY`*@KqJ98sg{Je9?E3kGdbH>zEDD`|u*Y%)-^Af< z(xrZZ(o@eSxeV+XE+*oJZv@ou=$!mBtoaILsL%J-Kci%d>uVS({^)>a zi?923Gp9o-r8d}~Fmb^GoQLx1tKMTqQ^`xAlH!m5v01>&_3zatj%-TQ=Z;m|>0WwQ z{m)XvdO5+Yt-hflN5a!~a{&*s52=+0FJHFWo;a*5EtP6CV2;!TT{hgC=eMEW3;x}k z4U_%$G3S3X8a9;~9Pg+GTaPUGjAsHLsDK9TWl(xTs+-T#fjfo40HhF$hMjCAF_#Fq zzzW$6Zd^a-=hR$LkuU^iqfrtd-vBvQ`JOKf)wJ0s<-6QrjuFDN+Fn*s>8|fQ7(<_2 z!Gj~izspPvQ>tLVG&!sY%!}1dA>jVtTvq@sOr$`xd-=l2a<%(@z-D-OhkiTSsR{d8$TzN@9kk>uNJUnw)ZeqFnK4iH@Da&Nt_V>U)8EP*`9fkh4IM zGo%kfI@Vx=C@JZTB6bg}s2NshnDLCQPkp)1Z>zii{od1{pUHgFh@uFn6c#k&>xE$) zO4x7^Xwzv05}Y}w_x7h|;CM4ivi_EHUpU(>Y37{&RBR6AXUkS!T3CeJnY=&poTHU^ zyY@WNf?G{hm7PFP1FE=bzeoH(yD889>)nivoCDV)BCXyqsy$BTvjb(Uhpx4sp?|>c zp?*-G#pBw#y|`sdd_`b}SI;A`0CG+xC15VjejHXIM&-6;4*|)Oo*c+d=4NK@KV1?+ zdje4@{!fV{LmW$K{cqPk!13k6tDb$uHf~_hkY3QzrpNU7^*fPIx*HXa^K6m~Ym$Fd^slT2x<(Xx{}5=pP)4TAd*uqJVuq;qW66+$G}*=~0Z`(CHpMIu+~-Urn%u3p zpNWeK1K$S2IckoV-YBl_?CF%`Z$Yy~k&Mic>eJV+_6re_&x3SQV6MJ;!w9jb56hC)%(9gQm#%qiu$^c#tZ(q+_$PCt%g92vB&83# zWd)Kenjx|*h*7~Gcv2#NlOg2s8JlnJwWdRemCqlwlpcHG)=fG%1v9LL~rxZW(O zSo1t8$&(KpUdP_aL9st1|~QMI**zchYxBOMk(%&TV~Q zX=a3_V_60L_B5*l>a-n&y{p^LsjV!>hn|HcaP7|-8mY3%N{dEunWs-T!`Y6D8x9$Z z+D`_G#@7(rsnJVZ){^a8FKXOo(vC$Tjeq7p2xqza88TE^$pDmW6|WJ2g9-sE08z;{-bD0Lo%mL-#k8e zKN$vZWZFiI|J63ReXGoP%!{>Cc*KiggvHl)7qpevytfU^&M^qwACf3yj0&ac|LIzd zp=rj@RM9eqX&S5kI3%+13HL+PlV8&XD_=0_k~`7eCvB=XE+ zoT}}S+ZR%@vzu*uneq!S2FopY`pGr@00&uK8`P5JNvytg_pSvP{ZBIz;dlzZ{ZKY? zR*tlZpG7=1fn;sC*jz<^yuf+~`chR@RY4(SOdL5-uL>FjRLdoyL8pG0 zp~1oQ1Ew^SA1I5*;6EpUuFE$0WU3+T)6Jp|g~7obfN}W*L|&Vi!=ysWcCb=Z%KirU z>0^$OkE)cR?0lea?y&N)DrWg`i6=)p;MLRl>jak?Pd5ig9eYU&^q`nfPaol-TJOD* zwb!$0)1YW5`(nb~@xG^7fI0##pdk&{H!uMGiGND0o9pZG`==@w2E*_{u#D62ovCCg zNfbfz0ui7EhB?6?vp?Q7ZvyQJ|KP$y-Y|$D}+(i(M0n!Qv== zcU`*mX*fodpaLApDW;!#((t1z@!MBSWM&wm-8ZrorryNhouFz4JL1X78p!VQ z>(ZR#=Zu}TDcs=Q!id6Mn|o4^y5(38X+8s+TPaZE8#bnZ`C|(d(;Ip+)T_)4ra+8& z#xFZCIzR+BH!j_iEhpa~v2eCcJxpULwNNL5O|l}z)EPD1rkNV!@f3PGZI5$GYU=dsIxv`evGMka zUrpl`@|5<<4Yo`79&X|w(xNL5E5P)X7t`32iuaZ^?M zHt_IFX`~cqh~DO^iER02mTUHxkO_oY;2Kp2$|>lj8$=%a6s*IqXbDiVl8Y@ZJv-A0 zd*5L$%R^!KIyn6Op=s5!-?pC5ThMx^@J@N2>=Bw*si~-(`+$lIt^XT&Wgi1tBRDFp zdmu3xsmw%q6*`hnGgVX?P{Ql>i!xBfDYBt(Pf|Hy>r9JWYEc&l*^T}_D%Pa)9`K!82+G^xTlje|rla08aBqkDhN|guRUn{sk?R*8W|(BX1zz zDtBBG0eflk6EuLyqZmNqOD>9)6X2d1-7vts^QJH5Iv@tuZG+a3wP*hBru6RNmbr#` zlc~@Vnw*v~B&Jf3QcLY!OZ85mt$k56wFq7dOf&u)5`37QWZ5!ZjSVI~7p)vO2ZT8g z`G?@{t^T~sz=HAHv$lU#(m4bgkEL(35;GKLfel*THesj)`5Lrb(&zMEL5+d^h$m76rCmfbfNcmw=LCzOAd} z)J3sO)(;{k&ExdSQw;u3Z&w-*<^J}kbJ`u8v?wac8fVn8W)EdewurHePSh|4$1+5N z!@oMpp48YyWEty>om8iU?E8$RvKt}{8B5P~Pyh4odGWk@Uio~+J@@^a-+leA?fbp1 z>uq3d!ByK8;2u$hUqk>=L!+5?P@GQG1HIN;hzfg^KI}pkdooNu+h$$)$L>A5oA%2J z3tQt)F#&3^ACW^l+{#XBDFF~KTTd<93RkG7K3G9QHEw|RfAwSd6=(CG63)#ztJ`^)pu_x<7`^=(x6|vx{jAu8RQ%Oj!35YwPVHb zuC}cyCysr$v%C1u2Nw&WZAiYY2ikUeopQVSVvY#O{@Ky2!`Q3I6pb1{$1a8v7;+No z@~^tPEp|n3Z*5e`TYf#MVJ-_9j*bq~CK~%G{vs{YR5@@vv?_RWtQz1xi_w_Cob(Gz zA@%(=~g0dl#2nlY=sS#JG?Cz-tF#cJRx+QaHJBZx>S}_du09)yU31 z8#%zNTwq!`R>?$T*NKq7r;_s&uUfg6DE9bxSE)=;B$X{gbJdJ7u|q@Uej`CSZqkiQ zm2Dz{x7r}6bL}KHl}=5x{>DfJFS#~W#-Y~whnpN%_@9pT?0SoEXCmV8E0DJnfn%4lP58!2Cqel@vcu}j|=AX{VpcNdbb?QgPGW|*VC4^I#1K0;wuNAgSX>)7wIgC##DJtyxg+oWyQ=0)ku?+ zKcoo~h$+k1{`Xz?-RaM$~2IZ5T-gMGMNG^%58 zM$kdBh$cnZySvqyy}Kx>_tUr==?+_6@kTju+ZCh!_!4mMu&If|;Q`Mj3|o3mlflEk zce7o}?^bGg8|3!b2fiChAHQuXFfXxSvmLeLTN^SQA1oN0=h|&HleKlHDKc?Td}rQpMM3bV*O$tq?=Va~dj8Prdm{Lo z^mkRq>Z)W*LhChfUse}JO@i;0wt+L&)fXS$+-zcu{X0lvp$`9TrMrDR$P6ntJdm^S zbqP>o-DMy`Ikb}Ink7;Oc~#fbdoY#hG4}?jO(@Au-?`$-9F!SBY$D6U!tz! zuTjxx^h6vDUB>^PyXRcjf07+8wxl?GD0Ewm?EHM!eL^s|YE2m?jSFT1Pk1jEmt3NE zujMr}Yw-3woo?!i;C1ZXyhr826nd1XSM~hhW1O6z&-UTNhcD?ch+TfbhDV{2E-`@W z)>}tzX+KdA;P3zR-ZiqwGH_^+ImIYX@MXrvEPySNZF$gkUlASV<)Ru~o>3gGUcCya zm%smcvEHEIjp@u?%Rzd|)W#=M^;y3b{PXtaqqX1ef>=u-NFy*=ZLGCI7oe9hq(+X$ z6kJLp5e-kq|E!{?J9%V3a9W4$2-nH49RH4rf{F62EiH2P7yxfFh!D>MN0iU=jG>RL zpYCUcPm`0n!aml6UUfT)OCa2NALcb(i_6z{iv$i77gu+^t+;59;W)w$4KWhN{L06t zE$kE1^hm;DuPy+tS-(o!qOJ&^TO9ey8~Laa;peUEwYSm9)$W;Ukfjqq$0^g_;OgC+ zt4n7tZg} z!Mkl?y$jxl8ZZUS5mI>8M``aDh%Z53`A%ktwbfI_CqCaz`vVV&W^lO%0LA1Q^7@0` z3@nMZ*5QnI$4P)W1J>y?ExGk;)=lUPOl2)D0`(^T6chjyPT~%-eF0fITKXy!atV*( z;w1O@V5W041X0KBEPF>MrU1C~^Ew@`zXj$QybvkN8u?!PdaSwVZ6)xAZqqwDKF-hc z1Hb+u|JfslpB&dP=R5Fw8Bh@r)btlKh9s!vuCMM?TQ(VtyD{ab$=m!tt_7y^O zg3<#(h)9@KF(?EyBI}Z?0Tl$j^3y#g3!nE?8hDfmKGM=^=lfiwelIG&DYmZ?pfdJe z$Q@m^u{qP3Uw3$g<1;OT^BCQR{%nQ>tYBzjflJlV&%Je6i45AIO!K}Zo;Wc ze&G*X&fQ_xL2`g}!1joEoRE-Lf1vUL47{ReXJov3o=bWSz%@Ain%HGl#WjHk4|$s| z8@vFw0OpW03xoWc5l#ga8OLC@uMrp}W+vWVWn#mIrrVAE*cSE%*Q$ESDf)`_!l6Tu zf$eg2qL}$Ltp{-7gz5JbVo%|#f%@32-R}sScFxjj)okF!yYCCZ-?Yq}Cvw=ACL|2; z`1-gNath6YZx%SarAs5Eby=+W3@{(4P>=N@1v11oQh|%BtB-u7_gQB)dNQjl)P24!g7z<$;#T#cTGpM$ne-~Xld-RkkBLd=tpq9>yB8MG zfoYPCqh|lKvBv)%0kiXRWU*bIzP@++th7GQ0XfIA`^`O`$7#VqTRFd%2Z29{_*zt^ z3^})*_O<=33oog93o^5=i|GMIF{pXFw*zmQS-^i6?axd2FZaxV3J+5JUBxF}k%a79 z5nH?%AuF_l?rWtzq+Ndf6&C7hLVGX(7(T>UT}8LQY7?%wmm9W2Pyo(ZE%T!7VJA>L z5V%IX>wKI8@uF*`EV~lHVog(1Qz2{J&#e>``23aRaALWxslDV2`MHQ)&beCJrluVw zHyTA>3rt`lvqqYm4ZA;rxP>UzfRhX)?4q<~U?tcI+z%D#oFoBpb@KCF3>O-&z~^f0 zP0{OquOQK4GIPtJ-frP?XWs;Nk0|~MjPpi@)-_oDwELHD+cnbcqloLR78ppQD1^!e zzZwFPG0DaF!C>Jl%nIXrBsxG&ZnR_aebC6+GfQhi@%F$}DqRw^&zlIb5p)Rb1r-Iz-2B-A310HpAOjEV zG%Mo5du%6J-7ygL(@uR>E8Ht@={ zbX9?xOmlO-CuhKH5CI*vhNjwyBc&587+_mL(1i3V>wBY8KweWD{r0EVZ;xc=JPz!J zw8RhQ>r2ImbVe$n^?ql5VOKVK${bHM#V3ItipR6Gb#yGLz?Y<&!!Ih1Gh1Vjk76py8OM3YYx{sP~dzO4S6o~?p# zm7<#3kk2kxS7aFA`QLi!uEqyGOk%e7BfF&oR|t@emgl*T;`rf%wgUv(R{RyW{cpUek3UEqD5p|;`une?=FKuzR(y005{x;inuaf!qc|lr+Ss_Kx7Q%S z(zk+-7^&;okthRSM)3#`K+>VW4=vgcJx%VN2H!bE1a~`q7>x10%De;G;HBnOa=Sh| zV)i#&?+KLSt{zKm?SsuA*x+WnK0t0`uhT*+hsqY{!vQuH>~Sjv6u9npEb^6UoOHM% z)T+ESYZPViAp`#$v1oaHXz%SP4`c|``CUdd(3D!Edtc>*4=UuABIzw;16H3JS@7-y zYqqisAEESa=4yp1n(N>W?nj;-^}(D%IdaRz9Z~2`+nc+V(vKg1@El_cb0OiUJjvFr zfaM%QM$R9%`Ip8S9u-xXm$!fae%Ts|Kc zlOzAPp_}PHr>NVeBqv`69}84LvNegc1-SOs<|a4+XSh+tnI{kJKvDGoFC8BrM|jeQ zP{_2)TQD9^&12-wyVyObMlBdjiKCU>%Fnryq8Q*KS#<<;d_T+(@7T{`L4i<_$rXo7 zU|o^>53J6U!7gXpK&>4UCoQ!54*qC}MLx8+%*$i1GCa^l#HH~jvrJtJJJj({MMpaO z5};0jHZv=3=Y*<$a|`Gy`+#4au{kD$^-bevm|;bjSkZ&c1_3gE^ zy9^($PRz^qRMBC;frd8DW-?Zd+)W*>SF&(YAZFxXdk7a`q% z5CE>#kbt(urJ;~%vD5p(f4j=2eWA7#?;;pMPkdqbyjx3Xnua5{X#K?B*&bS6|fsLFQve}i_a z9?V7`&iQIEds=QVEnd}kw;($%N2-zKi>wOGhSWOfhdi`t*rnv)Ry7r%l-kTH9|P zR}b)kvcIY0os62I;T!VYh$>L&7%c0p@mr8_?;1LP-Tq!uk}Zro92X~sS0N%4!Xs>> zsNlN=BjT#3T6lSQq9P+>Ba9GX^88|2=w!~VnALGFF~IQ07SWOyzp02Wh8 zzT!2yVyLg|R=dnPU2|jZs`o>2<3y@2GvUf~SOg-Tj)D*>cu%#dLVaN8ooHsEkn-vC zr#E94XQF-P`Of3~gO?|%&ve9mpKQ0_Vk;`v3hh1i?A*x_#^h7|Q349|>4$$*40$mQ zE>EFt5(b71MF6aOoR5{wYxMQ@hH!*%KZ?y+axtlJt5@Nr!?%AHwkzF@{bUka1-ny? zH@7rjet*d(+m^e{RQmk5^3TG$i3G;#ml1_i4Qa1)DeE_z&R+E* zJj}XmC4&V*249kjFNQ=?N5rGSOjJUGxuJ8aA5-jP^DI7km0DdLFjBj&sJWl>p6Jh@ zFj^A#_v%W5t-C6_f*0xuFEm-7o2z)mCRTu^a8phEnImXu-ADmSq|$0WK4`0gS93h@ z20D+j{;qL>NNL~c;jz%%ykGE3+HT-PqbOE(b_GIuexDA$6zRvj$#3TlzP!#m@8avr z0?n&9qzP&nuF5VdMK+@?bt_r6Bo#_|3G#awRzv0-$y9;Y$ChGfX4PIKQ=8l7u&%BI z+dny*h)e0|VbNDW0ALE*f#xjB-8;WA1C0ji0+l$wS!2a)E_{7iGDflE2?HdoEtMjU zoazA}^xu%*oK3`@^$*1{j(qAC`daxVpv;epBa&ClVb#XGE4-eoQEns-6TeaB{z>K2 z18%t-6`*2Z)_GQVE@R)=nHa@~dxQVOkuwu^tgnP|z~4W)$Zhp6fC;s8aJYg9^0XTJ z8w+-;^&IznPthofzy=x`=Sgju(bx(Wi#A3E)zGdFCersx~@qc>L#bp~E^Knkv=5r+pK z*Zkqhy(b~cpqywqhg&>Fs4j>oM_%!8^hHY#kC%HI@8#t{3EiJAb50*)%3lK)=VaiW zdx`Z*IDLq-dRMl$B9Hk&LUDEm0<=;g)%uu`I(0&g4yTg!{IT#nSTQVAsUoyq*Crc@ zNr5e=p%g#@W-_d$;nfa%niJ>Xv|ZHByJ)F7s=5hP2T96q(;bklL%VN87rp!fjCL-5 z9*&5RSE@RVXs7*e=;`7s9`8(kltgj$ta19(foQyjrlC9C#sCE*{D)5GRv!`+l&U2^ z%1OO>Yrdecuy~A1Ibh`mfj&IE8{5KBkqwxU5fW|Y`{ww;z?65?T;4`aetrPI#K&EJ zgcdT=8!z5+Klv>%2>9v{d?GnYNQGttU%5AG!X|jVRPn@7T-cEYj(Nqi=9v`Zw4&US zeitZ^5&P3BbOwrET#?|EGFS{$VhCVF1nc8VG1`>N_^r^vyW6V`;wicItg&cKFEWU_ zbtX(Z^IKrjau7P}s(Ko{DL~MWYKHpx0v8KRw=$&F1PIRj(4fI$_&GG|fU^!XP|hZ% zH1_uwRaaw-7w+7+1-1E-kII!Tg91!DPBBI#78PVce_QR{`|Y^_&V9u8kbctq;PN#> z04Qt4K?#noI1Gf}0%QIYQW+QMb442?o!NR(zmzm-aEurw-Gdk7H}jH5U?k@5ffY26SpgMMXV%59jX7)A>hv>#_cM zoASN>PA~2=~1~2a%n9VQRt_K8PGT}&FrPQS~JkSpY&;5I^n;iSQ{v6ME42_{} z67);tr_w)d?P=6_p$@o*3|d`WX&>j^Z>R>At4K=f;}aG^b<`~vX4#B-@n%<8956+(*kL^5ZIceq$ww7p*fW*{AOu+bZORG2do&h z8%V_*T7EL@!MtTyI9%;(wC6-DXXjSq3dF;WHK)dI=-t_L4_1`PU?em>%s1cMvzvJH zzs^j9Rh%G2HgO$(hq}lgx^8n8KmW8@qa&OwrCFygf)6BGf!xVr4jo*k*1O1Zj%CJS zetwe_6=SBHR{K~rbI-1w+mn5x5x37#nRSbyYrkMqH^)weTV6-hOBVAW?*U`PVbk(;_1Mg`~ITTR;3am3DAQ_&g))ZTp5VC(54zvbl)L z`sp8b;o}#mu!q5t;DOI+6d{sqk$pKTBALL@^5MuVPX?SP=pk5JYbOq&K+Oo(9OJ!e jx|!pY1g7;nzavUJ#K1XL&mB-j6w2Vzm5YVij-mepg+u1T literal 0 HcmV?d00001 diff --git a/docs/images/gpufit_program_flow_v2.png b/docs/images/gpufit_program_flow_v2.png new file mode 100644 index 0000000000000000000000000000000000000000..8ead94a956870491873bd28ab041fe7d9245b4c5 GIT binary patch literal 121269 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

avz$x9BLb@>(l6ZhiEnf3ajIW|9zJq#IWiKA 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 B8uS1F literal 0 HcmV?d00001 diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..6f89dc0 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,22 @@ +.. Gpufit documentation master file + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Gpufit Documentation +==================== + +.. toctree:: + :maxdepth: 3 + + introduction + installation + gpufit_api + fit_model_functions + fit_estimator_functions + examples + customization + bindings + appendix + license + + diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..8af76ba --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,220 @@ +.. _installation-and-testing: + +======================== +Installation and Testing +======================== + +The Gpufit library can be used in several ways. When using a pre-compiled +binary version of Gpufit, the Gpufit functions may be accessed directly via +a dynamic linked library (e.g. Gpufit.dll) or via the external bindings to +Gpufit (e.g. the Matlab or Python bindings). For more information on the +Gpufit interface, see :ref:`api-description`, or for details of the external +bindings see :ref:`external-bindings`. + +This section describes how to compile Gpufit, including generating its +external bindings, from source code. Building from source is necessary when +a fit model function is added or changed, or if a new fit estimator is required. +Building the library may also be useful for compiling the code using a +specific version of the CUDA toolkit, or for a particular CUDA compute +capability. + +Gpufit binary distribution +++++++++++++++++++++++++++ + +A binary distribution of the Gpufit library is available for **Windows**. +Use of this distribution requires only a CUDA-capable graphics card, and an +updated Nvidia graphics driver. The binary package contains: + +- The Gpufit SDK, which consists of the 32-bit and 64-bit DLL files, and + the Gpufit header file which contains the function definitions. The Gpufit + SDK is intended to be used when calling Gpufit from an external application + written in e.g. C code. +- The performance test application, which serves to test that Gpufit is + correctly installed, and to check the performance of the CPU and GPU hardware. +- Matlab 32 bit and 64 bit bindings, with Matlab examples. +- Python version 2.x and version 3.x bindings (compiled as wheel files) and + Python examples. +- This manual in PDF format. + +To re-build the binary distribution, see the instructions located in +package/README.md. + +Building from source code ++++++++++++++++++++++++++ + +This section describes how to build Gpufit from source code. Note that as of +the initial release of Gpufit, the source code has been tested only with the +Microsoft Visual Studio compiler. + +Prerequisites +------------- + +The following tools are required in order to build Gpufit from source. + +*Required* + +* CMake_ 3.7 or later +* A C/C++ Compiler + + * Linux: GCC 4.7 + * Windows: Visual Studio 2013 or 2015 + +* CUDA_ Toolkit 6.5 or later [#]_ + +.. [#] Note that it is recommended to use the newest available stable release of the CUDA Toolkit which is compatible + with the compiler (e.g. Visual Studio 2015 is required in order to use CUDA Toolkit 8.0). Some older graphics cards + may only be supported by CUDA Toolkit version 6.5 or earlier. Also, when using CUDA Toolkit version 6.5, please use + the version with support for GTX9xx GPUs, available `here `__. + +*Optional* + +* Boost_ 1.58 or later (required if you want to build the tests) +* MATLAB_ if building the MATLAB bindings (minimum version Matlab 2012a) +* Python_ if building the Python bindings (Python version 2.x or 3.x) + +Source code availability +------------------------ + +The source code is available in an open repository hosted at Github, at the +following URL. + +.. code-block:: bash + + https://github.com/gpufit/Gpufit.git + +To obtain the code, Git may be used to clone the repository, or a current +snapshot may be downloaded directly from Github as Gpufit-master.zip_. + +Compiler configuration via CMake +-------------------------------- + +CMake is an open-source tool designed to build, test, and package software. +It is used to control the software compilation process using compiler +independent configuration files, and generate native makefiles and workspaces +that can be used in the compiler environment. In this section we provide a +simple example of how to use CMake in order to generate the input files for the +compiler (e.g. the Visual Studio solution file), which can then be used to +compile Gpufit. + +First, identify the directory which contains the Gpufit source code +(for example, on a Windows computer the Gpufit source code may be stored in +*C:\\Sources\\Gpufit*). Next, create a build directory outside the +source code source directory (e.g. *C:\\Sources\\Gpufit-build-64*). Finally, +run cmake to configure and generate the compiler input files. The following +commands, executed from the command prompt, assume that the cmake executable +(e.g. *C:\\Program Files\\CMake\\bin\\cmake.exe*) is automatically found +via the PATH environment variable (if not, the full path to cmake.exe must be +specified). This example also assumes that the source and build directories +have been set up as specified above. + +.. code-block:: bash + + cd C:\Sources\Gpufit-build-64 + cmake -G "Visual Studio 12 2013 Win64" C:\Sources\Gpufit + +Note that in this example the *-G* flag has been used to specify the +64-bit version of the Visual Studio 12 compiler. This flag should be changed +depending on the compiler used, and the desired architecture +(e.g. 32- or 64-bit). Further details of the CMake command line arguments +can be found `here `__. + +There is also a graphical user interface available for CMake, which simplifies +the configuration and generation steps. For further details, see +`Running CMake `_. + +Common issues encountered during CMake configuration +---------------------------------------------------- + +**Boost NOT found - skipping tests!** + +If you want to build the tests and Boost is not found automatically, set the +CMake variable BOOST_ROOT to the corresponding directory, and configure again. + +**Specify CUDA_ARCHITECTURES set** + +If you need a specific CUDA architecture, set CUDA_ARCHITECTURES according +to CUDA_SELECT_NVCC_ARCH_FLAGS_. + +**CMake finds lowest installed CUDA version by default** + +If there are multiple CUDA toolkits installed on the computer, CMake 3.7.1 +seems to find by default the lowest installed version. Set the desired CUDA +version manually (e.g. by editing the CUDA_TOOLKIT_ROOT_DIR variable in CMake). + +**Specify CUDA version to use** + +Set CUDA_BIN_PATH before running CMake or CUDA_TOOLKIT_ROOT_DIR after +first CMAKE configuration to the installation folder of the desired +CUDA version. + +**Required CUDA version** + +When using Microsoft Visual Studio 2015, the minimum required CUDA Toolkit +version is 8.0. + +**Python launcher** + +Set Python_WORKING_DIRECTORY to a valid directory, it will be added to the +Python path. + +**Matlab launcher** + +Set Matlab_WORKING_DIRECTORY to a valid directory, it will be added to +the Matlab path. + +Compiling Gpufit on Windows +--------------------------- + +After configuring and generating the solution files using CMake, go to the +desired build directory and open Gpufit.sln using Visual Studio. Select the +"Debug" or "Release" build options, as appropriate. Select the build target +"ALL_BUILD", and build this target. If the build process completes +without errors, the Gpufit binary files will be created in the corresponding +"Debug" or "Release" folders in the build directory. + +The unit tests can be executed by building the target "RUN_TESTS" or by +starting the created executables in the output directory from +the command line. + +Linux +----- + +Gpufit has not yet been officially tested on a computer running a Linux variant +with a CUDA capable graphics card. However, satisfying the Prerequisites_ and +using CMake, we estimate that the library should build in principle and one +should also be able to run the examples on Linux. + +MacOS +----- + +Gpufit has not yet been officially tested on a computer running MacOS with a +CUDA capable graphics card. However, satisfying the Prerequisites_ and using +CMake, we estimate that the library should build in principle and one +should also be able to run the examples on MacOS. + +Running the performance test +++++++++++++++++++++++++++++ + +The Gpufit performance test is a program which verifies the correct function +of Gpufit, and tests the fitting speed in comparison with the same algorithm +executed on the CPU. + +If Gpufit was built from source, running the build target +GPUFIT_CPUFIT_Performance_Comparison will run the test, which executes the +fitting process multiple times, varying the number of fits per function call. +The execution time is measured in each case and the relative speed improvement +between the GPU and the CPU is calculated. A successful run of the performance +test also indicates also that Gpufit is functioning correctly. + +The performance comparison is also included in the Gpufit binary distribution +as a console application. An example of the program's output is +shown in :numref:`installation-gpufit-cpufit-performance-comparison`. + +.. _installation-gpufit-cpufit-performance-comparison: + +.. figure:: /images/Gpufit_Cpufit_Performance_Comparison.png + :width: 10 cm + :align: center + + Output of the GPUFIT vs CPUFIT performance comparison + diff --git a/docs/introduction.rst b/docs/introduction.rst new file mode 100644 index 0000000..2a6fc1f --- /dev/null +++ b/docs/introduction.rst @@ -0,0 +1,87 @@ +============ +Introduction +============ + +Gpufit is a GPU-accelerated CUDA implementation of the Levenberg-Marquardt +algorithm. It was developed to meet the need for a high performance, general- +purpose nonlinear curve fitting software library which is publicly available +and open source. + +Optimization algorithms are ubiquitous tools employed in many field of science +and technology. One such algorithm for numerical, non-linear optimization is the +Levenberg-Marquardt algorithm (LMA). The LMA combines elements of the method of +steepest descent and Newton's method, and has become a standard algorithm for +least-squares fitting. + +Although the LMA is, in itself, an efficient optimization algorithm, +applications requiring many iterations of this procedure may encounter +limitations due to the sheer number of calculations involved. The time required +for the convergence of a fit, or a set of fits, can determine an application's +feasibility, e.g. in the context of real-time data processing and feedback +systems. Alternatively, in the case of very large datasets, the time required +to solve a particular optimization problem may prove impractical. + +In recent years, advanced graphics processing units (GPUs) and the development +of general purpose GPU programming have enabled fast and parallelized computing +by shifting calculations from the CPU to the GPU. The large number of +independent computing units available on a modern GPU enables the rapid +execution of many instructions in parallel, with an overall computation power +far exceeding that of a CPU. Languages such as CUDA C and OpenCL allow GPU- +based programs to be developed in a manner similar to conventional software, but +with an inherently parallelized structure. These developments have led to the +creation of new GPU-accelerated tools, such as the Gpufit. + +This manual describes how to install and build the Gpufit library and its +external bindings. Furthermore it details how to extend Gpufit by adding +custom model functions as well as custom fit estimator functions. + +The documentation includes: + +- Instructions for building and installing Gpufit +- A detailed description of the C interface +- A description of the built-in model functions +- A description of the built-in goodness-of-fit estimator functions +- A detailed description of the external bindings to Matlab and Python +- Usage examples for C, Matlab, and Python +- Instructions for adding custom model functions or custom estimator functions + +The current version of the Gpufit library is |GF_version| +(`see homepage `_). This manual was compiled +on |today|. + +Hardware requirements +--------------------- + +Because the fit algorithm is implemented in CUDA C, a CUDA_-compatible graphics +card is required to run Gpufit. The minimum supported compute capability is +2.0. More advanced GPU hardware will result in higher fitting performance. + +Software requirements +--------------------- + +In addition to a compatible GPU, the graphics card driver installed on the +host computer must be compatible with the version of the CUDA toolkit which +was used to compile Gpufit. This may present an issue for older graphics +cards or for computers running outdated graphics drivers. + +At the time of its initial release, Gpufit was compiled with CUDA toolkit +version 8.0. Therefore, the Nvidia graphics driver installed on the host PC +must be at least version 367.48 (released July 2016) in order to be compatible +with the binary files generated in this build. + +When compatibility issues arise, there are two possible solutions. The best +option is to update the graphics driver to a version which is compatible with +the CUDA toolkit used to build Gpufit. The second option is to re-compile +Gpufit from source code, using an earlier version of the CUDA toolkit which is +compatible with the graphics driver in question. However, this solution is +likely to result in slower performance of the Gpufit code, since older versions +of the CUDA toolkit are not as efficient. + +Note that all CUDA-supported graphics cards should be compatible with +CUDA toolkit version 6.5. This is the last version of CUDA which supported +GPUs with compute capability 1.x. In other words, an updated Nvidia graphics +driver should be available for all CUDA-enabled GPUs which is compatible with +toolkit version 6.5. + +If you are unsure if your graphics card is CUDA-compatible, a lists of CUDA +supported GPUs can be found `here `_. diff --git a/docs/license.rst b/docs/license.rst new file mode 100644 index 0000000..1223cbc --- /dev/null +++ b/docs/license.rst @@ -0,0 +1,25 @@ +======================= +Gpufit software license +======================= + +MIT License + +Copyright (c) 2017 Mark Bates, Adrian Przybylski, Björn Thiel, and Jan Keller-Findeisen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..6f53cb2 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,281 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. epub3 to make an epub3 + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + echo. coverage to run coverage check of the documentation if enabled + echo. dummy to check syntax errors of document sources + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +REM Check if sphinx-build is available and fallback to Python version if any +%SPHINXBUILD% 1>NUL 2>NUL +if errorlevel 9009 goto sphinx_python +goto sphinx_ok + +:sphinx_python + +set SPHINXBUILD=python -m sphinx.__init__ +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +:sphinx_ok + + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\RTDSpielwiese.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\RTDSpielwiese.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "epub3" ( + %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "coverage" ( + %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage + if errorlevel 1 exit /b 1 + echo. + echo.Testing of coverage in the sources finished, look at the ^ +results in %BUILDDIR%/coverage/python.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +if "%1" == "dummy" ( + %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. Dummy builder generates no files. + goto end +) + +:end diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt new file mode 100644 index 0000000..b8c2751 --- /dev/null +++ b/examples/CMakeLists.txt @@ -0,0 +1,20 @@ + +# Applications + +function( add_example modules name ) + set( target ${name} ) + add_executable( ${target} ${name}.cpp + ${PROJECT_SOURCE_DIR}/Tests/utils.h + ${PROJECT_SOURCE_DIR}/Tests/utils.cpp + ) + target_include_directories( ${target} PRIVATE ${PROJECT_SOURCE_DIR} ) + target_link_libraries( ${target} ${modules} ) + set_property( TARGET ${target} + PROPERTY RUNTIME_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}" ) + set_property( TARGET ${target} PROPERTY FOLDER GpufitCpufitExamples ) +# install( TARGETS ${target} RUNTIME DESTINATION bin ) +endfunction() + +add_example( "Cpufit;Gpufit" Gpufit_Cpufit_Performance_Comparison ) + +add_example( "Cpufit;Gpufit" Gpufit_Cpufit_Nvidia_Profiler_Test ) diff --git a/examples/Gpufit_Cpufit_Nvidia_Profiler_Test.cpp b/examples/Gpufit_Cpufit_Nvidia_Profiler_Test.cpp new file mode 100644 index 0000000..41f72e2 --- /dev/null +++ b/examples/Gpufit_Cpufit_Nvidia_Profiler_Test.cpp @@ -0,0 +1,340 @@ +/* + * Runs 100k fits on the CPU and 2m fits on the GPU, used with the Nvidia profiler to obtain + * running time information on the different CUDA kernels. + */ + +#include "Cpufit/cpufit.h" +#include "Gpufit/gpufit.h" +#include "Tests/utils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define _USE_MATH_DEFINES +#include + + +/* + Names of paramters for the 2D Gaussian peak model +*/ +struct Parameters +{ + float amplitude; + float center_x; + float center_y; + float width; + float background; +}; + +/* +Prints some statistics and the speed (fits/second) of a run. +*/ +void print_result( + std::string const name, + std::vector const & estimated_parameters, + std::vector const & test_parameters, + std::vector states, + std::vector const & n_iterations, + std::size_t const n_fits, + std::size_t const n_parameters, + std::chrono::milliseconds::rep const duration_in_ms) +{ + + std::vector estimated_x_centers(n_fits); + std::vector test_x_centers(n_fits); + + for (std::size_t i = 0; i < n_fits; i++) + { + estimated_x_centers[i] = estimated_parameters[i*n_parameters + 1]; + test_x_centers[i] = test_parameters[i].center_x; + } + + double const std_dev_x = calculate_standard_deviation(estimated_x_centers, test_x_centers, states); + + double const mean_n_iterations = calculate_mean(n_iterations, states); + + double fits_per_second = static_cast(n_fits) / duration_in_ms * 1000; + + // output + std::cout << std::fixed; + + std::cout << std::setw(5) << std::endl << "***" << name << "***"; + + std::cout << std::setprecision(3); + std::cout << std::setw(12) << duration_in_ms / 1000.0 << " s "; + + std::cout << std::setprecision(2); + std::cout << std::setw(12) << fits_per_second << " fits/s" << std::endl; + + std::cout << std::setprecision(6); + std::cout << "x precision: " << std_dev_x << " px "; + + std::cout << std::setprecision(2); + std::cout << "mean iterations: " << mean_n_iterations << std::endl; +} + +/* +Randomize parameters, slightly differently +*/ +void generate_initial_parameters(std::vector & parameters_set, std::vector const & parameters) +{ + std::uniform_real_distribution< float> uniform_dist(0, 1); + + float const a = 0.9f; + float const b = 0.2f; + + int const n_parameters = sizeof(Parameters) / sizeof(float); + for (std::size_t i = 0; i < parameters_set.size() / n_parameters; i++) + { + parameters_set[0 + i * n_parameters] = parameters[i].amplitude * (a + b * uniform_dist(rng)); + parameters_set[1 + i * n_parameters] = parameters[i].center_x * (a + b * uniform_dist(rng)); + parameters_set[2 + i * n_parameters] = parameters[i].center_y * (a + b * uniform_dist(rng)); + parameters_set[3 + i * n_parameters] = parameters[i].width * (a + b * uniform_dist(rng)); + parameters_set[4 + i * n_parameters] = parameters[i].background * (a + b * uniform_dist(rng)); + } +} + +/* +Randomize parameters +*/ +void generate_test_parameters(std::vector & target, Parameters const source) +{ + std::size_t const n_fits = target.size(); + + std::uniform_real_distribution< float> uniform_dist(0, 1); + + float const a = 0.9f; + float const b = 0.2f; + + for (std::size_t i = 0; i < n_fits; i++) + { + target[i].amplitude = source.amplitude * (a + b * uniform_dist(rng)); + target[i].center_x = source.center_x * (a + b * uniform_dist(rng)); + target[i].center_y = source.center_y * (a + b * uniform_dist(rng)); + target[i].width = source.width * (a + b * uniform_dist(rng)); + target[i].background = source.background * (a + b * uniform_dist(rng)); + } +} + +/* + +*/ +void add_gauss_noise(std::vector & vec, Parameters const & parameters, float const snr) +{ + float const gauss_fwtm = 4.292f * parameters.width; //only valid for circular gaussian + float const fit_area = gauss_fwtm*gauss_fwtm; + + float const mean_amplitude = 2.f * float(M_PI) * parameters.amplitude * parameters.width * parameters.width / fit_area; + + float const std_dev = mean_amplitude / snr; + + std::normal_distribution distribution(0.0, std_dev); + + for (std::size_t i = 0; i < vec.size(); i++) + { + vec[i] += distribution(rng); + } +} + +/* + +*/ +void generate_gauss2d( + std::size_t const n_fits, + std::size_t const n_points, + std::vector & data, + std::vector const & parameters) +{ + std::cout << "generating " << n_fits << " fits ..." << std::endl; + for (int i = 0; i < 50; i++) + std::cout << "-"; + std::cout << std::endl; + std::size_t progress = 0; + + for (std::size_t i = 0; i < n_fits; i++) + { + float const amplitude = parameters[i].amplitude; + float const x00 = parameters[i].center_x; + float const y00 = parameters[i].center_y; + float const width = parameters[i].width; + float const background = parameters[i].background; + + std::size_t const fit_index = i * n_points; + + for (int iy = 0; iy < sqrt(n_points); iy++) + { + for (int ix = 0; ix < sqrt(n_points); ix++) + { + std::size_t const point_index = iy * std::size_t(sqrt(n_points)) + ix; + std::size_t const absolute_index = fit_index + point_index; + + float const argx + = exp(-0.5f * ((ix - x00) / width) * ((ix - x00) / width)); + float const argy + = exp(-0.5f * ((iy - y00) / width) * ((iy - y00) / width)); + + data[absolute_index] = amplitude * argx * argy + background; + } + } + + progress += 1; + if (progress >= n_fits / 50) + { + progress = 0; + std::cout << "|"; + } + } + std::cout << std::endl; + for (int i = 0; i < 50; i++) + std::cout << "-"; + std::cout << std::endl; +} + +/* +Runs Gpufit vs. Cpufit for various number of fits and compares the speed + +No weights, Model: Gauss_2D, Estimator: LSE +*/ +int main(int argc, char * argv[]) +{ + // check for CUDA availability + if (!gpufit_cuda_available()) + { + std::cout << "CUDA not available" << std::endl; + return -1; + } + + // all numbers of fits + std::size_t const n_fits_gpu = 2000000; + std::size_t const n_fits_cpu = 100000; + std::size_t const size_x = 15; + std::size_t const n_points = size_x * size_x; + + // fit parameters constant for every run + std::size_t const n_parameters = 5; + std::vector parameters_to_fit(n_parameters, 1); + float const tolerance = 0.0001f; + int const max_n_iterations = 10; + + // initial parameters + Parameters true_parameters; + true_parameters.amplitude = 500.f; + true_parameters.center_x = static_cast(size_x) / 2.f - 0.5f; + true_parameters.center_y = static_cast(size_x) / 2.f - 0.5f; + true_parameters.width = 2.f; + true_parameters.background = 10.f; + + // test parameters + std::cout << "generate test parameters" << std::endl; + std::vector test_parameters(n_fits_gpu); + generate_test_parameters(test_parameters, true_parameters); + + // test data + std::vector data(n_fits_gpu * n_points); + generate_gauss2d(n_fits_gpu, n_points, data, test_parameters); + std::cout << "add noise" << std::endl; + add_gauss_noise(data, true_parameters, 10.f); + + // initial parameter set + std::vector initial_parameters(n_parameters * n_fits_gpu); + generate_initial_parameters(initial_parameters, test_parameters); + + std::cout << std::endl; + std::cout << n_fits_cpu << " fits on the CPU" << std::endl; + + // Cpufit output + std::vector cpufit_parameters(n_fits_cpu * n_parameters); + std::vector cpufit_states(n_fits_cpu); + std::vector cpufit_chi_squares(n_fits_cpu); + std::vector cpufit_n_iterations(n_fits_cpu); + + // run Cpufit and measure time + std::chrono::high_resolution_clock::time_point t0 = std::chrono::high_resolution_clock::now(); + int const cpu_status + = cpufit + ( + n_fits_cpu, + n_points, + data.data(), + 0, + GAUSS_2D, + initial_parameters.data(), + tolerance, + max_n_iterations, + parameters_to_fit.data(), + LSE, + 0, + 0, + cpufit_parameters.data(), + cpufit_states.data(), + cpufit_chi_squares.data(), + cpufit_n_iterations.data() + ); + std::chrono::milliseconds::rep const dt_cpufit = std::chrono::duration_cast(std::chrono::high_resolution_clock::now() - t0).count(); + + if (cpu_status != 0) + { + // error in cpufit, should actually not happen + std::cout << "Error in cpufit: " << cpufit_get_last_error() << std::endl; + } + else + { + // print + print_result("Cpufit", cpufit_parameters, test_parameters, cpufit_states, cpufit_n_iterations, n_fits_cpu, n_parameters, dt_cpufit); + } + + std::cout << std::endl; + std::cout << n_fits_gpu << " fits on the GPU" << std::endl; + + // Gpufit output parameters + std::vector gpufit_parameters(n_fits_gpu * n_parameters); + std::vector gpufit_states(n_fits_gpu); + std::vector gpufit_chi_squares(n_fits_gpu); + std::vector gpufit_n_iterations(n_fits_gpu); + + // run Gpufit and measure time + t0 = std::chrono::high_resolution_clock::now(); + int const gpu_status + = gpufit + ( + n_fits_gpu, + n_points, + data.data(), + 0, + GAUSS_2D, + initial_parameters.data(), + tolerance, + max_n_iterations, + parameters_to_fit.data(), + LSE, + 0, + 0, + gpufit_parameters.data(), + gpufit_states.data(), + gpufit_chi_squares.data(), + gpufit_n_iterations.data() + ); + std::chrono::milliseconds::rep const dt_gpufit = std::chrono::duration_cast(std::chrono::high_resolution_clock::now() - t0).count(); + + if (gpu_status != 0) + { + // error in gpufit + std::cout << "Error in gpufit: " << gpufit_get_last_error() << std::endl; + } + else + { + // print results + print_result("Gpufit", gpufit_parameters, test_parameters, gpufit_states, gpufit_n_iterations, n_fits_gpu, n_parameters, dt_gpufit); + } + + std::cout << "\nPERFORMANCE GAIN Gpufit/Cpufit \t" << std::setw(10) << static_cast(dt_cpufit) / dt_gpufit * n_fits_gpu / n_fits_cpu << std::endl; + + return 0; +} \ No newline at end of file diff --git a/examples/Gpufit_Cpufit_Performance_Comparison.cpp b/examples/Gpufit_Cpufit_Performance_Comparison.cpp new file mode 100644 index 0000000..b25dd90 --- /dev/null +++ b/examples/Gpufit_Cpufit_Performance_Comparison.cpp @@ -0,0 +1,450 @@ +#include "Cpufit/cpufit.h" +#include "Gpufit/gpufit.h" +#include "Tests/utils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define _USE_MATH_DEFINES +#include + + +/* + Names of paramters for the 2D Gaussian peak model +*/ +struct Parameters +{ + float amplitude; + float center_x; + float center_y; + float width; + float background; +}; + +/* + Randomize parameters, slightly differently +*/ +void generate_initial_parameters(std::vector & parameters_set, std::vector const & parameters) +{ + std::uniform_real_distribution< float> uniform_dist(0, 1); + + float const a = 0.9f; + float const b = 0.2f; + + int const n_parameters = sizeof(Parameters) / sizeof(float); + for (std::size_t i = 0; i < parameters_set.size() / n_parameters; i++) + { + parameters_set[0 + i * n_parameters] = parameters[i].amplitude * (a + b * uniform_dist(rng)); + parameters_set[1 + i * n_parameters] = parameters[i].center_x * (a + b * uniform_dist(rng)); + parameters_set[2 + i * n_parameters] = parameters[i].center_y * (a + b * uniform_dist(rng)); + parameters_set[3 + i * n_parameters] = parameters[i].width * (a + b * uniform_dist(rng)); + parameters_set[4 + i * n_parameters] = parameters[i].background * (a + b * uniform_dist(rng)); + } +} + +/* + Randomize parameters +*/ +void generate_test_parameters(std::vector & target, Parameters const source) +{ + std::size_t const n_fits = target.size(); + + std::uniform_real_distribution< float> uniform_dist(0, 1); + + float const a = 0.9f; + float const b = 0.2f; + + int const text_width = 30; + int const progress_width = 25; + + std::cout << std::setw(text_width) << " "; + for (int i = 0; i < progress_width; i++) + std::cout << "-"; + std::cout << std::endl; + std::cout << std::setw(text_width) << std::left << "Generating test parameters"; + + std::size_t progress = 0; + + for (std::size_t i = 0; i < n_fits; i++) + { + target[i].amplitude = source.amplitude * (a + b * uniform_dist(rng)); + target[i].center_x = source.center_x * (a + b * uniform_dist(rng)); + target[i].center_y = source.center_y * (a + b * uniform_dist(rng)); + target[i].width = source.width * (a + b * uniform_dist(rng)); + target[i].background = source.background * (a + b * uniform_dist(rng)); + + progress += 1; + if (progress >= n_fits / progress_width) + { + progress = 0; + std::cout << "|"; + } + } + + std::cout << std::endl; + std::cout << std::setw(text_width) << " "; + for (int i = 0; i < progress_width; i++) + std::cout << "-"; + std::cout << std::endl; +} + +/* + +*/ +void add_gauss_noise(std::vector & vec, Parameters const & parameters, float const snr) +{ + float const gauss_fwtm = 4.292f * parameters.width; //only valid for circular gaussian + float const fit_area = gauss_fwtm*gauss_fwtm; + + float const mean_amplitude = 2.f * float(M_PI) * parameters.amplitude * parameters.width * parameters.width / fit_area; + + float const std_dev = mean_amplitude / snr; + + std::normal_distribution distribution(0.0, std_dev); + + int const text_width = 30; + int const progress_width = 25; + + std::cout << std::setw(text_width) << " "; + for (int i = 0; i < progress_width; i++) + std::cout << "-"; + std::cout << std::endl; + std::cout << std::setw(text_width) << std::left << "Adding noise"; + + std::size_t progress = 0; + + for (std::size_t i = 0; i < vec.size(); i++) + { + vec[i] += distribution(rng); + + progress += 1; + if (progress >= vec.size() / progress_width) + { + progress = 0; + std::cout << "|"; + } + } + + std::cout << std::endl; + std::cout << std::setw(text_width) << " "; + for (int i = 0; i < progress_width; i++) + std::cout << "-"; + std::cout << std::endl; +} + +/* + +*/ +void generate_gauss2d( + std::size_t const n_fits, + std::size_t const n_points, + std::vector & data, + std::vector const & parameters) +{ + int const text_width = 30; + int const progress_width = 25; + + std::cout << std::setw(text_width) << " "; + for (int i = 0; i < progress_width; i++) + std::cout << "-"; + std::cout << std::endl; + std::cout << std::setw(text_width) << std::left << "Generating data"; + + std::size_t progress = 0; + + for (std::size_t i = 0; i < n_fits; i++) + { + float const amplitude = parameters[i].amplitude; + float const x00 = parameters[i].center_x; + float const y00 = parameters[i].center_y; + float const width = parameters[i].width; + float const background = parameters[i].background; + + std::size_t const fit_index = i * n_points; + + for (int iy = 0; iy < sqrt(n_points); iy++) + { + for (int ix = 0; ix < sqrt(n_points); ix++) + { + std::size_t const point_index = iy * std::size_t(sqrt(n_points)) + ix; + std::size_t const absolute_index = fit_index + point_index; + + float const argx + = exp(-0.5f * ((ix - x00) / width) * ((ix - x00) / width)); + float const argy + = exp(-0.5f * ((iy - y00) / width) * ((iy - y00) / width)); + + data[absolute_index] = amplitude * argx * argy + background; + } + } + + progress += 1; + if (progress >= n_fits / progress_width) + { + progress = 0; + std::cout << "|"; + } + } + std::cout << std::endl; + std::cout << std::setw(text_width) << " "; + for (int i = 0; i < progress_width; i++) + std::cout << "-"; + std::cout << std::endl; +} + +/* +Runs Gpufit vs. Cpufit for various number of fits and compares the speed + +No weights, Model: Gauss_2D, Estimator: LSE +*/ +int main(int argc, char * argv[]) +{ + // title + std::cout << "----------------------------------------" << std::endl; + std::cout << "Performance comparison Gpufit vs. Cpufit" << std::endl; + std::cout << "----------------------------------------" << std::endl << std::endl; + + std::cout << "Please note that execution speed test results depend on" << std::endl; + std::cout << "the details of the CPU and GPU hardware." << std::endl; + std::cout << std::endl; + + + // check for CUDA availability + int cuda_runtime_version = 0; + int cuda_driver_version = 0; + bool const version_available = gpufit_get_cuda_version(&cuda_runtime_version, &cuda_driver_version) != 0; + int const cuda_runtime_major = cuda_runtime_version / 1000; + int const cuda_runtime_minor = cuda_runtime_version % 1000 / 10; + int const cuda_driver_major = cuda_driver_version / 1000; + int const cuda_driver_minor = cuda_driver_version % 1000 / 10; + + bool do_gpufits = false; + if (version_available) + { + std::cout << "CUDA runtime version: "; + std::cout << cuda_runtime_major << "." << cuda_runtime_minor << std::endl; + std::cout << "CUDA driver version: "; + std::cout << cuda_driver_major << "." << cuda_driver_minor << std::endl; + std::cout << std::endl; + + bool const cuda_available = cuda_driver_version > 0; + if (cuda_available) + { + bool const version_compatible + = cuda_driver_version >= cuda_runtime_version + && cuda_runtime_version > 0; + if (version_compatible) + { + do_gpufits = true; + } + else + { + std::cout << "The CUDA runtime version is not compatible with the" << std::endl; + std::cout << "current graphics driver. Please update the driver, or" << std::endl; + std::cout << "re - build Gpufit from source using a compatible version" << std::endl; + std::cout << "of the CUDA toolkit." << std::endl; + std::cout << std::endl; + } + } + else + { + std::cout << "No CUDA enabled graphics card detected." << std::endl; + std::cout << std::endl; + } + } + else + { + std::cout << "CUDA error detected. Error string: "; + std::cout << gpufit_get_last_error() << std::endl; + std::cout << std::endl; + } + if (!do_gpufits) + { + std::cout << "Skipping Gpufit computations." << std::endl << std::endl; + } + + // all numbers of fits + std::vector n_fits_all; + if (sizeof(void*) < 8) + { + n_fits_all = { 10, 100, 1000, 10000, 100000, 1000000}; + } + else + { + n_fits_all = { 10, 100, 1000, 10000, 100000, 1000000, 10000000 }; + } + + std::size_t const max_n_fits = n_fits_all.back(); + + // fit parameters constant for every run + std::size_t const size_x = 5; + std::size_t const n_points = size_x * size_x; + std::size_t const n_parameters = 5; + std::vector parameters_to_fit(n_parameters, 1); + float const tolerance = 0.0001f; + int const max_n_iterations = 10; + + // initial parameters + Parameters true_parameters; + true_parameters.amplitude = 500.f; + true_parameters.center_x = static_cast(size_x) / 2.f - 0.5f; + true_parameters.center_y = static_cast(size_x) / 2.f - 0.5f; + true_parameters.width = 1.f; + true_parameters.background = 10.f; + + // test parameters + std::vector test_parameters(max_n_fits); + generate_test_parameters(test_parameters, true_parameters); + + // test data + std::vector data(max_n_fits * n_points); + generate_gauss2d(max_n_fits, n_points, data, test_parameters); + add_gauss_noise(data, true_parameters, 10.f); + + // initial parameter set + std::vector initial_parameters(n_parameters * max_n_fits); + generate_initial_parameters(initial_parameters, test_parameters); + + // print collumn identifiers + std::cout << std::endl << std::right; + std::cout << std::setw(8) << "Number" << std::setw(3) << "|"; + std::cout << std::setw(13) << "Cpufit speed" << std::setw(3) << "|"; + std::cout << std::setw(13) << "Gpufit speed" << std::setw(3) << "|"; + std::cout << std::setw(12) << "Performance"; + std::cout << std::endl; + std::cout << std::setw(8) << "of fits" << std::setw(3) << "|"; + std::cout << std::setw(13) << "(fits/s)" << std::setw(3) << "|"; + std::cout << std::setw(13) << "(fits/s)" << std::setw(3) << "|"; + std::cout << std::setw(12) << "gain factor"; + std::cout << std::endl; + std::cout << "-------------------------------------------------------"; + std::cout << std::endl; + + // loop over number of fits + for (std::size_t fit_index = 0; fit_index < n_fits_all.size(); fit_index++) + { + // number of fits + std::size_t n_fits = n_fits_all[fit_index]; + std::cout << std::setw(8) << n_fits << std::setw(3) << "|"; + + // Cpufit output + std::vector cpufit_parameters(n_fits * n_parameters); + std::vector cpufit_states(n_fits); + std::vector cpufit_chi_squares(n_fits); + std::vector cpufit_n_iterations(n_fits); + + // run Cpufit and measure time + std::chrono::high_resolution_clock::time_point t0 = std::chrono::high_resolution_clock::now(); + int const cpu_status + = cpufit + ( + n_fits, + n_points, + data.data(), + 0, + GAUSS_2D, + initial_parameters.data(), + tolerance, + max_n_iterations, + parameters_to_fit.data(), + LSE, + 0, + 0, + cpufit_parameters.data(), + cpufit_states.data(), + cpufit_chi_squares.data(), + cpufit_n_iterations.data() + ); + std::chrono::milliseconds::rep const dt_cpufit = std::chrono::duration_cast(std::chrono::high_resolution_clock::now() - t0).count(); + + if (cpu_status != 0) + { + // error in cpufit, should actually not happen + std::cout << "Error in cpufit: " << cpufit_get_last_error() << std::endl; + } + + std::chrono::milliseconds::rep dt_gpufit = 0; + + // if we do not do gpufit, we skip the rest of the loop + if (do_gpufits) + { + // Gpufit output parameters + std::vector gpufit_parameters(n_fits * n_parameters); + std::vector gpufit_states(n_fits); + std::vector gpufit_chi_squares(n_fits); + std::vector gpufit_n_iterations(n_fits); + + // run Gpufit and measure time + t0 = std::chrono::high_resolution_clock::now(); + int const gpu_status + = gpufit + ( + n_fits, + n_points, + data.data(), + 0, + GAUSS_2D, + initial_parameters.data(), + tolerance, + max_n_iterations, + parameters_to_fit.data(), + LSE, + 0, + 0, + gpufit_parameters.data(), + gpufit_states.data(), + gpufit_chi_squares.data(), + gpufit_n_iterations.data() + ); + dt_gpufit = std::chrono::duration_cast(std::chrono::high_resolution_clock::now() - t0).count(); + + if (gpu_status != 0) + { + // error in gpufit + std::cout << "Error in gpufit: " << gpufit_get_last_error() << std::endl; + do_gpufits = false; + } + } + + // print the calculation speed in fits/s + std::cout << std::fixed << std::setprecision(0); + if (dt_cpufit) + { + std::cout << std::setw(13) << static_cast(n_fits) / static_cast(dt_cpufit)* 1000.0 << std::setw(3) << "|"; + } + else + { + std::cout << std::setw(13) << "inf" << std::setw(3) << "|"; + } + if (dt_gpufit) + { + std::cout << std::setw(13) << static_cast(n_fits) / static_cast(dt_gpufit)* 1000.0 << std::setw(3) << "|"; + std::cout << std::fixed << std::setprecision(2); + std::cout << std::setw(12) << static_cast(dt_cpufit) / static_cast(dt_gpufit); + } + else if (!do_gpufits) + { + std::cout << std::setw(13) << "--" << std::setw(3) << "|"; + std::cout << std::setw(12) << "--"; + } + else + { + std::cout << std::setw(13) << "inf" << std::setw(3) << "|"; + std::cout << std::setw(12) << "inf"; + } + + std::cout << std::endl; + } + std::cout << std::endl << "Test completed!" << std::endl; + std::cout << "Press ENTER to exit" << std::endl; + std::getchar(); + + return 0; +} \ No newline at end of file diff --git a/examples/Gpufit_Cpufit_Performance_Comparison_readme.txt b/examples/Gpufit_Cpufit_Performance_Comparison_readme.txt new file mode 100644 index 0000000..92339af --- /dev/null +++ b/examples/Gpufit_Cpufit_Performance_Comparison_readme.txt @@ -0,0 +1,106 @@ +Example application for the Gpufit library (https://github.com/gpufit/Gpufit) +which implements Levenberg Marquardt curve fitting in CUDA. + +Requirements +------------ + +- A CUDA capable graphics card with a recent Nvidia graphics driver + (at least 367.48 / July 2016) +- Windows +- >1.5 GB of free RAM + +Running +------- + +Start "Gpufit_Cpufit_Performance_Comparison.exe" to see a speed comparison of +GPU and CPU implementation. + +Output +------ + +The accurate execution of the performance comparison example shows the version +number of the installed CUDA driver and the CUDA runtime Gpufit was built with. + +EXAMPLE: + CUDA runtime version: 8.0 + CUDA driver version: 9.0 + +In the next step the successful generation of test data is indicated by three +full progress bars. + +EXAMPLE: + + ------------------------- + Generating test parameters ||||||||||||||||||||||||| + ------------------------- + ------------------------- + Generating data ||||||||||||||||||||||||| + ------------------------- + ------------------------- + Adding noise ||||||||||||||||||||||||| + ------------------------- + +The results of the performance comparison between Gpufit and Cpufit are shown +in a table. The results demonstrate the performance benefit of Gpufit compared +to Cpufit executing the fitting process vor various number of fits in a range +of 10 - 10000000. The execution speed is expressed in fits per second. If the +execution time was not measureable, the speed is expressed as infinite. + +EXAMPLE: + + Number | Cpufit speed | Gpufit speed | Performance + of fits | (fits/s) | (fits/s) | gain factor + ------------------------------------------------------- + 10 | inf | 92 | 0.00 + 100 | inf | 6667 | 0.00 + 1000 | 66667 | inf | inf + 10000 | 58480 | 666667 | 11.40 + 100000 | 59916 | 2173913 | 36.28 + 1000000 | 59898 | 2469136 | 41.22 + 10000000 | 60957 | 3038590 | 49.85 + +Troubleshooting +--------------- + +MESSAGE: + + CUDA runtime version: 0.0 + CUDA driver version: 7.5 + + The CUDA runtime version is not compatible with the current graphics driver. + Please update the driver, or re-build Gpufit from source using a compatible + version of the CUDA toolkit. + + Skipping Gpufit computations. + +BEHAVIOR: + + The example executes Cpufit skipping Gpufit. Only computation speed of Cpufit + is shown in the results table. + +SOLUTION: + + A common reason for this error message is an outdated Nvidia graphics driver. + In most cases updating the graphics card driver will solve this error. For + older graphics cards which are not supported by the CUDA toolkit used for + building Gpufit, re-compile Gpufit using an earlier version of the CUDA + toolkit which is compatible with the graphics driver. + +MESSAGE: + + CUDA runtime version: 0.0 + CUDA driver version: 0.0 + + No CUDA enabled graphics card detected. + + Skipping Gpufit computations. + +BEHAVIOR: + + The example executes Cpufit skipping Gpufit. Only computation speed of Cpufit + is shown in the results table. + +SOLUTION: + + The execution of Gpufit requires a CUDA enabled graphics card. + Ensure, that the host PC has installed a CUDA enabled graphics card. \ No newline at end of file diff --git a/package/README.md b/package/README.md new file mode 100644 index 0000000..ebf9279 --- /dev/null +++ b/package/README.md @@ -0,0 +1,48 @@ +# Creating a binary package + +The binary package bundles different builds outputs into a single distributable binary package containing the Gpufit dll, +the performance comparison example, the Matlab bindings and the Python bindings. + +## Calling the script + +create_package.bat %1 %2 %3 + +with + +- %1 is the BUILD_BASE_PATH (the path containing the various (see below) CMake generated Visual Studio projects) + +- %2 is the VERSION (e.g. 1.0.0) + +- %3 is the SOURCE_BASE_PATH (the path containing the sources) + +The output is a folder (BUILD_BASE_PATH/Gpufit-VERSION) which is also zipped if 7-Zip is available. + +## Requirements + +Note: The script has no way of checking that the requirements are fulfilled! + +See also [Build from sources](http://Gpufit.readthedocs.io/en/latest/installation.html#build-from-sources) for instructions. + +CMake + +- CUDA_ARCHITECTURE must be set to All (it is by default) + +- CUDA toolkit 8.0 is used for all builds (must be installed before) + +- Build directory for MSVC14 Win64 is BUILD_BASE_PATH/VC14x64-8.0 + +- Build directory for MSVC14 Win32 is BUILD_BASE_PATH/VC14x32-8.0 + +- Matlab and Python must be available + +Build + +- Configuration RelWithDebInfo is used for all builds! + +- With MSVC14 Win64 build target PYTHON_WHEEL, MATLAB_GPUFIT_PACKAGE and the Gpufit_Cpufit_Performance_Comparison example + +- With MSVC14 Win32 build target PYTHON_WHEEL, MATLAB_GPUFIT_PACKAGE and the Gpufit_Cpufit_Performance_Comparison example + +Documentation + +- An up-to-date version of the documentation must exist at SOURCE_BASE_PATH\docs\_build\latex\Gpufit.pdf (must be created before). \ No newline at end of file diff --git a/package/create_package.bat b/package/create_package.bat new file mode 100644 index 0000000..75ba751 --- /dev/null +++ b/package/create_package.bat @@ -0,0 +1,170 @@ +@ECHO OFF + +REM create package for Gpufit, assumes everything is compiled + +if "%1" == "" ( + echo specify build base path + goto end +) + +if "%2" == "" ( + echo specify version + goto end +) + +if "%3" == "" ( + echo specify source base path + goto end +) + +REM date and time from https://stackoverflow.com/a/30343827/1536976 + +@SETLOCAL ENABLEDELAYEDEXPANSION + +@REM Use WMIC to retrieve date and time +@echo off +FOR /F "skip=1 tokens=1-6" %%A IN ('WMIC Path Win32_LocalTime Get Day^,Hour^,Minute^,Month^,Second^,Year /Format:table') DO ( + IF NOT "%%~F"=="" ( + SET /A SortDate = 10000 * %%F + 100 * %%D + %%A + set YEAR=!SortDate:~0,4! + set MON=!SortDate:~4,2! + set DAY=!SortDate:~6,2! + @REM Add 1000000 so as to force a prepended 0 if hours less than 10 + SET /A SortTime = 1000000 + 10000 * %%B + 100 * %%C + %%E + set HOUR=!SortTime:~1,2! + set MIN=!SortTime:~3,2! + set SEC=!SortTime:~5,2! + ) +) + +set DATECODE=!YEAR!!MON!!DAY!!HOUR!!MIN! +echo %DATECODE% + +REM define paths + +set BUILD_BASE=%1 +set VERSION=%2 +set SOURCE_BASE=%3 + +set OUTPUT_NAME=Gpufit_%VERSION%_win32_win64_build%DATECODE% +set ROOT_INSTALL=%BUILD_BASE%\%OUTPUT_NAME% +set OUTPUT_ZIP=%BUILD_BASE%\%OUTPUT_NAME%.zip + +set PERFORMANCE_TEST_INSTALL=%ROOT_INSTALL%\gpufit_performance_test +set PYTHON_INSTALL=%ROOT_INSTALL%\python +set x32_MATLAB_INSTALL=%ROOT_INSTALL%\matlab32 +set x64_MATLAB_INSTALL=%ROOT_INSTALL%\matlab64 +set SDK_INSTALL_ROOT=%ROOT_INSTALL%\gpufit_sdk + +set x64_BUILD=%BUILD_BASE%\VC14x64-8.0\RelWithDebInfo +set x64_BUILD_LIB=%BUILD_BASE%\VC14x64-8.0\Gpufit\RelWithDebInfo +set x32_BUILD=%BUILD_BASE%\VC14x32-8.0\RelWithDebInfo +set x32_BUILD_LIB=%BUILD_BASE%\VC14x32-8.0\Gpufit\RelWithDebInfo + +set x64_PYTHON_BUILD=%x64_BUILD%\pyGpufit\dist +set x32_PYTHON_BUILD=%x32_BUILD%\pyGpufit\dist + +set x64_MATLAB_BUILD=%x64_BUILD%\matlab +set x32_MATLAB_BUILD=%x32_BUILD%\matlab + +set EXAMPLES_SOURCE=%SOURCE_BASE%\examples +set PYTHON_SOURCE=%SOURCE_BASE%\Gpufit\python +set MATLAB_SOURCE=%SOURCE_BASE%\Gpufit\matlab +set SDK_README_SOURCE=%SOURCE_BASE%\package\sdk_readme.txt + +set MANUAL_SOURCE=%SOURCE_BASE%\docs\_build\latex\Gpufit.pdf +set MANUAL_INSTALL=%ROOT_INSTALL%\Gpufit_%VERSION%_Manual.pdf + +REM clean up (if necessary) + +if exist "%ROOT_INSTALL%" rmdir /s /q "%ROOT_INSTALL%" +if exist "%OUTPUT_ZIP%" del "%OUTPUT_ZIP%" + +REM create root folder + +echo create root directory +mkdir "%ROOT_INSTALL%" + +REM copy main readme (is markdown, written as txt) and license + +copy "%SOURCE_BASE%\README.md" "%ROOT_INSTALL%\README.txt" +copy "%SOURCE_BASE%\LICENSE.txt" "%ROOT_INSTALL%" + +REM copy manual + +if not exist "%MANUAL_SOURCE%" ( + echo file %MANUAL_SOURCE% required, does not exist + goto end +) +copy "%MANUAL_SOURCE%" "%MANUAL_INSTALL%" + +REM copy performance test + +echo collect performance test application +mkdir "%PERFORMANCE_TEST_INSTALL%" +copy "%EXAMPLES_SOURCE%\Gpufit_Cpufit_Performance_Comparison_readme.txt" "%PERFORMANCE_TEST_INSTALL%\README.txt" + +mkdir "%PERFORMANCE_TEST_INSTALL%\win64" +copy "%x64_BUILD%\Gpufit_Cpufit_Performance_Comparison.exe" "%PERFORMANCE_TEST_INSTALL%\win64" +copy "%x64_BUILD%\Gpufit.dll" "%PERFORMANCE_TEST_INSTALL%\win64" +copy "%x64_BUILD%\Cpufit.dll" "%PERFORMANCE_TEST_INSTALL%\win64" + +mkdir "%PERFORMANCE_TEST_INSTALL%\win32" +copy "%x32_BUILD%\Gpufit_Cpufit_Performance_Comparison.exe" "%PERFORMANCE_TEST_INSTALL%\win32" +copy "%x32_BUILD%\Gpufit.dll" "%PERFORMANCE_TEST_INSTALL%\win32" +copy "%x32_BUILD%\Cpufit.dll" "%PERFORMANCE_TEST_INSTALL%\win32" + +REM copy Python packages + +echo collect python +mkdir "%PYTHON_INSTALL%" +copy "%x64_PYTHON_BUILD%\pyGpufit-%VERSION%-py2.py3-none-any.whl" "%PYTHON_INSTALL%\pyGpufit-%VERSION%-py2.py3-none-win_amd64.whl" +copy "%x32_PYTHON_BUILD%\pyGpufit-%VERSION%-py2.py3-none-any.whl" "%PYTHON_INSTALL%\pyGpufit-%VERSION%-py2.py3-none-win32.whl" +copy "%PYTHON_SOURCE%\README.txt" "%PYTHON_INSTALL%" +xcopy "%PYTHON_SOURCE%\examples" "%PYTHON_INSTALL%\examples" /i /q + +REM copy Matlab 32 bit + +echo collect matlab32 +mkdir "%x32_MATLAB_INSTALL%" +xcopy "%x32_MATLAB_BUILD%" "%x32_MATLAB_INSTALL%" /q +xcopy "%MATLAB_SOURCE%\examples" "%x32_MATLAB_INSTALL%\examples" /i /q + +REM copy Matlab 64 bit + +echo collect matlab64 +mkdir "%x64_MATLAB_INSTALL%" +xcopy "%x64_MATLAB_BUILD%" "%x64_MATLAB_INSTALL%" /q +xcopy "%MATLAB_SOURCE%\examples" "%x64_MATLAB_INSTALL%\examples" /i /q + +REM copy SDK_INSTALL_ROOT + +echo collect SDK +mkdir "%SDK_INSTALL_ROOT%" +copy "%SDK_README_SOURCE%" "%SDK_INSTALL_ROOT%\README.txt" + +mkdir "%SDK_INSTALL_ROOT%\include" +copy "%SOURCE_BASE%\Gpufit\gpufit.h" "%SDK_INSTALL_ROOT%\include" + +mkdir "%SDK_INSTALL_ROOT%\win32" +copy "%x32_BUILD%\Gpufit.dll" "%SDK_INSTALL_ROOT%\win32" +copy "%x32_BUILD_LIB%\Gpufit.lib" "%SDK_INSTALL_ROOT%\win32" + +mkdir "%SDK_INSTALL_ROOT%\win64" +copy "%x64_BUILD%\Gpufit.dll" "%SDK_INSTALL_ROOT%\win64" +copy "%x64_BUILD_LIB%\Gpufit.lib" "%SDK_INSTALL_ROOT%\win64" + +REM zip content of temp folder with 7-Zip if availabe + +set ZIP=C:\Program Files\7-Zip\7z.exe + +if not exist "%ZIP%" ( + echo 7-Zip not installed, zip manually + goto end +) ELSE ( + echo zip result + "%ZIP%" a -y -r -mem=AES256 "%OUTPUT_ZIP%" "%ROOT_INSTALL%%" > nul +) + +:end +PAUSE \ No newline at end of file diff --git a/package/sdk_readme.txt b/package/sdk_readme.txt new file mode 100644 index 0000000..59fc094 --- /dev/null +++ b/package/sdk_readme.txt @@ -0,0 +1,10 @@ +Software development kit for the Gpufit library (https://github.com/gpufit/Gpufit) +which implements Levenberg Marquardt curve fitting in CUDA. + +Compiled with the Microsoft Visual Studio 2015 C++ compiler and CUDA toolkit 8.0. + +Folder include contains the gpufit.h header file representing the C API. + +Folder win32 contains the 32 bit compiled dynamic link library and import libary. + +Folder win64 contains the 64 bit compiled dynamic link library and import libary. \ No newline at end of file diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..c524ac3 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,4 @@ + +# Tests + +add_boost_test( "Cpufit;Gpufit" Consistency ) diff --git a/tests/Consistency.cpp b/tests/Consistency.cpp new file mode 100644 index 0000000..feb1032 --- /dev/null +++ b/tests/Consistency.cpp @@ -0,0 +1,220 @@ +#define BOOST_TEST_MODULE Gpufit + +#include "Cpufit/cpufit.h" +#include "Gpufit/gpufit.h" +#include "Tests/utils.h" + +#include + +#include + +void generate_input_linear_fit_1d(FitInput & i) +{ + // number fits, points, parameters + i.n_fits = 1; + i.n_points = 2; + i.n_parameters = 2; // LINEAR_1D has two parameters + + // data and weights + i.data = { 0, 1 }; + i.weights_ = { 1, 1 }; + + // model id and estimator id + i.model_id = LINEAR_1D; + i.estimator_id = LSE; + + // initial parameters and parameters to fit + i.initial_parameters = { 0, 0 }; + i.parameters_to_fit = { 1, 1 }; + + // tolerance and max_n_iterations + i.tolerance = 0.001f; + i.max_n_iterations = 10; + + // user info + i.user_info_ = { 0.f, 1.f }; +} + +void generate_input_gauss_fit_1d(FitInput & i) +{ + // number fits, points, parameters + i.n_fits = 1; + i.n_points = 5; + i.n_parameters = 4; // GAUSS_1D has four parameters + + // data and weights + clean_resize(i.data, i.n_fits * i.n_points); + std::vector< float > const true_parameters{ { 4.f, 2.f, 0.5f, 1.f } }; + generate_gauss_1d(i.data, true_parameters); + i.weights_.clear(); // no weights + + // model id and estimator id + i.model_id = GAUSS_1D; + i.estimator_id = LSE; + + // initial parameters and parameters to fit + i.initial_parameters = { 2.f, 1.5f, 0.3f, 0.f }; + i.parameters_to_fit = { 1, 1, 1, 1 }; + + // tolerance and max_n_iterations + i.tolerance = 0.001f; + i.max_n_iterations = 10; + + // user info + i.user_info_.clear(); // no user info +} + +void generate_input_gauss_fit_2d(FitInput & i) +{ + // number fits, points, parameters + i.n_fits = 1; + i.n_points = 25; + i.n_parameters = 5; // GAUSS_2D has five parameters + + // data and weights + clean_resize(i.data, i.n_fits * i.n_points); + std::vector< float > const true_parameters{ { 4.f, 1.8f, 2.2f, 0.5f, 1.f } }; + generate_gauss_2d(i.data, true_parameters); + i.weights_.clear(); // no weights + + // model id and estimator id + i.model_id = GAUSS_2D; + i.estimator_id = LSE; + + // initial parameters and parameters to fit + i.initial_parameters = { 2.f, 1.8f, 2.2f, 0.4f, 0.f }; + i.parameters_to_fit = { 1, 1, 1, 1, 1 }; + + // tolerance and max_n_iterations + i.tolerance = 0.0001f; + i.max_n_iterations = 20; + + // user info + i.user_info_.clear(); // no user info +} + +void generate_input_gauss_fit_2d_elliptic(FitInput & i) +{ + // number fits, points, parameters + i.n_fits = 1; + std::size_t const size_x = 5; + i.n_points = size_x * size_x; + i.n_parameters = 6; // GAUSS_2D_ELLIPTIC has five parameters + + // data and weights + clean_resize(i.data, i.n_fits * i.n_points); + + float const center_x = (static_cast(size_x) - 1.f) / 2.f; + std::vector< float > const true_parameters{ { 4.f, center_x, center_x, 0.4f, 0.6f, 1.f} }; + generate_gauss_2d_elliptic(i.data, true_parameters); + i.weights_.clear(); // no weights + + // model id and estimator id + i.model_id = GAUSS_2D_ELLIPTIC; + i.estimator_id = LSE; + + // initial parameters and parameters to fit + i.initial_parameters = { 2.f, 1.8f, 2.2f, 0.5f, 0.5f, 0.f }; + i.parameters_to_fit = { 1, 1, 1, 1, 1 }; + + // tolerance and max_n_iterations + i.tolerance = 0.001f; + i.max_n_iterations = 10; + + // user info + i.user_info_.clear(); // no user info +} + +void perform_cpufit_gpufit_and_check(void (*func)(FitInput &)) +{ + // generate the data + FitInput i; + func(i); + + // sanity checks (we don't want to introduce faulty data) + BOOST_CHECK(i.sanity_check()); + + // reset output variables + FitOutput gpu, cpu; + clean_resize(gpu.parameters, i.n_fits * i.n_parameters); + clean_resize(gpu.states, i.n_fits); + clean_resize(gpu.chi_squares, i.n_fits); + clean_resize(gpu.n_iterations, i.n_fits); + + clean_resize(cpu.parameters, i.n_fits * i.n_parameters); + clean_resize(cpu.states, i.n_fits); + clean_resize(cpu.chi_squares, i.n_fits); + clean_resize(cpu.n_iterations, i.n_fits); + + + // call to cpufit, store output + int const cpu_status + = cpufit + ( + i.n_fits, + i.n_points, + i.data.data(), + i.weights(), + i.model_id, + i.initial_parameters.data(), + i.tolerance, + i.max_n_iterations, + i.parameters_to_fit.data(), + i.estimator_id, + i.user_info_size(), + i.user_info(), + cpu.parameters.data(), + cpu.states.data(), + cpu.chi_squares.data(), + cpu.n_iterations.data() + ); + + BOOST_CHECK(cpu_status == 0); + + // call to gpufit, store output + int const gpu_status + = gpufit + ( + i.n_fits, + i.n_points, + i.data.data(), + i.weights(), + i.model_id, + i.initial_parameters.data(), + i.tolerance, + i.max_n_iterations, + i.parameters_to_fit.data(), + i.estimator_id, + i.user_info_size(), + i.user_info(), + gpu.parameters.data(), + gpu.states.data(), + gpu.chi_squares.data(), + gpu.n_iterations.data() + ); + + BOOST_CHECK(gpu_status == 0); + + // check both output for equality + BOOST_CHECK(cpu.states == gpu.states); + BOOST_CHECK(cpu.n_iterations == gpu.n_iterations); + BOOST_CHECK(close_or_equal(cpu.parameters, gpu.parameters)); + BOOST_CHECK(close_or_equal(cpu.chi_squares, gpu.chi_squares)); + +} + +BOOST_AUTO_TEST_CASE( Consistency ) +{ + BOOST_TEST_MESSAGE( "linear_fit_1d" ); + perform_cpufit_gpufit_and_check(&generate_input_linear_fit_1d); + + BOOST_TEST_MESSAGE( "gauss_fit_1d" ); + perform_cpufit_gpufit_and_check(&generate_input_gauss_fit_1d); + + BOOST_TEST_MESSAGE( "gauss_fit_2d" ); + perform_cpufit_gpufit_and_check(&generate_input_gauss_fit_2d); + + BOOST_TEST_MESSAGE("gauss_fit_2d_elliptic"); + perform_cpufit_gpufit_and_check(&generate_input_gauss_fit_2d_elliptic); + +} diff --git a/tests/utils.cpp b/tests/utils.cpp new file mode 100644 index 0000000..16f3970 --- /dev/null +++ b/tests/utils.cpp @@ -0,0 +1,60 @@ +#include "utils.h" + +// initialize random number generator +std::mt19937 rng(0); + +/* + Given a parameter vector p with 4 entries, constructs a 1D Gaussian peak function with x values 0,..,v.size() - 1 +*/ +void generate_gauss_1d(std::vector< float > & v, std::vector< float > const & p) +{ + for (std::size_t i = 0; i < v.size(); i++) + { + float const argx = ((i - p[1]) * (i - p[1])) / (2.f * p[2] * p[2]); + float const ex = exp(-argx); + v[i] = p[0] * ex + p[3]; + } +} + +/* + Given a parameters vector p with 5 entries, constructs a 2D Gaussian peak function with x, y values 0, .., sqrt(v.size()) - 1 +*/ +void generate_gauss_2d(std::vector< float > & v, std::vector< float > const & p) +{ + std::size_t const n = static_cast(std::sqrt(v.size())); + if (n * n != v.size()) + { + throw std::runtime_error("v.size() is not a perfect square number"); + } + + for (std::size_t j = 0; j < n; j++) + { + float const argy = ((j - p[2]) * (j - p[2])); + for (std::size_t i = 0; i < n; i++) + { + float const argx = ((i - p[1]) * (i - p[1])); + float const ex = exp(-(argx + argy) / (2.f * p[3] * p[3])); + v[j * n + i] = p[0] * ex + p[3]; + } + } +} + +void generate_gauss_2d_elliptic(std::vector< float > & v, std::vector< float > const & p) +{ + std::size_t const n = static_cast(std::sqrt(v.size())); + if (n * n != v.size()) + { + throw std::runtime_error("v.size() is not a perfect square number"); + } + + for (std::size_t j = 0; j < n; j++) + { + float const argy = ((j - p[2]) * (j - p[2])) / (2.f * p[4] * p[4]); + for (std::size_t i = 0; i < n; i++) + { + float const argx = ((i - p[1]) * (i - p[1])) / (2.f * p[3] * p[3]); + float const ex = exp(-(argx + argy)); + v[j * n + i] = p[0] * ex + p[3]; + } + } +} \ No newline at end of file diff --git a/tests/utils.h b/tests/utils.h new file mode 100644 index 0000000..dd0caa7 --- /dev/null +++ b/tests/utils.h @@ -0,0 +1,176 @@ +#ifndef TEST_UTILS_H_INCLUDED +#define TEST_UTILS_H_INCLUDED + +#include +#include + +#define CHK(x) if (!x) return false + +extern std::mt19937 rng; + +/* +Just to make sure that the content is erased after the resize. +*/ +template void clean_resize(std::vector & v, std::size_t const n) +{ + v.resize(n); + std::fill(v.begin(), v.end(), (T)0); +} + +template double max_relative_difference(std::vector const & a, std::vector const & b) +{ + double v = 0; + + auto it_a = a.begin(); + auto it_b = b.begin(); + + while (it_a !=a.end()) + { + T va = *it_a++; + T vb = *it_b++; + double d = static_cast(std::abs(va - vb)) / (std::abs(va) + std::abs(vb)); + v = std::max(v, d); + } + return v; +} + +template double max_absolute_difference(std::vector const & a, std::vector const & b) +{ + double v = 0; + + auto it_a = a.begin(); + auto it_b = b.begin(); + + while (it_a != a.end()) + { + T va = *it_a++; + T vb = *it_b++; + double d = static_cast(std::abs(va - vb)); + v = std::max(v, d); + } + return v; +} + +template bool close_or_equal(std::vector const & a, std::vector const & b, double relative_threshold = 1e-3, double absolute_threshold = 1e-6) +{ + if (a.empty() && b.empty()) + { + return true; + } + if (a.size() != b.size()) + { + return false; + } + double rd = max_relative_difference(a, b); + double ad = max_absolute_difference(a, b); + return rd < relative_threshold || ad < absolute_threshold; +} + +/* +Calculates the standard deviation of a vector whose values are the differences of values of two others vectors of equal length. +Only use values if use[i] == 0. +*/ +template double calculate_standard_deviation(std::vector const & a, std::vector const & b, std::vector const & use) +{ + std::size_t n = 0; + double sq_diff = 0; + + for (std::size_t i = 0; i < a.size(); i++) + { + if (use[i] == 0) + { + n++; + sq_diff += static_cast((a[i] - b[i])) * (a[i] - b[i]); + } + } + + double std_dev = std::sqrt(sq_diff / n); + return std_dev; +} + +template double calculate_mean(std::vector const & a, std::vector const & use) +{ + std::size_t n = 0; + double s = 0; + + for (std::size_t i = 0; i < a.size(); i++) + { + if (use[i] == 0) + { + n++; + s += static_cast(a[i]); + } + } + return s / n; +} + +void generate_gauss_1d(std::vector< float > & v, std::vector< float > const & p); + +void generate_gauss_2d(std::vector< float > & v, std::vector< float > const & p); + +void generate_gauss_2d_elliptic(std::vector< float > & v, std::vector< float > const & p); + +struct FitInput +{ + std::size_t n_fits; + std::size_t n_points; + std::size_t n_parameters; + + std::vector< float > data; + std::vector< float > weights_; // size 0 means no weights + + int model_id; + int estimator_id; + + std::vector< float > initial_parameters; + std::vector< int > parameters_to_fit; + + float tolerance; + int max_n_iterations; + + std::vector< float > user_info_; // user info is float + + float * weights() + { + if (!this->weights_.empty()) + { + return this->weights_.data(); + } + return 0; + } + + char * user_info() + { + if (!this->user_info_.empty()) + { + return reinterpret_cast(this->user_info_.data()); + } + return 0; + } + + std::size_t user_info_size() + { + return this->user_info_.size() * sizeof(float); // type of user_info is float + } + + bool sanity_check() + { + CHK(this->data.size() == this->n_fits * this->n_points); + if (!this->weights_.empty()) + { + CHK(this->weights_.size() == this->n_fits * this->n_points); + } + CHK(this->initial_parameters.size() == this->n_fits * this->n_parameters); + return true; + } +}; + +struct FitOutput +{ + std::vector< float > parameters; + std::vector< int > states; + std::vector< float > chi_squares; + std::vector< int > n_iterations; +}; + +#endif \ No newline at end of file