From 6c211fc48597287c72822d6d394f91f00715932b Mon Sep 17 00:00:00 2001 From: loker2024 Date: Wed, 16 Sep 2026 09:49:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=9C=9F=E6=9D=83=E5=AE=9A=E4=BB=B7):=20?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=20CUDA=20=E8=92=99=E7=89=B9=E5=8D=A1?= =?UTF-8?q?=E6=B4=9B=E5=AE=9A=E4=BB=B7=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 整理金融衍生品定价与风险估计项目提交代码。 - 提供 C++17/CUDA 蒙特卡洛定价实现与示例配置 - 移除测试和实验产物,保留格式与关键注释 - 附上 Markdown 实验报告及其引用图表 --- .../.clang-format" | 3 + .../.gitignore" | 60 ++ .../CMakeLists.txt" | 107 ++++ .../README.md" | 149 +++++ .../configs/option_asian_call.ini" | 6 + .../configs/option_european_call.ini" | 6 + .../configs/simulation.ini" | 8 + .../configs/simulation_fp64.ini" | 8 + .../include/pricer/black_scholes.hpp" | 12 + .../include/pricer/config.hpp" | 86 +++ .../include/pricer/cpu_pricer.hpp" | 23 + .../include/pricer/cuda_pricer.hpp" | 66 +++ .../include/pricer/measurement.hpp" | 55 ++ .../include/pricer/output.hpp" | 87 +++ .../include/pricer/payoff.hpp" | 29 + .../include/pricer/statistics.hpp" | 41 ++ .../include/pricer/version.hpp" | 10 + .../figure-01-system-architecture.png" | Bin 0 -> 108152 bytes .../report/figures/figure-02-convergence.svg" | 71 +++ .../figures/figure-03-correctness-ci.svg" | 53 ++ .../figure-04-performance-scaling.svg" | 95 ++++ .../report/figures/figure-05-block-size.svg" | 39 ++ .../figures/figure-06-nsight-bottleneck.svg" | 44 ++ ...\256\241 CUDA \346\212\245\345\221\212.md" | 186 ++++++ .../src/black_scholes.cpp" | 74 +++ .../src/config.cpp" | 373 ++++++++++++ .../src/cpu_pricer.cpp" | 91 +++ .../src/cuda_backend.cu" | 533 ++++++++++++++++++ .../src/main.cpp" | 460 +++++++++++++++ .../src/measurement.cpp" | 120 ++++ .../src/output.cpp" | 524 +++++++++++++++++ .../src/payoff.cpp" | 82 +++ .../src/statistics.cpp" | 115 ++++ .../src/version.cpp" | 8 + 34 files changed, 3624 insertions(+) create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/.clang-format" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/.gitignore" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/CMakeLists.txt" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/README.md" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/configs/option_asian_call.ini" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/configs/option_european_call.ini" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/configs/simulation.ini" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/configs/simulation_fp64.ini" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/black_scholes.hpp" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/config.hpp" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/cpu_pricer.hpp" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/cuda_pricer.hpp" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/measurement.hpp" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/output.hpp" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/payoff.hpp" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/statistics.hpp" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/version.hpp" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-01-system-architecture.png" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-02-convergence.svg" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-03-correctness-ci.svg" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-04-performance-scaling.svg" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-05-block-size.svg" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-06-nsight-bottleneck.svg" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/report/\351\207\221\350\236\215\350\241\215\347\224\237\345\223\201\345\256\232\344\273\267\344\270\216\351\243\216\351\231\251\344\274\260\350\256\241 CUDA \346\212\245\345\221\212.md" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/src/black_scholes.cpp" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/src/config.cpp" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/src/cpu_pricer.cpp" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/src/cuda_backend.cu" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/src/main.cpp" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/src/measurement.cpp" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/src/output.cpp" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/src/payoff.cpp" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/src/statistics.cpp" create mode 100644 "05_option_pricing/\346\266\202\345\256\266\344\277\212/src/version.cpp" diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/.clang-format" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/.clang-format" new file mode 100644 index 00000000..96332f09 --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/.clang-format" @@ -0,0 +1,3 @@ +BasedOnStyle: LLVM +IndentWidth: 4 +ContinuationIndentWidth: 4 diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/.gitignore" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/.gitignore" new file mode 100644 index 00000000..4262a39f --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/.gitignore" @@ -0,0 +1,60 @@ +# CMake build directories (including build-* / cmake-build-* at any level) +/build/ +build-*/ +/out/ +cmake-build-*/ + +# CMake generated files +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +CTestTestfile.cmake +Makefile +compile_commands.json +install_manifest.txt + +# Compiled binaries and CUDA artifacts +*.a +*.dll +*.exe +*.exp +*.ilk +*.lib +*.obj +*.o +*.pdb +*.ptx +*.cubin +*.fatbin + +# IDE and editor settings +.vs/ +.vscode/ +.idea/ +*.suo +*.user +*.userosscache +*.sln.docstates + +# Generated experiment and profiling output +/results/* +!/results/.gitkeep +*.nsys-rep +*.ncu-rep +*.sqlite + +# Temporary files +*.log +*.tmp +*.py[cod] +__pycache__/ +*.swp +*~ +.DS_Store +Thumbs.db + +# Agent-managed isolated workspaces +/.worktrees/ +/.superpowers/ + +/.workbuddy \ No newline at end of file diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/CMakeLists.txt" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/CMakeLists.txt" new file mode 100644 index 00000000..2f6ff695 --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/CMakeLists.txt" @@ -0,0 +1,107 @@ +# 配置 CUDA 定价器的构建目标、可选分析能力和自动化测试。 +cmake_minimum_required(VERSION 3.24) + +project(cuda_pricer LANGUAGES CXX CUDA) + +# CUDA Toolkit 提供运行时、cuRAND 和 CUB。 +find_package(CUDAToolkit REQUIRED) + +if(NOT DEFINED CMAKE_CUDA_ARCHITECTURES OR CMAKE_CUDA_ARCHITECTURES STREQUAL "") + message(FATAL_ERROR "Set CMAKE_CUDA_ARCHITECTURES to the target GPU architecture.") +endif() + +# 将分析能力和性能模式显式设为独立开关。 +option(PRICER_ENABLE_NVTX "Enable NVTX performance markers" OFF) +option(PRICER_ENABLE_FAST_MATH "Enable CUDA fast math" OFF) + +# 固定第三方库版本,保证干净环境能重复配置。 +include(FetchContent) + +FetchContent_Declare( + nlohmann_json + GIT_REPOSITORY https://github.com/nlohmann/json.git + GIT_TAG v3.11.3 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(nlohmann_json) + +# 与设备无关的金融模型、配置、统计和输出代码。 +add_library(pricer_core STATIC + src/black_scholes.cpp + src/config.cpp + src/cpu_pricer.cpp + src/measurement.cpp + src/output.cpp + src/payoff.cpp + src/statistics.cpp + src/version.cpp +) +add_library(pricer::core ALIAS pricer_core) +target_compile_features(pricer_core PUBLIC cxx_std_17) +target_include_directories(pricer_core + PUBLIC + $ +) +target_link_libraries(pricer_core PUBLIC nlohmann_json::nlohmann_json) + +# 单独构建设备端后端,避免 CPU 测试依赖 CUDA 内核实现细节。 +add_library(pricer_cuda_backend STATIC src/cuda_backend.cu) +add_library(pricer::cuda_backend ALIAS pricer_cuda_backend) +target_compile_features(pricer_cuda_backend PUBLIC cxx_std_17) +target_include_directories(pricer_cuda_backend + PUBLIC + $ +) +target_link_libraries(pricer_cuda_backend PUBLIC pricer::core CUDA::cudart) +if(MSVC) + target_compile_options(pricer_cuda_backend PRIVATE + $<$:-Xcompiler=/Zc:preprocessor> + ) +endif() +set_target_properties(pricer_cuda_backend PROPERTIES CUDA_SEPARABLE_COMPILATION ON) + +# 快速数学只用于明确请求的性能实验,不能作为正确性默认值。 +if(PRICER_ENABLE_FAST_MATH) + target_compile_options(pricer_cuda_backend PRIVATE + $<$:--use_fast_math> + ) +endif() + +# NVTX 标记仅为剖析提供时间线语义,常规构建不依赖它。 +if(PRICER_ENABLE_NVTX) + set(PRICER_NVTX_INCLUDE_DIR "" CACHE PATH + "Directory containing nvtx3/nvToolsExt.h") + if(NOT PRICER_NVTX_INCLUDE_DIR) + unset(PRICER_NVTX_INCLUDE_DIR CACHE) + find_path(PRICER_NVTX_INCLUDE_DIR nvtx3/nvToolsExt.h + HINTS ${CUDAToolkit_INCLUDE_DIRS} "$ENV{NVTX_PATH}" + PATH_SUFFIXES include) + endif() + if(NOT PRICER_NVTX_INCLUDE_DIR) + message(FATAL_ERROR + "PRICER_ENABLE_NVTX requires nvtx3/nvToolsExt.h; set PRICER_NVTX_INCLUDE_DIR.") + endif() + target_compile_definitions(pricer_cuda_backend PRIVATE PRICER_ENABLE_NVTX=1) + target_include_directories(pricer_cuda_backend PRIVATE + "${PRICER_NVTX_INCLUDE_DIR}") + target_link_libraries(pricer_cuda_backend PRIVATE CUDA::nvtx3) +endif() + +# CLI 负责把配置、定价后端和文件输出串成完整一次运行。 +add_executable(pricer_cli src/main.cpp) +target_compile_features(pricer_cli PRIVATE cxx_std_17) +target_link_libraries(pricer_cli PRIVATE pricer::core pricer::cuda_backend) +execute_process( + COMMAND git rev-parse --short HEAD + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + OUTPUT_VARIABLE PRICER_GIT_COMMIT + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET +) +if(PRICER_GIT_COMMIT STREQUAL "") + set(PRICER_GIT_COMMIT "unknown") +endif() +target_compile_definitions(pricer_cli PRIVATE + PRICER_BUILD_TYPE="$" + PRICER_GIT_COMMIT="${PRICER_GIT_COMMIT}" +) diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/README.md" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/README.md" new file mode 100644 index 00000000..79a8570b --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/README.md" @@ -0,0 +1,149 @@ +# CUDA 金融衍生品定价器 + +这是一个使用 C++17 和 CUDA C++ 实现的蒙特卡洛定价命令行项目。它以 CPU 单线程实现作为基线,并提供 GPU 加速路径,用于定价欧式看涨期权和离散算术平均亚式看涨期权。 + +项目不是无参数可执行程序:运行 `pricer_cli` 时必须同时提供期权配置和模拟配置。下面的命令可从克隆仓库开始完成配置、构建、测试与一次实际定价。 + +## 功能范围 + +- 欧式看涨与离散算术平均亚式看涨期权; +- Black-Scholes 欧式看涨解析参考价; +- CPU 单线程蒙特卡洛基线; +- CUDA 蒙特卡洛路径,使用 cuRAND Philox 和 CUB 归约; +- FP32 / FP64 GPU 路径、FP64 统计量、标准误与 95% 置信区间; +- 每次运行输出 JSON 结果,并向性能 CSV 追加记录。 + +## 环境要求 + +- CMake 3.24 或更高版本; +- 支持 C++17 的主机编译器; +- CUDA Toolkit 12.x(含 cuRAND 与 CUB)及可用的 NVIDIA GPU; +- Ninja; +- 配置阶段能访问 GitHub:CMake 会获取固定版本的 nlohmann/json 3.11.3。 + +构建前需要按实际显卡指定 `CMAKE_CUDA_ARCHITECTURES`。例如,RTX 4060 Laptop 的计算能力为 8.9,对应 CMake 值 `89`。Linux/WSL 中可先查看: + +```bash +nvidia-smi --query-gpu=compute_cap --format=csv,noheader +``` + +去掉小数点后填入构建命令;例如 `8.6` 使用 `86`,`8.9` 使用 `89`。 + +## 构建 + +以下命令适用于 Linux 和 WSL。Windows 请在已初始化 MSVC 与 CUDA Toolkit 的开发者终端中执行同样的 CMake 命令;若使用 Ninja,Windows 可执行文件扩展名为 `.exe`。 + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_CUDA_ARCHITECTURES=89 + +cmake --build build -j +``` + +`89` 只是 RTX 4060 Laptop 的示例,不应照搬到其他显卡。正确性构建默认关闭 `PRICER_ENABLE_FAST_MATH`;性能测量也应使用 Release 构建。 + +## 运行定价器 + +先查看本机构建产物支持的参数: + +```bash +./build/pricer_cli --help +``` + +运行欧式看涨期权,并同时执行 CPU 与 GPU 后端: + +```bash +./build/pricer_cli \ + --option configs/option_european_call.ini \ + --simulation configs/simulation.ini \ + --output-dir results/european \ + --backend both \ + --repetitions 1 +``` + +运行亚式看涨期权: + +```bash +./build/pricer_cli \ + --option configs/option_asian_call.ini \ + --simulation configs/simulation.ini \ + --output-dir results/asian \ + --backend both \ + --repetitions 1 +``` + +Windows + Ninja 时将可执行文件写为 `build\\pricer_cli.exe`,路径分隔符可使用 `\\`。若使用 Visual Studio 多配置生成器,则通常从 `build\\Release\\pricer_cli.exe` 启动。 + +`configs/simulation.ini` 默认配置为 1,000 万条路径、256 个时间步,适合作为完整实验输入,运行时间可能较长。需要快速调试时,请复制该文件到新的 INI,再降低 `num_paths` 与 `num_steps`;不要改写仓库提供的示例配置。 + +### 常用 CLI 参数 + +| 参数 | 说明 | +| --- | --- | +| `--option ` | 必填。欧式或亚式期权 INI 文件。 | +| `--simulation ` | 必填。路径数、步数、随机数、精度、block size 等模拟 INI 文件。 | +| `--output-dir ` | 输出目录,默认是 `results`。 | +| `--backend ` | 选择 CPU、GPU 或同时运行两者,默认 `both`。 | +| `--device ` | CUDA 设备编号,默认 `0`。 | +| `--warmup ` | GPU 非计量预热次数,默认 `1`。 | +| `--repetitions ` | 正式计量重复次数,默认 `1`。 | + +## 配置与输出 + +`configs/` 中提供两类配置: + +- `option_european_call.ini` 与 `option_asian_call.ini`:期权类型、现价、行权价、利率、波动率和到期时间; +- `simulation.ini`:FP32 GPU 默认实验配置; +- `simulation_fp64.ini`:FP64 GPU 配置,适合与 CPU FP64 基线进行同精度性能对比。 + +每个后端会在输出目录写入一份: + +```text +---result.json +``` + +性能数据会追加到同目录的 `performance.csv`。JSON 中价格、标准误、置信区间、运行时间和环境信息使用结构化字段记录;不可用统计值以 `null` 表示。 + +CPU 与 GPU 使用不同随机序列,验证时应比较统计一致性,而不是比较逐路径结果。性能结论应使用 CPU 与 GPU 的 `total_runtime_ms`,不要将 CPU 总时间与 GPU `compute_runtime_ms` 混用。 + +## 项目结构 + +```text +include/pricer/ 公共 C++ 接口 +src/ CPU、CUDA、CLI、统计与输出实现 +configs/ 示例期权与模拟 INI +results/ 运行生成的 JSON、CSV 与实验产物(默认不提交) +``` + +## 常见问题 + +### CMake 提示未设置 CUDA 架构 + +本项目要求显式传入 `CMAKE_CUDA_ARCHITECTURES`。确认 GPU 的计算能力后重新配置,例如: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_CUDA_ARCHITECTURES=89 +``` + +不要复用由其他操作系统、其他 CUDA 版本或其他 GPU 生成的 CMake 缓存。 + +### FetchContent 下载依赖失败 + +检查 GitHub 网络与 Git 是否可用后重新配置。若所在环境已经有固定版本的依赖源码,也可以显式指定它们,避免 CMake 联网下载: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_CUDA_ARCHITECTURES=89 \ + -DFETCHCONTENT_SOURCE_DIR_NLOHMANN_JSON=/path/to/nlohmann_json \ + -DFETCHCONTENT_SOURCE_DIR_GOOGLETEST=/path/to/googletest +``` + +这些目录必须分别包含对应依赖的顶层 `CMakeLists.txt`。不要将下载缓存、`_deps` 或构建目录提交到仓库。 + +### 无参数运行 `pricer_cli` 报错 + +这是预期行为。`--option` 与 `--simulation` 都是必填参数;请使用“运行定价器”章节中的完整命令。 diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/configs/option_asian_call.ini" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/configs/option_asian_call.ini" new file mode 100644 index 00000000..05510320 --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/configs/option_asian_call.ini" @@ -0,0 +1,6 @@ +option_type = asian_call +spot = 100.0 +strike = 100.0 +risk_free_rate = 0.03 +volatility = 0.2 +maturity = 1.0 diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/configs/option_european_call.ini" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/configs/option_european_call.ini" new file mode 100644 index 00000000..fad49bdb --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/configs/option_european_call.ini" @@ -0,0 +1,6 @@ +option_type = european_call +spot = 100.0 +strike = 100.0 +risk_free_rate = 0.03 +volatility = 0.2 +maturity = 1.0 diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/configs/simulation.ini" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/configs/simulation.ini" new file mode 100644 index 00000000..b0844006 --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/configs/simulation.ini" @@ -0,0 +1,8 @@ +num_paths = 10000000 +num_steps = 256 +seed = 1234 +rng = curand_philox +variance_reduction = none +precision = fp32 +block_size = 256 +batch_size = 0 diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/configs/simulation_fp64.ini" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/configs/simulation_fp64.ini" new file mode 100644 index 00000000..d525af1a --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/configs/simulation_fp64.ini" @@ -0,0 +1,8 @@ +num_paths = 10000000 +num_steps = 256 +seed = 1234 +rng = curand_philox +variance_reduction = none +precision = fp64 +block_size = 256 +batch_size = 0 diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/black_scholes.hpp" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/black_scholes.hpp" new file mode 100644 index 00000000..88d798a6 --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/black_scholes.hpp" @@ -0,0 +1,12 @@ +#pragma once + +// 提供欧式看涨期权的 Black-Scholes 解析参考值。 + +#include "pricer/config.hpp" + +namespace pricer { + +// 返回欧式看涨的解析价格,用于校验蒙特卡洛估计。 +double black_scholes_call(const OptionParams &option); + +} // namespace pricer diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/config.hpp" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/config.hpp" new file mode 100644 index 00000000..ed2cfc53 --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/config.hpp" @@ -0,0 +1,86 @@ +#pragma once + +// 定义期权、模拟和命令行配置的数据结构与读取接口。 + +#include +#include +#include +#include +#include +#include + +namespace pricer { + +// P0 支持的期权类型;亚式期权需要完整路径上的多个监控点。 +enum class OptionType { EuropeanCall, AsianArithmeticCall }; +// GPU 路径演化可选精度;统计累加始终使用双精度。 +enum class Precision { Fp32, Fp64 }; +enum class VarianceReduction { None }; +enum class RngType { CurandPhilox, Mt19937_64 }; + +// 一份期权合约的金融参数。 +struct OptionParams { + OptionType type; + double spot; + double strike; + double risk_free_rate; + double volatility; + double maturity; + std::optional barrier; +}; + +// 一次蒙特卡洛运行的随机数、精度和 GPU 执行参数。 +struct SimulationParams { + std::uint64_t num_paths; + std::uint32_t num_steps; + std::uint64_t seed; + RngType rng; + Precision precision; + VarianceReduction variance_reduction; + std::uint32_t block_size; + std::uint64_t batch_size; +}; + +// 将合约、模拟策略和 CLI 后端选择组合为完整运行配置。 +struct RunConfig { + OptionParams option; + SimulationParams simulation; + std::filesystem::path output_dir; + bool run_cpu; + bool run_gpu; +}; + +enum class ErrorCode { ConfigInvalid, FileRead, UnsupportedFeature }; + +// 保留文件、行号和字段,方便用户定位配置错误。 +class ConfigError : public std::runtime_error { + public: + ConfigError(ErrorCode code, std::filesystem::path source_file, + std::optional line, + std::optional field, std::string reason); + + ErrorCode code() const noexcept; + const std::filesystem::path &source_file() const noexcept; + const std::optional &line() const noexcept; + const std::optional &field() const noexcept; + const std::string &reason() const noexcept; + std::string render() const; + + private: + ErrorCode code_; + std::filesystem::path source_file_; + std::optional line_; + std::optional field_; + std::string reason_; +}; + +// 将配置错误稳定映射为 CLI 退出码。 +int exit_code_for(ErrorCode code) noexcept; + +// 读取两份 INI 文件,并在返回前完成跨字段校验。 +RunConfig load_run_config(const std::filesystem::path &option_file, + const std::filesystem::path &simulation_file, + std::filesystem::path output_dir = "results", + bool run_cpu = true, bool run_gpu = true); + +} // namespace pricer diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/cpu_pricer.hpp" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/cpu_pricer.hpp" new file mode 100644 index 00000000..8f3c7866 --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/cpu_pricer.hpp" @@ -0,0 +1,23 @@ +#pragma once + +// 声明 CPU 单线程蒙特卡洛基线的运行结果和定价入口。 + +#include "pricer/config.hpp" +#include "pricer/statistics.hpp" + +namespace pricer { + +// CPU 一次定价产生的累计矩和纯计算耗时。 +struct CpuPricingRun { + RawMoments moments; + double compute_runtime_ms; +}; + +class CpuMonteCarloPricer { + public: + // 按固定 seed 顺序生成全部路径,供正确性对照使用。 + static CpuPricingRun price(const OptionParams &option, + const SimulationParams &simulation); +}; + +} // namespace pricer diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/cuda_pricer.hpp" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/cuda_pricer.hpp" new file mode 100644 index 00000000..8abfd5a8 --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/cuda_pricer.hpp" @@ -0,0 +1,66 @@ +#pragma once + +// 声明 CUDA 蒙特卡洛后端及其设备错误转换接口。 + +#include +#include +#include +#include +#include + +#include "pricer/config.hpp" +#include "pricer/statistics.hpp" + +namespace pricer { + +// 将 CUDA API 调用失败转换为带调用位置的 C++ 异常。 +class CudaError : public std::runtime_error { + public: + CudaError(std::string api, int code, std::string cuda_text, + std::string source_file, int source_line); + + const std::string &api() const noexcept; + int code() const noexcept; + const std::string &cuda_text() const noexcept; + const std::string &source_file() const noexcept; + int source_line() const noexcept; + + private: + std::string api_; + int code_; + std::string cuda_text_; + std::string source_file_; + int source_line_; +}; + +// 输出报告所需的目标 GPU 基本信息。 +struct CudaDeviceInfo { + int id; + std::string name; + int compute_capability_major; + int compute_capability_minor; + std::size_t total_memory_bytes; + std::size_t free_memory_bytes; + int max_threads_per_block; +}; + +// GPU 一次定价的累计矩、分批策略和分段计时。 +struct CudaPricingRun { + RawMoments moments; + double gpu_compute_ms; + double reduction_ms; + std::optional rng_ms; + std::uint64_t batch_size; + std::uint64_t grid_size; + CudaDeviceInfo device; +}; + +class CudaMonteCarloPricer { + public: + // 在指定设备上模拟路径并完成收益归约。 + static CudaPricingRun price(const OptionParams &option, + const SimulationParams &simulation, + int device_id = 0); +}; + +} // namespace pricer diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/measurement.hpp" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/measurement.hpp" new file mode 100644 index 00000000..3f9801f6 --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/measurement.hpp" @@ -0,0 +1,55 @@ +#pragma once + +// 统一重复运行、预热和中位数计时的测量接口。 + +#include +#include +#include + +#include "pricer/cpu_pricer.hpp" +#include "pricer/cuda_pricer.hpp" + +namespace pricer { + +// 允许测量逻辑复用真实定价器或测试替身。 +using CpuPriceFunction = std::function; +using GpuPriceFunction = std::function; +using AnalyzeFunction = + std::function)>; + +// 对多次 CPU 运行取中位数后的报告数据。 +struct CpuMeasurement { + PricingResult result; + double total_runtime_ms; + double compute_runtime_ms; +}; + +// 对多次 GPU 运行取中位数后的报告数据与设备信息。 +struct GpuMeasurement { + PricingResult result; + double total_runtime_ms; + double compute_runtime_ms; + std::optional rng_ms; + double reduction_ms; + std::uint64_t actual_batch_size; + std::uint64_t grid_size; + CudaDeviceInfo device; +}; + +// 运行 CPU 基线;每次重复都包含统计分析,避免计时口径不一致。 +CpuMeasurement +measure_cpu(const OptionParams &option, const SimulationParams &simulation, + std::uint32_t repetitions, std::optional reference_price, + const CpuPriceFunction &price, const AnalyzeFunction &analyze); + +// 先执行 GPU 预热,再测量正式重复运行。 +GpuMeasurement measure_gpu(const OptionParams &option, + const SimulationParams &simulation, int device, + std::uint32_t warmup, std::uint32_t repetitions, + std::optional reference_price, + const GpuPriceFunction &price, + const AnalyzeFunction &analyze); + +} // namespace pricer diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/output.hpp" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/output.hpp" new file mode 100644 index 00000000..8db4c528 --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/output.hpp" @@ -0,0 +1,87 @@ +#pragma once + +// 定义定价结果、性能记录及 JSON/CSV 输出接口。 + +#include +#include +#include +#include +#include +#include + +#include + +#include "pricer/config.hpp" +#include "pricer/statistics.hpp" + +namespace pricer { + +// 一次后端运行的时间、吞吐和加速比数据。 +struct PerformanceData { + double total_runtime_ms; + double compute_runtime_ms; + std::optional rng_ms; + std::optional reduction_ms; + double paths_per_second; + std::uint32_t block_size; + std::uint64_t grid_size; + std::uint32_t repetitions; + std::string aggregation; + std::optional cpu_runtime_ms; + std::optional speedup_vs_cpu; +}; + +// 记录复现实验需要的硬件和构建环境。 +struct EnvironmentData { + std::optional gpu_name; + std::optional compute_capability; + std::optional cuda_runtime_version; + std::optional build_type; + std::optional git_commit; +}; + +// 序列化为 JSON 和 CSV 的完整一次运行记录。 +struct OutputRecord { + std::string run_id; + std::string timestamp_utc; + std::string backend; + OptionParams option; + SimulationParams simulation; + PricingResult result; + PerformanceData performance; + EnvironmentData environment; +}; + +// 成功发布后返回两个输出文件的位置。 +struct WrittenOutput { + std::filesystem::path json_path; + std::filesystem::path csv_path; + std::string run_id; +}; + +class OutputError : public std::runtime_error { + public: + using std::runtime_error::runtime_error; +}; + +// 只构建 JSON 内容,不访问文件系统,便于单元测试。 +nlohmann::json make_result_json(const OutputRecord &record); + +std::filesystem::path +// 临时写入 JSON 后再 rename,避免中途失败留下半个结果文件。 +write_result_json_atomic(const std::filesystem::path &output_dir, + OutputRecord record); + +// 在进程锁保护下创建或追加性能 CSV。 +void append_performance_csv(const std::filesystem::path &path, + const OutputRecord &record); + +// 将 JSON 和 CSV 作为一个输出事务发布。 +WrittenOutput write_output_transaction(const std::filesystem::path &output_dir, + OutputRecord record); + +// 返回计时样本中位数,降低单次抖动的影响。 +double median(std::vector measurements); +std::string utc_timestamp(); + +} // namespace pricer diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/payoff.hpp" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/payoff.hpp" new file mode 100644 index 00000000..44c2fd2e --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/payoff.hpp" @@ -0,0 +1,29 @@ +#pragma once + +// 提供 GBM 步进常量和欧式、亚式看涨收益计算。 + +#include + +namespace pricer { + +// GBM 一步演化和全期折现共用的预计算常量。 +struct GbmStepConstants { + double dt; + double drift; + double diffusion; + double discount; +}; + +// 根据总期限与步数生成 CPU/GPU 共用的风险中性 GBM 常量。 +GbmStepConstants make_gbm_step_constants(double maturity, double risk_free_rate, + double volatility, + std::uint32_t num_steps); + +// 计算一条欧式路径的折现到期收益。 +double european_call_payoff(double terminal_spot, double strike, + double discount); +// 计算一条亚式路径的折现收益;running_sum 不包含初始价格。 +double asian_arithmetic_call_payoff(double running_sum, std::uint32_t num_steps, + double strike, double discount); + +} // namespace pricer diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/statistics.hpp" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/statistics.hpp" new file mode 100644 index 00000000..3ddf4f15 --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/statistics.hpp" @@ -0,0 +1,41 @@ +#pragma once + +// 定义原始矩和价格统计量之间的转换接口。 + +#include +#include + +namespace pricer { + +// 不保存所有样本时仍可计算统计量的一阶、二阶原始矩。 +struct RawMoments { + std::uint64_t count; + double sum; + double sum_squares; +}; + +// 面向用户输出的价格、误差和置信区间。 +struct PricingResult { + double price; + std::optional sample_stddev; + std::optional standard_error; + std::optional ci_lower; + std::optional ci_upper; + std::optional reference_price; + std::optional absolute_error; + std::optional relative_error; + RawMoments moments; +}; + +// 合并批次或分段运行产生的原始矩。 +RawMoments merge_raw_moments(const RawMoments &left, const RawMoments &right); + +class ResultAnalyzer { + public: + // 从原始矩推导样本标准差、标准误和 95% 置信区间。 + static PricingResult + analyze(const RawMoments &moments, + std::optional reference_price = std::nullopt); +}; + +} // namespace pricer diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/version.hpp" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/version.hpp" new file mode 100644 index 00000000..28a5ea0a --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/include/pricer/version.hpp" @@ -0,0 +1,10 @@ +#pragma once + +// 暴露命令行显示用的项目版本字符串。 + +namespace pricer { + +// 返回稳定的版本文本,不分配内存也不会抛出异常。 +const char *version() noexcept; + +} // namespace pricer diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-01-system-architecture.png" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-01-system-architecture.png" new file mode 100644 index 0000000000000000000000000000000000000000..2208486a56ce3c88144a78bba47cc6e6ef7bd374 GIT binary patch literal 108152 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 D+C15; literal 0 HcmV?d00001 diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-02-convergence.svg" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-02-convergence.svg" new file mode 100644 index 00000000..bb734a19 --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-02-convergence.svg" @@ -0,0 +1,71 @@ + + +图 2 欧式与亚式蒙特卡洛收敛(FP32 GPU) +标准误随路径数的幂律下降,并与理论 -1/2 斜率对照。 +{"generator": "scripts/render_final_report_figures.py", "result_root": "final-report-20260915", "sources": [{"path": "convergence/european-fp32/convergence_summary.csv", "sha256": "5c324b4265482661e80e23bcffbd8af570e67732da07b03b6e08f082645c4313"}, {"path": "convergence/asian-fp32/convergence_summary.csv", "sha256": "ae3da223b6ff9db31c5c0e4f60f6d5ad193b65a1eaacbd63a59fe59c4ee82549"}, {"path": "benchmark/european-fp64/benchmark_summary.csv", "sha256": "3722ad2fcb3bf3b9b6d9ad17718602336445778bb9ebbe59a6a662c2e08d0d9b"}, {"path": "benchmark/asian-fp64/benchmark_summary.csv", "sha256": "0154c83bfb0b26dcc7374541cd8db2c1df452f7069ba5d7b1c7d9ba16c945440"}, {"path": "block-sweep/asian-fp32/block_sweep.csv", "sha256": "e463e14b70bfb5a8334fa609e450a132582c83d00d8a7efc36aef60057700dd6"}, {"path": "profiling/asian-fp32/nsys-cuda_gpu_kern_sum_cuda_gpu_kern_sum.csv", "sha256": "f1af93a6c26adde4c62a99ffbfc2d70460de55c2585e450088b53de60473520c"}, {"path": "profiling/asian-fp32/nsys-cuda_gpu_mem_time_sum_cuda_gpu_mem_time_sum.csv", "sha256": "2f1fc8fa2953d96432e314f4f274e9fabcca31ce2665fbfbba0ca41332a57437"}, {"path": "profiling/asian-fp32/nsys-cuda_api_sum_cuda_api_sum.csv", "sha256": "728c8aeffa4972c0950a5ba94d6b5572576ab85db524266faaa4eaf0cff1d6dc"}, {"path": "profiling/asian-fp32/ncu-details.csv", "sha256": "10c85cf76b074afd0a58f41f80f19914cd849e5af5f93a9ee9346abb191044a4"}]} + + +欧式看涨 + + + +1e+04 + +1e+05 + +1e+06 + +1e+07 + +2.2e-03 + +7.5e-03 + +2.5e-02 + +8.5e-02 + +2.9e-01 +路径数 +标准误 + + + + + + +拟合斜率 -0.501;理论对照 -0.500 +Black–Scholes 参考价 9.4134 +亚式看涨 + + + +1e+04 + +1e+05 + +1e+06 + +1e+07 + +1.2e-03 + +4.1e-03 + +1.4e-02 + +4.6e-02 + +1.5e-01 +路径数 +标准误 + + + + + + +拟合斜率 -0.498;理论对照 -0.500 +亚式无可得解析参考价,不显示参考线 +虚线为 -1/2 幂律对照线;数据来源见 SVG metadata。 + diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-03-correctness-ci.svg" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-03-correctness-ci.svg" new file mode 100644 index 00000000..a89c7497 --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-03-correctness-ci.svg" @@ -0,0 +1,53 @@ + + +图 3 正确性与 95% 置信区间(FP64,1000 万路径) +CPU/GPU 合并标准误的误差棒,欧式对照 Black–Scholes 参考价。 +{"generator": "scripts/render_final_report_figures.py", "result_root": "final-report-20260915", "sources": [{"path": "convergence/european-fp32/convergence_summary.csv", "sha256": "5c324b4265482661e80e23bcffbd8af570e67732da07b03b6e08f082645c4313"}, {"path": "convergence/asian-fp32/convergence_summary.csv", "sha256": "ae3da223b6ff9db31c5c0e4f60f6d5ad193b65a1eaacbd63a59fe59c4ee82549"}, {"path": "benchmark/european-fp64/benchmark_summary.csv", "sha256": "3722ad2fcb3bf3b9b6d9ad17718602336445778bb9ebbe59a6a662c2e08d0d9b"}, {"path": "benchmark/asian-fp64/benchmark_summary.csv", "sha256": "0154c83bfb0b26dcc7374541cd8db2c1df452f7069ba5d7b1c7d9ba16c945440"}, {"path": "block-sweep/asian-fp32/block_sweep.csv", "sha256": "e463e14b70bfb5a8334fa609e450a132582c83d00d8a7efc36aef60057700dd6"}, {"path": "profiling/asian-fp32/nsys-cuda_gpu_kern_sum_cuda_gpu_kern_sum.csv", "sha256": "f1af93a6c26adde4c62a99ffbfc2d70460de55c2585e450088b53de60473520c"}, {"path": "profiling/asian-fp32/nsys-cuda_gpu_mem_time_sum_cuda_gpu_mem_time_sum.csv", "sha256": "2f1fc8fa2953d96432e314f4f274e9fabcca31ce2665fbfbba0ca41332a57437"}, {"path": "profiling/asian-fp32/nsys-cuda_api_sum_cuda_api_sum.csv", "sha256": "728c8aeffa4972c0950a5ba94d6b5572576ab85db524266faaa4eaf0cff1d6dc"}, {"path": "profiling/asian-fp32/ncu-details.csv", "sha256": "10c85cf76b074afd0a58f41f80f19914cd849e5af5f93a9ee9346abb191044a4"}]} + + + + + +4.550 + +5.955 + +7.361 + +8.766 + +10.171 +期权类型与后端 +价格估计 +欧式看涨 + + + + +9.4183 + + + + +9.4186 + +Black–Scholes 9.4134 +亚式看涨 + + + + +5.3007 + + + + +5.2987 + +CPU + +GPU + +Black–Scholes 参考价 +误差棒为各后端 95% 置信区间;亚式不虚构解析参考线。 + diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-04-performance-scaling.svg" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-04-performance-scaling.svg" new file mode 100644 index 00000000..d1cfaaff --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-04-performance-scaling.svg" @@ -0,0 +1,95 @@ + + +图 4 CPU/GPU 性能尺度(FP64,中位数) +左面板比较 total 墙钟时序,右面板给出由 total 之比计算的加速比。 +{"generator": "scripts/render_final_report_figures.py", "result_root": "final-report-20260915", "sources": [{"path": "convergence/european-fp32/convergence_summary.csv", "sha256": "5c324b4265482661e80e23bcffbd8af570e67732da07b03b6e08f082645c4313"}, {"path": "convergence/asian-fp32/convergence_summary.csv", "sha256": "ae3da223b6ff9db31c5c0e4f60f6d5ad193b65a1eaacbd63a59fe59c4ee82549"}, {"path": "benchmark/european-fp64/benchmark_summary.csv", "sha256": "3722ad2fcb3bf3b9b6d9ad17718602336445778bb9ebbe59a6a662c2e08d0d9b"}, {"path": "benchmark/asian-fp64/benchmark_summary.csv", "sha256": "0154c83bfb0b26dcc7374541cd8db2c1df452f7069ba5d7b1c7d9ba16c945440"}, {"path": "block-sweep/asian-fp32/block_sweep.csv", "sha256": "e463e14b70bfb5a8334fa609e450a132582c83d00d8a7efc36aef60057700dd6"}, {"path": "profiling/asian-fp32/nsys-cuda_gpu_kern_sum_cuda_gpu_kern_sum.csv", "sha256": "f1af93a6c26adde4c62a99ffbfc2d70460de55c2585e450088b53de60473520c"}, {"path": "profiling/asian-fp32/nsys-cuda_gpu_mem_time_sum_cuda_gpu_mem_time_sum.csv", "sha256": "2f1fc8fa2953d96432e314f4f274e9fabcca31ce2665fbfbba0ca41332a57437"}, {"path": "profiling/asian-fp32/nsys-cuda_api_sum_cuda_api_sum.csv", "sha256": "728c8aeffa4972c0950a5ba94d6b5572576ab85db524266faaa4eaf0cff1d6dc"}, {"path": "profiling/asian-fp32/ncu-details.csv", "sha256": "10c85cf76b074afd0a58f41f80f19914cd849e5af5f93a9ee9346abb191044a4"}]} + + +欧式看涨 + + + +1e+04 + +1e+05 + +1e+06 + +1e+07 + +0 + +1 + +8 + +60 + +469 +路径数 +total 时序 (ms) + + + + + + + + + + + +0.1× + +1.8× + +7.8× + +13.6× +亚式看涨 + + + +1e+04 + +1e+05 + +1e+06 + +1e+07 + +2 + +32 + +454 + +6501 + +93040 +路径数 +total 时序 (ms) + + + + + + + + + + + +13.8× + +21.4× + +33.7× + +38.9× + +CPU total + +GPU total +加速比由同一档 CPU/GPU total 墙钟之比重新计算;吞吐才使用 GPU compute 时序。 + diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-05-block-size.svg" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-05-block-size.svg" new file mode 100644 index 00000000..abcc788d --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-05-block-size.svg" @@ -0,0 +1,39 @@ + + +图 5 block-size 决策(FP32 亚式,1000 万路径) +三个候选的相对变化,仅当双指标均改善至少 5% 才替换基准 256。 +{"generator": "scripts/render_final_report_figures.py", "result_root": "final-report-20260915", "sources": [{"path": "convergence/european-fp32/convergence_summary.csv", "sha256": "5c324b4265482661e80e23bcffbd8af570e67732da07b03b6e08f082645c4313"}, {"path": "convergence/asian-fp32/convergence_summary.csv", "sha256": "ae3da223b6ff9db31c5c0e4f60f6d5ad193b65a1eaacbd63a59fe59c4ee82549"}, {"path": "benchmark/european-fp64/benchmark_summary.csv", "sha256": "3722ad2fcb3bf3b9b6d9ad17718602336445778bb9ebbe59a6a662c2e08d0d9b"}, {"path": "benchmark/asian-fp64/benchmark_summary.csv", "sha256": "0154c83bfb0b26dcc7374541cd8db2c1df452f7069ba5d7b1c7d9ba16c945440"}, {"path": "block-sweep/asian-fp32/block_sweep.csv", "sha256": "e463e14b70bfb5a8334fa609e450a132582c83d00d8a7efc36aef60057700dd6"}, {"path": "profiling/asian-fp32/nsys-cuda_gpu_kern_sum_cuda_gpu_kern_sum.csv", "sha256": "f1af93a6c26adde4c62a99ffbfc2d70460de55c2585e450088b53de60473520c"}, {"path": "profiling/asian-fp32/nsys-cuda_gpu_mem_time_sum_cuda_gpu_mem_time_sum.csv", "sha256": "2f1fc8fa2953d96432e314f4f274e9fabcca31ce2665fbfbba0ca41332a57437"}, {"path": "profiling/asian-fp32/nsys-cuda_api_sum_cuda_api_sum.csv", "sha256": "728c8aeffa4972c0950a5ba94d6b5572576ab85db524266faaa4eaf0cff1d6dc"}, {"path": "profiling/asian-fp32/ncu-details.csv", "sha256": "10c85cf76b074afd0a58f41f80f19914cd849e5af5f93a9ee9346abb191044a4"}]} + + + + +block size +相对 256 的变化 (%) + + +改善阈值 5% + +改善阈值 -5% +128 + ++0.3% + +-1.0% +256(保留) + ++0.0% + ++0.0% +512 + ++3.2% + ++2.3% + +保留候选 + +其他候选 + +±5% 判定阈值 +每档左柱为 total 中位数变化,右柱为 compute 中位数变化;仅双指标同时越过 -5% 才替换。 + diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-06-nsight-bottleneck.svg" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-06-nsight-bottleneck.svg" new file mode 100644 index 00000000..c65032b9 --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/report/figures/figure-06-nsight-bottleneck.svg" @@ -0,0 +1,44 @@ + + +图 6 Nsight 瓶颈剖析(FP32 亚式代表负载) +左面板为内核时间占比,右面板为 ncu 的 SM/DRAM 与占用率指标。 +{"generator": "scripts/render_final_report_figures.py", "result_root": "final-report-20260915", "sources": [{"path": "convergence/european-fp32/convergence_summary.csv", "sha256": "5c324b4265482661e80e23bcffbd8af570e67732da07b03b6e08f082645c4313"}, {"path": "convergence/asian-fp32/convergence_summary.csv", "sha256": "ae3da223b6ff9db31c5c0e4f60f6d5ad193b65a1eaacbd63a59fe59c4ee82549"}, {"path": "benchmark/european-fp64/benchmark_summary.csv", "sha256": "3722ad2fcb3bf3b9b6d9ad17718602336445778bb9ebbe59a6a662c2e08d0d9b"}, {"path": "benchmark/asian-fp64/benchmark_summary.csv", "sha256": "0154c83bfb0b26dcc7374541cd8db2c1df452f7069ba5d7b1c7d9ba16c945440"}, {"path": "block-sweep/asian-fp32/block_sweep.csv", "sha256": "e463e14b70bfb5a8334fa609e450a132582c83d00d8a7efc36aef60057700dd6"}, {"path": "profiling/asian-fp32/nsys-cuda_gpu_kern_sum_cuda_gpu_kern_sum.csv", "sha256": "f1af93a6c26adde4c62a99ffbfc2d70460de55c2585e450088b53de60473520c"}, {"path": "profiling/asian-fp32/nsys-cuda_gpu_mem_time_sum_cuda_gpu_mem_time_sum.csv", "sha256": "2f1fc8fa2953d96432e314f4f274e9fabcca31ce2665fbfbba0ca41332a57437"}, {"path": "profiling/asian-fp32/nsys-cuda_api_sum_cuda_api_sum.csv", "sha256": "728c8aeffa4972c0950a5ba94d6b5572576ab85db524266faaa4eaf0cff1d6dc"}, {"path": "profiling/asian-fp32/ncu-details.csv", "sha256": "10c85cf76b074afd0a58f41f80f19914cd849e5af5f93a9ee9346abb191044a4"}]} + + + + +内核时间占比 (%) + + + + +asian_payoff_kernel 97.96% + +CUB 归约 2.04% + +其他内核 0.00% +内核时间构成 +内存传输总时长相当于内核时间的 0.0% +占比相对内核总时间,不代表完整墙钟时长 +ncu 关键指标 (%) +SM 吞吐 + + +85.0 +DRAM 吞吐 + + +2.2 +实际占用率 + + +98.9 +理论占用率 + + +100.0 +每线程寄存器 34 +本地内存溢出请求 0 次 +grid 39062 × block 256 +两类占比分别相对内核总时间与 ncu 指标口径,不可写作完整墙钟占比。 + diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/report/\351\207\221\350\236\215\350\241\215\347\224\237\345\223\201\345\256\232\344\273\267\344\270\216\351\243\216\351\231\251\344\274\260\350\256\241 CUDA \346\212\245\345\221\212.md" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/report/\351\207\221\350\236\215\350\241\215\347\224\237\345\223\201\345\256\232\344\273\267\344\270\216\351\243\216\351\231\251\344\274\260\350\256\241 CUDA \346\212\245\345\221\212.md" new file mode 100644 index 00000000..ae65fc2b --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/report/\351\207\221\350\236\215\350\241\215\347\224\237\345\223\201\345\256\232\344\273\267\344\270\216\351\243\216\351\231\251\344\274\260\350\256\241 CUDA \346\212\245\345\221\212.md" @@ -0,0 +1,186 @@ +# 金融衍生品定价与风险估计 CUDA 报告 + +报告日期:2026-09-15
+冻结结果根:`results/final-report-20260915/`
+运行环境:RTX 4060 Laptop GPU / CUDA 12.9 / SM89 / Release / fast-math OFF / seed 1234 + +## 摘要与关键结论 + +本项目实现了一个基于 C++17/CUDA 的蒙特卡洛期权定价工具,覆盖欧式看涨和离散算术平均亚式看涨两类产品。程序提供 Black-Scholes 解析值、单线程 CPU 基线和 CUDA GPU 实现,并输出价格、标准误、95% 置信区间与性能日志。 + +实现中有三处直接影响实验结果:Philox 将路径编号固定映射到随机流;程序按可用显存分批计算,每批只回传 `sum` 和 `sum_squares`;收益及其平方以 FP64 完成 CUB 归约。这样既能控制显存占用,也便于在大规模路径数下稳定统计和复现实验。 + +| 关键指标 | 冻结结果 | +| --- | --- | +| 正确性门槛 | 86/86 CTest 通过;两个 CUDA 测试程序的 Compute Sanitizer 均为 `ERROR SUMMARY: 0 errors` | +| 收敛行为 | 欧式标准误斜率 −0.5014,亚式 −0.4980,均符合理论 −0.5 | +| 最大端到端加速 | FP64 亚式、1000 万路径为 **38.90×** | +| 关键性能边界 | FP64 欧式在 1 万路径为 **0.135×**,GPU 因固定开销反而更慢 | + +文中的数值均来自本轮冻结产物。CPU/GPU 加速比统一按对应的 `total_runtime_ms` 计算。离散算术平均亚式期权没有闭式解析解,因此不设置虚构的 `reference_price` 或 `absolute_error`。 + +## 目录 + +- [1. 问题、方案与贡献](#1-问题方案与贡献) +- [2. 金融模型与数值方法](#2-金融模型与数值方法) + - [2.1 欧式与亚式收益](#21-欧式与亚式收益) + - [2.2 随机数、统计与时间口径](#22-随机数统计与时间口径) +- [3. 系统架构与 CUDA 设计](#3-系统架构与-cuda-设计) + - [3.1 路径与内存策略](#31-路径与内存策略) + - [3.2 归约与可观测性](#32-归约与可观测性) +- [4. 实验环境与复现](#4-实验环境与复现) +- [5. 正确性与收敛](#5-正确性与收敛) +- [6. CPU/GPU 性能](#6-cpugpu-性能) +- [7. P1 剖析与 block-size 决策](#7-p1-剖析与-block-size-决策) +- [8. 精度取舍](#8-精度取舍) +- [9. 整体结论](#9-整体结论) +- [10. 局限与后续方向](#10-局限与后续方向) +- [11. 附录:证据索引](#11-附录证据索引) + +## 1. 问题、方案与贡献 + +蒙特卡洛定价需要生成大量相互独立的价格路径,路径之间几乎没有数据依赖,因此适合放到 GPU 上并行执行。本项目关注的不只是给出一个价格,还要检查 CPU/GPU 的统计结果能否相互印证、结果是否带有不确定度描述,以及性能结论是否有端到端计时和剖析数据支撑。 + +CPU 部分采用单线程实现,作为便于核对的正确性和性能基线。它并不代表最优 CPU 性能,因此本文的加速比应理解为单线程 CPU 与 GPU 的对比。对这类任务,路径规模和单条路径的计算量比“是否使用 GPU”更关键:亚式期权每条路径的计算更重,更容易摊薄 GPU 的固定开销。 + +## 2. 金融模型与数值方法 + +### 2.1 欧式与亚式收益 + +在风险中性测度下,欧式看涨的终端价格按一步 GBM 生成: + +$$S_T = S_0 \exp\left[(r-q-\tfrac{1}{2}\sigma^2)T+\sigma\sqrt{T}Z\right],\quad Z\sim N(0,1)$$ + +欧式看涨的折现收益为 $e^{-rT}\max(S_T-K,0)$。本轮实验的 Black-Scholes 参考价为 **9.413403383853016**。 + +离散算术平均亚式看涨以 $m$ 个监控点价格的平均值计算收益: + +$$\mathrm{Payoff}=\max\left(\frac{1}{m}\sum_{i=1}^{m}S_{t_i}-K,0\right)$$ + +初始价格 $S_0$ 不计入平均值。离散算术平均亚式没有闭式解析解,因而主要通过 CPU/GPU 的统计一致性、置信区间和收敛情况判断实现是否合理。 + +### 2.2 随机数、统计与时间口径 + +GPU 使用 `curandStatePhilox4_32_10_t`。`curand_init(seed, batch_offset + local_path, 0)` 将全局路径编号作为 subsequence,因此即使改变分批策略,同一条路径仍使用同一随机流。CPU 使用 `mt19937_64`,两端随机流不同,不应要求逐路径或逐位结果一致。 + +标准误由路径收益的样本方差计算,95% 置信区间为 $\hat{V}\pm1.96\cdot\mathrm{SE}$。比较 CPU 与 GPU 时,使用 $\sqrt{SE_{cpu}^2+SE_{gpu}^2}$ 作为合并标准误,衡量两项独立估计之差的正常波动范围。 + +`total_runtime_ms` 包含初始化、分配、计算和回传;`compute_runtime_ms` 只统计核函数区间。本文的 CPU/GPU 加速比均采用 `total_runtime_ms`,反映实际端到端等待时间。 + +## 3. 系统架构与 CUDA 设计 + +![图 1:命令行入口、配置校验、三条定价路径与结果输出的系统架构](figures/figure-01-system-architecture.png) + +图 1 展示了当前实现的模块关系。命令行读取并校验 INI 配置后,程序选择 Black-Scholes、CPU 蒙特卡洛或 CUDA 蒙特卡洛路径;`ResultAnalyzer` 负责统一统计结果,并以事务方式写入 JSON 和性能 CSV。 + +### 3.1 路径与内存策略 + +CUDA 实现采用一线程一条路径和 grid-stride loop。欧式期权只需生成终端价格;亚式期权则在单个线程内推进 256 个时间步,并同步维护路径平均值。程序不保存完整的路径矩阵。 + +`choose_batch()` 根据可用显存确定批大小,并预留约 20% 空间。每批只从设备端回传两个 FP64 标量,主机通过 `merge_raw_moments()` 合并各批矩。因此,路径数增加不会带来完整路径结果的主机传输负担。 + +### 3.2 归约与可观测性 + +payoff 及 payoff 平方分别通过 `cub::DeviceReduce::Sum` 归约,输入、输出和最终统计均为 FP64。NVTX 可以标记 `simulate_paths`、`reduce_moments` 和 `copy_batch_moments`,便于把端到端时间拆分为具体的 GPU 活动。 + +## 4. 实验环境与复现 + +| 项目 | 值 | +| --- | --- | +| GPU | NVIDIA GeForce RTX 4060 Laptop GPU,8188 MiB,compute capability 8.9 | +| 软件 | CUDA 12.9 / 驱动 595.71 / CMake 3.28.3 / Ninja 1.11.1 | +| 构建 | Release,SM89,fast-math OFF,正式构建 NVTX OFF | +| 实验 | seed 1234;GPU 预热 1 次;正式运行 5 次取中位数 | +| 路径数 | 10,000 / 100,000 / 1,000,000 / 10,000,000 | + +正式结果固定写入 `results/final-report-20260915/`。附录给出了构建、Sanitizer、扫描、剖析和绘图命令;该目录保存环境清单、输入哈希、原始 CSV/JSON、日志和 Nsight 导出物。 + +## 5. 正确性与收敛 + +![图 2:FP32 GPU 下欧式与亚式标准误随路径数的幂律收敛](figures/figure-02-convergence.svg) + +图 2 中,欧式和亚式的标准误经验斜率分别为 **−0.5014** 和 **−0.4980**,与蒙特卡洛理论值 −0.5 接近。欧式绝对误差不必随路径数单调下降;判断误差量级时,应结合标准误,而不是只看估计值偏离参考价的方向。 + +![图 3:1000 万路径下 CPU 与 GPU 的价格估计及 95% 置信区间](figures/figure-03-correctness-ci.svg) + +在 1000 万路径下,欧式期权的 CPU、GPU 估计都覆盖 Black-Scholes 参考值。亚式期权的 CPU/GPU 差异为 2.04e−3,合并标准误为 3.465e−3,前者约为后者的 0.59 倍。由于两端使用不同随机流,这种统计比较比逐路径相等更合适。 + +## 6. CPU/GPU 性能 + +![图 4:两类期权的 CPU/GPU 端到端耗时与加速比](figures/figure-04-performance-scaling.svg) + +图 4 使用端到端时间比较性能。对于 FP64 欧式期权,1 万路径时 CPU 总耗时为 0.214687 ms,GPU 为 1.590332 ms,加速比只有 **0.135×**;此时初始化、分配和同步成本尚未被计算量摊薄。路径数增至 1000 万后,加速比达到 **13.595×**。 + +亚式期权每条路径需要推进 256 个时间步,计算密度更高:1 万路径时已达到 **13.759×**,1000 万路径时为 **38.898×**。这说明 GPU 的优势取决于任务规模和计算密度,小规模任务并不一定适合迁移到 GPU。 + +| 1000 万路径,FP64 | CPU total (ms) | GPU total (ms) | 加速比 | +| --- | ---: | ---: | ---: | +| 欧式看涨 | 260.402149 | 19.154564 | 13.595× | +| 亚式看涨 | 51,688.686487 | 1,328.820065 | 38.898× | + +## 7. P1 剖析与 block-size 决策 + +![图 5:block-size 扫描下 total 与 compute 中位数对照](figures/figure-05-block-size.svg) + +以 256 为基准时,只有候选 block size 的 `total_runtime_ms` 和 `compute_runtime_ms` 都至少改善 5%,才会替换当前配置。128 的 total 反而增加 0.35%;512 的 total 和 compute 分别增加 3.24% 和 2.27%。因此本轮保留 **256**。 + +![图 6:Nsight 剖析下的内核时间构成与瓶颈判定](figures/figure-06-nsight-bottleneck.svg) + +剖析结果显示,`asian_payoff_kernel` 占内核时间的 97.96%,SM 吞吐为 84.97%,DRAM 吞吐只有 2.24%,实际占用率为 98.88%,本地内存溢出请求为 0。该负载主要受计算吞吐限制,而不是 DRAM 带宽限制;在这一前提下,小幅调整 block size 的收益有限。批处理只回传标量,D2H 时间约为内核总时间的 0.02%。 + +## 8. 精度取舍 + +当前冻结指标中,FP32 与 FP64 的可用计时字段口径不一致,不能据此比较两种精度的性能,也不报告性能倍数。两种精度的价格估计处于采样误差的相近量级;但本轮 FP32/FP64 同时改变了随机流,不能把差异全部归因于数值精度。若要单独考察精度影响,需要使用共同随机数,并在同一计时边界下记录 FP32/FP64 的 `compute_runtime_ms`。 + +## 9. 整体结论 + +**数值结果通过了本轮验证。** 86/86 CTest 通过,两个 CUDA 测试程序的 Compute Sanitizer 均报告 `ERROR SUMMARY: 0 errors`。欧式和亚式的标准误斜率分别为 **−0.5014**、**−0.4980**;欧式结果覆盖解析值,亚式结果则通过合并标准误比较进行核对。 + +**GPU 的收益取决于负载。** FP64 亚式在 1000 万路径下的端到端加速为 **38.90×**,欧式为 13.59×;但欧式在 1 万路径下只有 **0.135×**。因此,对计算量较小的任务,GPU 未必比单线程 CPU 更合适。 + +**block_size = 256 是本轮扫描的保守选择。** 亚式核函数的 SM 吞吐为 84.97%,实际占用率为 98.88%,且未出现本地内存溢出。128、256、512 三种 block size 中,没有候选值同时让 total 与 compute 中位数改善至少 5%,因此继续采用 256。 + +## 10. 局限与后续方向 + +1. **性能适用范围。** 本轮结果只对应 RTX 4060 Laptop GPU、驱动 595.71、WSL2 和当时的热状态,未进行长时间温控测试,也不应直接外推到其他硬件。 +2. **CPU 基线公平性。** CPU 为单线程串行基线。加速比体现的是它与大规模并行 GPU 的差异,不等同于最优 CPU 与最优 GPU 的比较。 +3. **随机流差异。** CPU 使用 `mt19937_64`,GPU 使用 Philox;跨后端只能比较统计一致性,不能要求逐路径或逐位相同。 +4. **精度解耦不足。** FP32/FP64 对照同时改变了随机流。要隔离纯精度影响,需要采用共同随机数或固定随机流。 +5. **方差缩减未实现。** 尚未使用对偶变量、控制变量或重要性采样,收敛仍处于标准的 −1/2 级别。 +6. **剖析为代表性采样。** nsys/ncu 指标来自一次 FP32 亚式代表负载采集,并非多次统计结果。 +7. **亚式没有解析基准。** 离散算术平均亚式没有闭式解,只能借助跨后端一致性和收敛证据建立信心。 + +后续可先加入对偶变量等方差缩减方法,再补充多线程 CPU 基线和共同随机数下的精度实验,最后评估 block-size 与 batch 策略的自动调优。 + +## 11. 附录:证据索引 + +### 11.1 复现入口 + +```bash +python3 scripts/run_final_report.py \ + --source-root . --build-dir build-final-report-20260915 \ + --output-root results/final-report-20260915 --phase validate +python3 scripts/run_final_report.py \ + --source-root . --build-dir build-final-report-20260915 \ + --output-root results/final-report-20260915 --phase block-sweep +python3 scripts/run_final_report.py \ + --source-root . --build-dir build-final-report-20260915 \ + --output-root results/final-report-20260915 --phase formal +python3 scripts/render_final_report_figures.py \ + --input-root results/final-report-20260915 --output-dir report/figures +``` + +NVTX 构建与 nsys/ncu 采集命令记录在 `results/final-report-20260915/profiling/asian-fp32/profile_commands.txt`。输出根名称受脚本保护,避免误写入已废弃的 `final-report-20260909`。 + +### 11.2 图表与原始产物 + +| 图 | 数据源 | +| --- | --- | +| 图 1 系统架构 | `report/figures/figure-01-system-architecture.png`;源 `Docs/architecture/derivative_pricer.architecture.json` | +| 图 2 收敛 | `convergence/{european,asian}-fp32/convergence_summary.csv` | +| 图 3 正确性与 CI | `benchmark/{european,asian}-fp64/benchmark_summary.csv` | +| 图 4 性能扩展 | `benchmark/{european,asian}-fp64/benchmark_summary.csv` | +| 图 5 block-size | `block-sweep/asian-fp32/block_sweep.csv` | +| 图 6 Nsight 瓶颈 | `profiling/asian-fp32/{nsys-*.csv,ncu-details.csv}` | + +`manifest.json` 记录环境与构建选项,`input-sha256.txt` 记录 33 个输入文件与可执行文件哈希,`validation/` 保存构建、CTest 和两份 memcheck 日志,`report/figures/report_metrics.json` 保存每个派生指标的源文件、行键与公式。五张数据 SVG 的 metadata 内嵌生成脚本、结果根、输入相对路径和 SHA-256,用于避免历史结果混入。 diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/black_scholes.cpp" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/black_scholes.cpp" new file mode 100644 index 00000000..06733e84 --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/black_scholes.cpp" @@ -0,0 +1,74 @@ +// 计算欧式看涨的解析价格,并处理零波动率边界。 +#include "pricer/black_scholes.hpp" + +#include +#include +#include + +namespace pricer { +namespace { + +// 在代入解析公式前排除类型、范围和非有限数错误。 +void validate_option(const OptionParams &option) { + if (option.type != OptionType::EuropeanCall) { + throw std::domain_error("Black-Scholes requires a European call"); + } + if (!std::isfinite(option.spot) || !std::isfinite(option.strike) || + !std::isfinite(option.risk_free_rate) || + !std::isfinite(option.volatility) || !std::isfinite(option.maturity)) { + throw std::domain_error("Black-Scholes parameters must be finite"); + } + if (option.spot <= 0.0 || option.strike <= 0.0 || option.maturity <= 0.0 || + option.volatility < 0.0) { + throw std::domain_error("Black-Scholes parameters are out of range"); + } +} + +// 用 erfc 表示标准正态 CDF,数值上比手写积分更稳定。 +double normal_cdf(double value) { + return 0.5 * std::erfc(-value / std::sqrt(2.0)); +} + +void require_finite(double value) { + if (!std::isfinite(value)) { + throw std::overflow_error("Black-Scholes result is not finite"); + } +} + +} // namespace + +// 解析公式只适用于欧式看涨;亚式期权没有同样的闭式解。 +double black_scholes_call(const OptionParams &option) { + validate_option(option); + + const double discount = std::exp(-option.risk_free_rate * option.maturity); + require_finite(discount); + + // sigma=0 时没有随机性,常规 d1/d2 公式会除以零;直接计算唯一的到期价格。 + if (option.volatility == 0.0) { + const double terminal_spot = + option.spot * std::exp(option.risk_free_rate * option.maturity); + const double price = + discount * std::max(terminal_spot - option.strike, 0.0); + require_finite(price); + return price; + } + + // Black-Scholes 是欧式看涨的解析“标准答案”。它不参与亚式定价, + // 而是用来检查蒙特卡洛平均值是否落在合理误差范围内。 + const double volatility_sqrt_time = + option.volatility * std::sqrt(option.maturity); + require_finite(volatility_sqrt_time); + const double d1 = + (std::log(option.spot / option.strike) + + (option.risk_free_rate + 0.5 * option.volatility * option.volatility) * + option.maturity) / + volatility_sqrt_time; + const double d2 = d1 - volatility_sqrt_time; + const double price = option.spot * normal_cdf(d1) - + option.strike * discount * normal_cdf(d2); + require_finite(price); + return price; +} + +} // namespace pricer diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/config.cpp" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/config.cpp" new file mode 100644 index 00000000..0a6b2c0c --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/config.cpp" @@ -0,0 +1,373 @@ +// 解析 INI 配置和 CLI 参数,尽早报告可定位的输入错误。 +#include "pricer/config.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pricer { +namespace { + +struct Entry { + std::string value; + std::size_t line; +}; + +using Entries = std::unordered_map; + +std::string trim(std::string_view text) { + const auto first = text.find_first_not_of(" \t\r\n"); + if (first == std::string_view::npos) { + return {}; + } + const auto last = text.find_last_not_of(" \t\r\n"); + return std::string(text.substr(first, last - first + 1)); +} + +const char *code_name(ErrorCode code) noexcept { + switch (code) { + case ErrorCode::ConfigInvalid: + return "CONFIG_INVALID"; + case ErrorCode::FileRead: + return "FILE_READ"; + case ErrorCode::UnsupportedFeature: + return "UNSUPPORTED_FEATURE"; + } + return "UNKNOWN"; +} + +[[noreturn]] void fail(ErrorCode code, const std::filesystem::path &file, + std::optional line, + std::optional field, + const std::string &reason) { + throw ConfigError(code, file, std::move(line), std::move(field), reason); +} + +std::string strip_comment(const std::string &line, + const std::filesystem::path &file, + std::size_t line_number) { + bool quoted = false; + for (std::size_t index = 0; index < line.size(); ++index) { + if (line[index] == '"') { + quoted = !quoted; + } else if (line[index] == '#' && !quoted) { + return line.substr(0, index); + } + } + if (quoted) { + fail(ErrorCode::ConfigInvalid, file, line_number, std::nullopt, + "unmatched double quote"); + } + return line; +} + +Entries parse_ini(const std::filesystem::path &path, + const std::unordered_set &allowed_keys) { + std::ifstream stream(path); + if (!stream) { + fail(ErrorCode::FileRead, path, std::nullopt, std::nullopt, + "could not open file"); + } + + Entries entries; + std::string raw_line; + std::size_t line_number = 0; + while (std::getline(stream, raw_line)) { + ++line_number; + const auto line = trim(strip_comment(raw_line, path, line_number)); + if (line.empty()) { + continue; + } + + const auto equals = line.find('='); + if (equals == std::string::npos) { + fail(ErrorCode::ConfigInvalid, path, line_number, std::nullopt, + "expected key = value"); + } + const auto key = trim(std::string_view(line).substr(0, equals)); + auto value = trim(std::string_view(line).substr(equals + 1)); + if (key.empty()) { + fail(ErrorCode::ConfigInvalid, path, line_number, std::nullopt, + "empty key"); + } + if (allowed_keys.find(key) == allowed_keys.end()) { + fail(ErrorCode::ConfigInvalid, path, line_number, key, + "unknown key"); + } + if (entries.find(key) != entries.end()) { + fail(ErrorCode::ConfigInvalid, path, line_number, key, + "duplicate key"); + } + if (!value.empty() && value.front() == '"' && value.back() == '"' && + value.size() >= 2) { + value = value.substr(1, value.size() - 2); + } else if (value.find('"') != std::string::npos) { + fail(ErrorCode::ConfigInvalid, path, line_number, key, + "unmatched double quote"); + } + entries.emplace(key, Entry{value, line_number}); + } + if (stream.bad()) { + fail(ErrorCode::FileRead, path, std::nullopt, std::nullopt, + "could not read file"); + } + return entries; +} + +const Entry &required(const Entries &entries, const char *key, + const std::filesystem::path &path) { + const auto entry = entries.find(key); + if (entry == entries.end()) { + fail(ErrorCode::ConfigInvalid, path, std::nullopt, key, + "missing required key"); + } + return entry->second; +} + +std::uint64_t parse_unsigned(const Entry &entry, const char *field, + const std::filesystem::path &path) { + std::uint64_t value = 0; + const auto begin = entry.value.data(); + const auto end = begin + entry.value.size(); + const auto result = std::from_chars(begin, end, value); + if (entry.value.empty() || entry.value.front() == '-' || + result.ec != std::errc() || result.ptr != end) { + fail(ErrorCode::ConfigInvalid, path, entry.line, field, + "expected integer, got '" + entry.value + "'"); + } + return value; +} + +std::uint32_t parse_uint32(const Entry &entry, const char *field, + const std::filesystem::path &path) { + const auto value = parse_unsigned(entry, field, path); + if (value > UINT32_MAX) { + fail(ErrorCode::ConfigInvalid, path, entry.line, field, + "expected integer <= 4294967295, got '" + entry.value + "'"); + } + return static_cast(value); +} + +double parse_double(const Entry &entry, const char *field, + const std::filesystem::path &path) { + errno = 0; + char *end = nullptr; + const auto value = std::strtod(entry.value.c_str(), &end); + if (entry.value.empty() || + end != entry.value.c_str() + entry.value.size() || errno == ERANGE || + !std::isfinite(value)) { + fail(ErrorCode::ConfigInvalid, path, entry.line, field, + "expected finite number, got '" + entry.value + "'"); + } + return value; +} + +OptionType parse_option_type(const Entry &entry, + const std::filesystem::path &path) { + if (entry.value == "european_call") { + return OptionType::EuropeanCall; + } + if (entry.value == "asian_call") { + return OptionType::AsianArithmeticCall; + } + if (entry.value == "european_put" || entry.value == "barrier_call") { + fail(ErrorCode::UnsupportedFeature, path, entry.line, "option_type", + "option type '" + entry.value + "' is not supported in P0"); + } + fail(ErrorCode::ConfigInvalid, path, entry.line, "option_type", + "invalid option type '" + entry.value + "'"); +} + +Precision parse_precision(const Entry &entry, + const std::filesystem::path &path) { + if (entry.value == "fp32") { + return Precision::Fp32; + } + if (entry.value == "fp64") { + return Precision::Fp64; + } + fail(ErrorCode::ConfigInvalid, path, entry.line, "precision", + "invalid precision '" + entry.value + "'"); +} + +VarianceReduction parse_variance_reduction(const Entry &entry, + const std::filesystem::path &path) { + if (entry.value == "none") { + return VarianceReduction::None; + } + if (entry.value == "antithetic" || entry.value == "control_variate") { + fail(ErrorCode::UnsupportedFeature, path, entry.line, + "variance_reduction", + "variance reduction '" + entry.value + "' is not supported in P0"); + } + fail(ErrorCode::ConfigInvalid, path, entry.line, "variance_reduction", + "invalid variance reduction '" + entry.value + "'"); +} + +RngType parse_rng(const Entry &entry, const std::filesystem::path &path) { + if (entry.value == "curand_philox") { + return RngType::CurandPhilox; + } + fail(ErrorCode::ConfigInvalid, path, entry.line, "rng", + "invalid rng '" + entry.value + "'"); +} + +void validate_positive(double value, const Entry &entry, const char *field, + const std::filesystem::path &path) { + if (value <= 0.0) { + fail(ErrorCode::ConfigInvalid, path, entry.line, field, + "expected number > 0, got '" + entry.value + "'"); + } +} + +} // namespace + +ConfigError::ConfigError(ErrorCode code, std::filesystem::path source_file, + std::optional line, + std::optional field, std::string reason) + : std::runtime_error(reason), code_(code), + source_file_(std::move(source_file)), line_(std::move(line)), + field_(std::move(field)), reason_(std::move(reason)) {} + +ErrorCode ConfigError::code() const noexcept { return code_; } + +const std::filesystem::path &ConfigError::source_file() const noexcept { + return source_file_; +} + +const std::optional &ConfigError::line() const noexcept { + return line_; +} + +const std::optional &ConfigError::field() const noexcept { + return field_; +} + +const std::string &ConfigError::reason() const noexcept { return reason_; } + +std::string ConfigError::render() const { + std::ostringstream stream; + stream << "ERROR [" << code_name(code_) << "] " + << source_file_.filename().string(); + if (line_) { + stream << ':' << *line_; + } + if (field_) { + stream << " field '" << *field_ << "'"; + } + stream << ": " << reason_; + return stream.str(); +} + +int exit_code_for(ErrorCode code) noexcept { + switch (code) { + case ErrorCode::ConfigInvalid: + return 2; + case ErrorCode::FileRead: + return 3; + case ErrorCode::UnsupportedFeature: + return 6; + } + return 1; +} + +RunConfig load_run_config(const std::filesystem::path &option_file, + const std::filesystem::path &simulation_file, + std::filesystem::path output_dir, bool run_cpu, + bool run_gpu) { + const Entries option_entries = parse_ini( + option_file, {"option_type", "spot", "strike", "risk_free_rate", + "volatility", "maturity", "barrier"}); + const Entries simulation_entries = + parse_ini(simulation_file, {"num_paths", "num_steps", "seed", "rng", + "variance_reduction", "precision", + "block_size", "batch_size"}); + + const auto &option_type_entry = + required(option_entries, "option_type", option_file); + const auto &spot_entry = required(option_entries, "spot", option_file); + const auto &strike_entry = required(option_entries, "strike", option_file); + const auto &rate_entry = + required(option_entries, "risk_free_rate", option_file); + const auto &volatility_entry = + required(option_entries, "volatility", option_file); + const auto &maturity_entry = + required(option_entries, "maturity", option_file); + + OptionParams option{ + parse_option_type(option_type_entry, option_file), + parse_double(spot_entry, "spot", option_file), + parse_double(strike_entry, "strike", option_file), + parse_double(rate_entry, "risk_free_rate", option_file), + parse_double(volatility_entry, "volatility", option_file), + parse_double(maturity_entry, "maturity", option_file), + std::nullopt}; + if (const auto barrier = option_entries.find("barrier"); + barrier != option_entries.end()) { + fail(ErrorCode::ConfigInvalid, option_file, barrier->second.line, + "barrier", "barrier is not supported for P0 option types"); + } + + validate_positive(option.spot, spot_entry, "spot", option_file); + validate_positive(option.strike, strike_entry, "strike", option_file); + validate_positive(option.maturity, maturity_entry, "maturity", option_file); + if (option.volatility < 0.0) { + fail(ErrorCode::ConfigInvalid, option_file, volatility_entry.line, + "volatility", + "expected number >= 0, got '" + volatility_entry.value + "'"); + } + + const auto &num_paths_entry = + required(simulation_entries, "num_paths", simulation_file); + const auto &num_steps_entry = + required(simulation_entries, "num_steps", simulation_file); + const auto &seed_entry = + required(simulation_entries, "seed", simulation_file); + const auto &rng_entry = + required(simulation_entries, "rng", simulation_file); + const auto &variance_entry = + required(simulation_entries, "variance_reduction", simulation_file); + const auto &precision_entry = + required(simulation_entries, "precision", simulation_file); + const auto &block_size_entry = + required(simulation_entries, "block_size", simulation_file); + const auto &batch_size_entry = + required(simulation_entries, "batch_size", simulation_file); + + SimulationParams simulation{ + parse_unsigned(num_paths_entry, "num_paths", simulation_file), + parse_uint32(num_steps_entry, "num_steps", simulation_file), + parse_unsigned(seed_entry, "seed", simulation_file), + parse_rng(rng_entry, simulation_file), + parse_precision(precision_entry, simulation_file), + parse_variance_reduction(variance_entry, simulation_file), + parse_uint32(block_size_entry, "block_size", simulation_file), + parse_unsigned(batch_size_entry, "batch_size", simulation_file)}; + if (simulation.num_paths == 0) { + fail(ErrorCode::ConfigInvalid, simulation_file, num_paths_entry.line, + "num_paths", + "expected integer >= 1, got '" + num_paths_entry.value + "'"); + } + if (option.type == OptionType::AsianArithmeticCall && + simulation.num_steps == 0) { + fail(ErrorCode::ConfigInvalid, simulation_file, num_steps_entry.line, + "num_steps", + "expected integer >= 1, got '" + num_steps_entry.value + "'"); + } + if (simulation.block_size == 0) { + fail(ErrorCode::ConfigInvalid, simulation_file, block_size_entry.line, + "block_size", + "expected integer >= 1, got '" + block_size_entry.value + "'"); + } + + return RunConfig{option, simulation, std::move(output_dir), run_cpu, + run_gpu}; +} + +} // namespace pricer diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/cpu_pricer.cpp" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/cpu_pricer.cpp" new file mode 100644 index 00000000..1265e3c2 --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/cpu_pricer.cpp" @@ -0,0 +1,91 @@ +// 使用 CPU 单线程生成路径,作为 GPU 结果的统计基线。 +#include "pricer/cpu_pricer.hpp" + +#include +#include +#include +#include + +#include "pricer/payoff.hpp" + +namespace pricer { +namespace { + +// CPU 与 GPU 在启动前执行同一类基础请求校验。 +void validate_request(const OptionParams &option, + const SimulationParams &simulation) { + if (simulation.num_paths == 0U) { + throw std::invalid_argument("CPU pricing requires at least one path"); + } + if (option.type != OptionType::EuropeanCall && + option.type != OptionType::AsianArithmeticCall) { + throw std::domain_error("CPU pricing supports only P0 call options"); + } + if (option.type == OptionType::AsianArithmeticCall && + simulation.num_steps == 0U) { + throw std::domain_error("Asian CPU pricing requires at least one step"); + } +} + +// 将单条路径收益折叠进原始矩,避免保存整条收益数组。 +void accumulate(RawMoments &moments, double payoff) { + // 不保存全部路径收益:只保留 sum 和 sum_squares 已足够在最后算均值、方差和 + // SE。 + ++moments.count; + moments.sum += payoff; + moments.sum_squares += payoff * payoff; +} + +} // namespace + +// 按期权类型选择一次终值采样或多步路径采样。 +CpuPricingRun CpuMonteCarloPricer::price(const OptionParams &option, + const SimulationParams &simulation) { + validate_request(option, simulation); + + const auto start = std::chrono::steady_clock::now(); + // 每次 price 都从同一个 seed 重新开始,保证同一 CPU + // 二进制上的重复运行可复现。 + std::mt19937_64 engine(simulation.seed); + std::normal_distribution normal(0.0, 1.0); + RawMoments moments{0U, 0.0, 0.0}; + + if (option.type == OptionType::EuropeanCall) { + // 欧式期权只关心到期价,所以一条路径只需要一个标准正态随机数和一个 GBM + // 步。 + const auto step = make_gbm_step_constants( + option.maturity, option.risk_free_rate, option.volatility, 1U); + for (std::uint64_t path = 0; path < simulation.num_paths; ++path) { + const double terminal_spot = + option.spot * + std::exp(step.drift + step.diffusion * normal(engine)); + accumulate(moments, + european_call_payoff(terminal_spot, option.strike, + step.discount)); + } + } else { + // 亚式期权关心路径上的平均价,因此一条路径必须逐步演化并累计每个监控价。 + const auto step = + make_gbm_step_constants(option.maturity, option.risk_free_rate, + option.volatility, simulation.num_steps); + for (std::uint64_t path = 0; path < simulation.num_paths; ++path) { + double spot = option.spot; + double running_sum = 0.0; // 不包含初始 S0,见 Asian payoff 的定义。 + for (std::uint32_t index = 0; index < simulation.num_steps; + ++index) { + spot *= std::exp(step.drift + step.diffusion * normal(engine)); + running_sum += spot; + } + accumulate(moments, asian_arithmetic_call_payoff( + running_sum, simulation.num_steps, + option.strike, step.discount)); + } + } + + const auto stop = std::chrono::steady_clock::now(); + const double compute_runtime_ms = + std::chrono::duration(stop - start).count(); + return {moments, compute_runtime_ms}; +} + +} // namespace pricer diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/cuda_backend.cu" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/cuda_backend.cu" new file mode 100644 index 00000000..781b1f56 --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/cuda_backend.cu" @@ -0,0 +1,533 @@ +// 在 GPU 上生成蒙特卡洛路径,并归约收益的一阶、二阶矩。 +#include "pricer/cuda_pricer.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#ifdef PRICER_ENABLE_NVTX +#include +#endif + +namespace pricer { +namespace { + +[[noreturn]] void throw_cuda_error(const char *api, cudaError_t status, + const char *source_file, int source_line) { + throw CudaError(api, static_cast(status), cudaGetErrorString(status), + source_file, source_line); +} + +void check_cuda(cudaError_t status, const char *api, const char *source_file, + int source_line) { + if (status != cudaSuccess) { + throw_cuda_error(api, status, source_file, source_line); + } +} + +#define PRICER_CUDA_CHECK(api_name, expression) \ + ::pricer::check_cuda((expression), (api_name), __FILE__, __LINE__) + +class DeviceBuffer { + public: + DeviceBuffer() = default; + + explicit DeviceBuffer(std::size_t bytes) { allocate(bytes); } + + DeviceBuffer(const DeviceBuffer &) = delete; + DeviceBuffer &operator=(const DeviceBuffer &) = delete; + + ~DeviceBuffer() noexcept { + if (pointer_ != nullptr) { + const cudaError_t status = cudaFree(pointer_); + if (status != cudaSuccess) { + // Destructors cannot replace an active exception. Normal-path + // cleanup uses release(), which reports a structured error. + } + } + } + + void allocate(std::size_t bytes) { + if (bytes == 0U) { + throw std::invalid_argument( + "CUDA allocation size must be positive"); + } + PRICER_CUDA_CHECK("cudaMalloc", cudaMalloc(&pointer_, bytes)); + } + + void *get() const noexcept { return pointer_; } + + void release() { + if (pointer_ != nullptr) { + void *released = pointer_; + pointer_ = nullptr; + PRICER_CUDA_CHECK("cudaFree", cudaFree(released)); + } + } + + private: + void *pointer_ = nullptr; +}; + +class CudaEvent { + public: + CudaEvent() { + PRICER_CUDA_CHECK("cudaEventCreate", cudaEventCreate(&event_)); + } + + CudaEvent(const CudaEvent &) = delete; + CudaEvent &operator=(const CudaEvent &) = delete; + + ~CudaEvent() noexcept { + if (event_ != nullptr) { + const cudaError_t status = cudaEventDestroy(event_); + if (status != cudaSuccess) { + // See DeviceBuffer::~DeviceBuffer(). + } + } + } + + void record() { + PRICER_CUDA_CHECK("cudaEventRecord", cudaEventRecord(event_)); + } + + void synchronize() { + PRICER_CUDA_CHECK("cudaEventSynchronize", cudaEventSynchronize(event_)); + } + + float elapsed_since(const CudaEvent &start) const { + float milliseconds = 0.0F; + PRICER_CUDA_CHECK( + "cudaEventElapsedTime", + cudaEventElapsedTime(&milliseconds, start.event_, event_)); + return milliseconds; + } + + void release() { + if (event_ != nullptr) { + cudaEvent_t released = event_; + event_ = nullptr; + PRICER_CUDA_CHECK("cudaEventDestroy", cudaEventDestroy(released)); + } + } + + private: + cudaEvent_t event_ = nullptr; +}; + +class NvtxRange { + public: + explicit NvtxRange(const char *label) { +#ifdef PRICER_ENABLE_NVTX + nvtxRangePushA(label); +#else + static_cast(label); +#endif + } + + NvtxRange(const NvtxRange &) = delete; + NvtxRange &operator=(const NvtxRange &) = delete; + + ~NvtxRange() noexcept { +#ifdef PRICER_ENABLE_NVTX + nvtxRangePop(); +#endif + } +}; + +struct Square { + __host__ __device__ double operator()(const double &value) const { + return value * value; + } +}; + +template struct PathMath; + +template <> struct PathMath { + __device__ static float normal(curandStatePhilox4_32_10_t *state) { + return curand_normal(state); + } + + __device__ static float exponential(float value) { return expf(value); } + + __device__ static float maximum(float left, float right) { + return fmaxf(left, right); + } +}; + +template <> struct PathMath { + __device__ static double normal(curandStatePhilox4_32_10_t *state) { + return curand_normal_double(state); + } + + __device__ static double exponential(double value) { return exp(value); } + + __device__ static double maximum(double left, double right) { + return fmax(left, right); + } +}; + +template +__global__ void +european_payoff_kernel(double *payoffs, std::uint64_t batch_paths, + std::uint64_t batch_offset, std::uint64_t seed, + Real spot, Real strike, Real drift, Real diffusion, + Real discount) { + const std::uint64_t thread = + static_cast(blockIdx.x) * blockDim.x + threadIdx.x; + const std::uint64_t stride = + static_cast(gridDim.x) * blockDim.x; + for (std::uint64_t local_path = thread; local_path < batch_paths; + local_path += stride) { + // 一个 CUDA 线程负责一条路径。batch_offset + local_path + // 是全局路径编号,让分批与不分批时也取到同一条 Philox + // 随机子序列,而不会重复抽样。 + curandStatePhilox4_32_10_t state; + curand_init(static_cast(seed), + static_cast(batch_offset + local_path), + 0ULL, &state); + const Real normal = PathMath::normal(&state); + const Real terminal_spot = + spot * PathMath::exponential(drift + diffusion * normal); + const Real payoff = + discount * PathMath::maximum(terminal_spot - strike, Real{0}); + payoffs[local_path] = static_cast(payoff); + } +} + +template +__global__ void asian_payoff_kernel(double *payoffs, std::uint64_t batch_paths, + std::uint64_t batch_offset, + std::uint64_t seed, Real initial_spot, + Real strike, Real drift, Real diffusion, + Real discount, std::uint32_t num_steps) { + const std::uint64_t thread = + static_cast(blockIdx.x) * blockDim.x + threadIdx.x; + const std::uint64_t stride = + static_cast(gridDim.x) * blockDim.x; + for (std::uint64_t local_path = thread; local_path < batch_paths; + local_path += stride) { + // 亚式版本与 CPU 的双层循环一一对应:外层是路径,内层是时间监控点。 + curandStatePhilox4_32_10_t state; + curand_init(static_cast(seed), + static_cast(batch_offset + local_path), + 0ULL, &state); + Real spot = initial_spot; + Real running_sum = Real{0}; + for (std::uint32_t step = 0; step < num_steps; ++step) { + spot *= PathMath::exponential( + drift + diffusion * PathMath::normal(&state)); + running_sum += spot; + } + const Real average = running_sum / static_cast(num_steps); + const Real payoff = + discount * PathMath::maximum(average - strike, Real{0}); + payoffs[local_path] = static_cast(payoff); + } +} + +template +void launch_payoff_kernel(const OptionParams &option, + const SimulationParams &simulation, + double *payoff_pointer, std::uint64_t batch_paths, + std::uint64_t batch_offset, std::uint64_t grid, + double drift, double diffusion, double discount) { + if (option.type == OptionType::EuropeanCall) { + european_payoff_kernel + <<(grid), simulation.block_size>>>( + payoff_pointer, batch_paths, batch_offset, simulation.seed, + static_cast(option.spot), + static_cast(option.strike), static_cast(drift), + static_cast(diffusion), static_cast(discount)); + } else { + asian_payoff_kernel + <<(grid), simulation.block_size>>>( + payoff_pointer, batch_paths, batch_offset, simulation.seed, + static_cast(option.spot), + static_cast(option.strike), static_cast(drift), + static_cast(diffusion), static_cast(discount), + simulation.num_steps); + } +} + +std::size_t checked_add(std::size_t left, std::size_t right) { + if (right > std::numeric_limits::max() - left) { + throw std::overflow_error("CUDA batch byte count overflow"); + } + return left + right; +} + +std::size_t payoff_bytes(std::uint64_t batch_paths) { + if (batch_paths > + std::numeric_limits::max() / sizeof(double)) { + throw std::overflow_error("CUDA payoff byte count overflow"); + } + return static_cast(batch_paths) * sizeof(double); +} + +std::size_t reduction_temp_bytes(std::uint64_t batch_paths) { + std::size_t sum_bytes = 0U; + PRICER_CUDA_CHECK( + "cub::DeviceReduce::Sum(query payoff)", + cub::DeviceReduce::Sum(nullptr, sum_bytes, + static_cast(nullptr), + static_cast(nullptr), batch_paths)); + + std::size_t square_bytes = 0U; + const auto squares = thrust::make_transform_iterator( + static_cast(nullptr), Square{}); + PRICER_CUDA_CHECK("cub::DeviceReduce::Sum(query square)", + cub::DeviceReduce::Sum(nullptr, square_bytes, squares, + static_cast(nullptr), + batch_paths)); + return std::max(sum_bytes, square_bytes); +} + +struct BatchStorage { + std::uint64_t paths; + std::size_t payoff_bytes; + std::size_t temporary_bytes; + std::size_t total_bytes; +}; + +BatchStorage describe_storage(std::uint64_t batch_paths) { + const std::size_t payoffs = payoff_bytes(batch_paths); + const std::size_t temporary = reduction_temp_bytes(batch_paths); + std::size_t total = checked_add(payoffs, temporary); + total = checked_add(total, 2U * sizeof(double)); + return {batch_paths, payoffs, temporary, total}; +} + +BatchStorage choose_batch(const SimulationParams &simulation, + std::size_t free_memory_bytes) { + if (simulation.batch_size > 0U) { + const auto storage = describe_storage( + std::min(simulation.batch_size, simulation.num_paths)); + if (storage.total_bytes > free_memory_bytes) { + throw_cuda_error("batch_memory", cudaErrorMemoryAllocation, + __FILE__, __LINE__); + } + return storage; + } + + // 自动模式只使用约 80% 的空闲显存,给 CUDA 运行时和显示任务保留余量。 + const std::size_t budget = (free_memory_bytes / 10U) * 8U; + const std::uint64_t maximum_by_size = static_cast( + std::numeric_limits::max() / sizeof(double)); + std::uint64_t low = 0U; + std::uint64_t high = std::min(simulation.num_paths, maximum_by_size); + while (low < high) { + const std::uint64_t middle = low + (high - low + 1U) / 2U; + if (describe_storage(middle).total_bytes <= budget) { + low = middle; + } else { + high = middle - 1U; + } + } + if (low == 0U) { + throw_cuda_error("automatic_batch_memory", cudaErrorMemoryAllocation, + __FILE__, __LINE__); + } + if (low > simulation.block_size) { + low = (low / simulation.block_size) * simulation.block_size; + } + low = std::max(low, 1U); + return describe_storage(low); +} + +void validate_request(const OptionParams &option, + const SimulationParams &simulation) { + if (simulation.num_paths == 0U) { + throw std::invalid_argument("CUDA pricing requires at least one path"); + } + if (option.type != OptionType::EuropeanCall && + option.type != OptionType::AsianArithmeticCall) { + throw std::domain_error("CUDA pricing supports only P0 call options"); + } + if (option.type == OptionType::AsianArithmeticCall && + simulation.num_steps == 0U) { + throw std::domain_error( + "Asian CUDA pricing requires at least one step"); + } +} + +std::uint64_t grid_size_for(std::uint64_t paths, std::uint32_t block_size, + int maximum_grid_size) { + const std::uint64_t blocks = + paths / block_size + (paths % block_size == 0U ? 0U : 1U); + return std::min(blocks, static_cast(maximum_grid_size)); +} + +} // namespace + +CudaError::CudaError(std::string api, int code, std::string cuda_text, + std::string source_file, int source_line) + : std::runtime_error(api + " failed with CUDA error " + + std::to_string(code) + ": " + cuda_text), + api_(std::move(api)), code_(code), cuda_text_(std::move(cuda_text)), + source_file_(std::move(source_file)), source_line_(source_line) {} + +const std::string &CudaError::api() const noexcept { return api_; } +int CudaError::code() const noexcept { return code_; } +const std::string &CudaError::cuda_text() const noexcept { return cuda_text_; } +const std::string &CudaError::source_file() const noexcept { + return source_file_; +} +int CudaError::source_line() const noexcept { return source_line_; } + +CudaPricingRun CudaMonteCarloPricer::price(const OptionParams &option, + const SimulationParams &simulation, + int device_id) { + validate_request(option, simulation); + + int device_count = 0; + PRICER_CUDA_CHECK("cudaGetDeviceCount", cudaGetDeviceCount(&device_count)); + if (device_id < 0 || device_id >= device_count) { + throw_cuda_error("device_id", cudaErrorInvalidDevice, __FILE__, + __LINE__); + } + PRICER_CUDA_CHECK("cudaSetDevice", cudaSetDevice(device_id)); + + cudaDeviceProp properties{}; + PRICER_CUDA_CHECK("cudaGetDeviceProperties", + cudaGetDeviceProperties(&properties, device_id)); + if (simulation.block_size == 0U || + simulation.block_size > + static_cast(properties.maxThreadsPerBlock)) { + throw std::invalid_argument( + "CUDA block size must be within the selected device limit"); + } + if (properties.maxGridSize[0] <= 0) { + throw std::domain_error("CUDA device reports no usable x-grid size"); + } + + std::size_t free_memory_bytes = 0U; + std::size_t total_memory_bytes = 0U; + PRICER_CUDA_CHECK("cudaMemGetInfo", + cudaMemGetInfo(&free_memory_bytes, &total_memory_bytes)); + // 批处理只限制“同时放在显存里的路径数”;所有批次的统计量随后在主机合并。 + const BatchStorage storage = choose_batch(simulation, free_memory_bytes); + const std::uint64_t representative_grid = grid_size_for( + storage.paths, simulation.block_size, properties.maxGridSize[0]); + const double discount = std::exp(-option.risk_free_rate * option.maturity); + const double step_maturity = + option.type == OptionType::EuropeanCall + ? option.maturity + : option.maturity / static_cast(simulation.num_steps); + const double drift = + (option.risk_free_rate - 0.5 * option.volatility * option.volatility) * + step_maturity; + const double diffusion = option.volatility * std::sqrt(step_maturity); + + DeviceBuffer payoffs(storage.payoff_bytes); + DeviceBuffer reduced_sum(sizeof(double)); + DeviceBuffer reduced_sum_squares(sizeof(double)); + DeviceBuffer temporary(storage.temporary_bytes); + CudaEvent compute_start; + CudaEvent compute_stop; + CudaEvent reduction_start; + CudaEvent reduction_stop; + + auto *payoff_pointer = static_cast(payoffs.get()); + auto *sum_pointer = static_cast(reduced_sum.get()); + auto *sum_squares_pointer = + static_cast(reduced_sum_squares.get()); + + RawMoments moments{0U, 0.0, 0.0}; + double gpu_compute_ms = 0.0; + double reduction_ms = 0.0; + for (std::uint64_t batch_offset = 0U; + batch_offset < simulation.num_paths;) { + const std::uint64_t batch_paths = + std::min(storage.paths, simulation.num_paths - batch_offset); + const std::uint64_t grid = grid_size_for( + batch_paths, simulation.block_size, properties.maxGridSize[0]); + + // 这个循环是一批完整 GPU 工作:生成并计算每条路径收益,再归约为两个数。 + compute_start.record(); + { + NvtxRange range("simulate_paths"); + if (simulation.precision == Precision::Fp32) { + launch_payoff_kernel(option, simulation, payoff_pointer, + batch_paths, batch_offset, grid, + drift, diffusion, discount); + } else { + launch_payoff_kernel(option, simulation, payoff_pointer, + batch_paths, batch_offset, grid, + drift, diffusion, discount); + } + } + PRICER_CUDA_CHECK("cudaGetLastError", cudaGetLastError()); + + reduction_start.record(); + { + NvtxRange range("reduce_moments"); + std::size_t temporary_bytes = storage.temporary_bytes; + PRICER_CUDA_CHECK("cub::DeviceReduce::Sum(payoff)", + cub::DeviceReduce::Sum( + temporary.get(), temporary_bytes, + payoff_pointer, sum_pointer, batch_paths)); + const auto squares = + thrust::make_transform_iterator(payoff_pointer, Square{}); + temporary_bytes = storage.temporary_bytes; + PRICER_CUDA_CHECK("cub::DeviceReduce::Sum(square)", + cub::DeviceReduce::Sum( + temporary.get(), temporary_bytes, squares, + sum_squares_pointer, batch_paths)); + } + reduction_stop.record(); + compute_stop.record(); + compute_stop.synchronize(); + gpu_compute_ms += compute_stop.elapsed_since(compute_start); + reduction_ms += reduction_stop.elapsed_since(reduction_start); + + { + NvtxRange range("copy_batch_moments"); + double host_sum = 0.0; + double host_sum_squares = 0.0; + PRICER_CUDA_CHECK("cudaMemcpy(sum)", + cudaMemcpy(&host_sum, sum_pointer, + sizeof(host_sum), + cudaMemcpyDeviceToHost)); + PRICER_CUDA_CHECK("cudaMemcpy(sum_squares)", + cudaMemcpy(&host_sum_squares, sum_squares_pointer, + sizeof(host_sum_squares), + cudaMemcpyDeviceToHost)); + moments = merge_raw_moments( + moments, {batch_paths, host_sum, host_sum_squares}); + } + // 每批只回传两个标量,随后与先前批次在主机合并。 + batch_offset += batch_paths; + } + + CudaDeviceInfo device{device_id, + properties.name, + properties.major, + properties.minor, + total_memory_bytes, + free_memory_bytes, + properties.maxThreadsPerBlock}; + reduction_stop.release(); + reduction_start.release(); + compute_stop.release(); + compute_start.release(); + temporary.release(); + reduced_sum_squares.release(); + reduced_sum.release(); + payoffs.release(); + + return {moments, gpu_compute_ms, reduction_ms, std::nullopt, + storage.paths, representative_grid, std::move(device)}; +} + +} // namespace pricer diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/main.cpp" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/main.cpp" new file mode 100644 index 00000000..1068039f --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/main.cpp" @@ -0,0 +1,460 @@ +// 协调配置、CPU/GPU 定价、统计汇总和结果输出的命令行入口。 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "pricer/black_scholes.hpp" +#include "pricer/config.hpp" +#include "pricer/cpu_pricer.hpp" +#include "pricer/cuda_pricer.hpp" +#include "pricer/measurement.hpp" +#include "pricer/output.hpp" +#include "pricer/statistics.hpp" +#include "pricer/version.hpp" + +#ifndef PRICER_BUILD_TYPE +#define PRICER_BUILD_TYPE "unknown" +#endif + +#ifndef PRICER_GIT_COMMIT +#define PRICER_GIT_COMMIT "unknown" +#endif + +namespace { + +class CliError : public std::runtime_error { + public: + using std::runtime_error::runtime_error; +}; + +class NumericResultError : public std::runtime_error { + public: + using std::runtime_error::runtime_error; +}; + +class CudaRunError : public std::runtime_error { + public: + using std::runtime_error::runtime_error; +}; + +struct Arguments { + std::filesystem::path option_path; + std::filesystem::path simulation_path; + std::filesystem::path output_dir = "results"; + std::string backend = "both"; + int device = 0; + std::uint32_t repetitions = 1U; + std::uint32_t warmup = 1U; +}; + +struct BackendRun { + pricer::PricingResult result; + pricer::PerformanceData performance; + pricer::EnvironmentData environment; + std::uint64_t actual_batch_size; +}; + +void print_help() { + std::cout + << "Usage: pricer_cli --option --simulation [options]\n" + << " --output-dir Output directory (default: results)\n" + << " --backend Backend selection (default: both)\n" + << " --device CUDA device (default: 0)\n" + << " --repetitions Formal measurements (default: 1)\n" + << " --warmup Unmeasured GPU warmups (default: 1)\n" + << " --help Show this help\n" + << " --version Show version\n"; +} + +template +Integer parse_integer(std::string_view text, std::string_view flag, + bool allow_zero) { + std::uint64_t parsed = 0U; + const auto result = + std::from_chars(text.data(), text.data() + text.size(), parsed); + if (text.empty() || text.front() == '-' || result.ec != std::errc() || + result.ptr != text.data() + text.size() || + (!allow_zero && parsed == 0U) || + parsed > + static_cast(std::numeric_limits::max())) { + throw CliError(std::string(flag) + " has an invalid integer value"); + } + return static_cast(parsed); +} + +Arguments parse_arguments(int argc, char **argv) { + Arguments arguments; + std::unordered_set seen; + for (int index = 1; index < argc; ++index) { + const std::string flag = argv[index]; + if (flag.empty() || flag.front() != '-') { + throw CliError("unexpected positional argument '" + flag + "'"); + } + if (flag == "--help" || flag == "--version") { + throw CliError(flag + " must be used alone"); + } + if (flag != "--option" && flag != "--simulation" && + flag != "--output-dir" && flag != "--backend" && + flag != "--device" && flag != "--repetitions" && + flag != "--warmup") { + throw CliError("unknown flag '" + flag + "'"); + } + if (!seen.insert(flag).second) { + throw CliError("duplicate flag '" + flag + "'"); + } + if (++index >= argc) { + throw CliError("missing value for '" + flag + "'"); + } + const std::string value = argv[index]; + if (flag == "--option") { + arguments.option_path = value; + } else if (flag == "--simulation") { + arguments.simulation_path = value; + } else if (flag == "--output-dir") { + arguments.output_dir = value; + } else if (flag == "--backend") { + if (value != "cpu" && value != "gpu" && value != "both") { + throw CliError("--backend must be cpu, gpu, or both"); + } + arguments.backend = value; + } else if (flag == "--device") { + arguments.device = parse_integer(value, flag, true); + } else if (flag == "--repetitions") { + arguments.repetitions = + parse_integer(value, flag, false); + } else { + arguments.warmup = parse_integer(value, flag, true); + } + } + if (arguments.option_path.empty()) { + throw CliError("missing required --option"); + } + if (arguments.simulation_path.empty()) { + throw CliError("missing required --simulation"); + } + if (arguments.output_dir.empty()) { + throw CliError("--output-dir must not be empty"); + } + return arguments; +} + +std::optional reference_price(const pricer::OptionParams &option) { + // 只有欧式看涨有这里可用的解析参考价;亚式结果以 CPU/GPU 统计一致性验证。 + if (option.type == pricer::OptionType::EuropeanCall) { + return pricer::black_scholes_call(option); + } + return std::nullopt; +} + +void require_finite_nonnegative(double value, const char *name) { + if (!std::isfinite(value) || value < 0.0) { + throw NumericResultError(std::string(name) + + " must be finite and non-negative"); + } +} + +void validate_result(const pricer::PricingResult &result, + const pricer::PerformanceData &performance) { + require_finite_nonnegative(result.price, "price"); + for (const auto &[value, name] : + std::vector, const char *>>{ + {result.sample_stddev, "sample stddev"}, + {result.standard_error, "standard error"}, + {result.reference_price, "reference price"}, + {result.absolute_error, "absolute error"}, + {result.relative_error, "relative error"}}) { + if (value) { + require_finite_nonnegative(*value, name); + } + } + if (result.ci_lower && !std::isfinite(*result.ci_lower)) { + throw NumericResultError("confidence interval lower bound is invalid"); + } + if (result.ci_upper && !std::isfinite(*result.ci_upper)) { + throw NumericResultError("confidence interval upper bound is invalid"); + } + if (result.ci_lower && result.ci_upper && + *result.ci_lower > *result.ci_upper) { + throw NumericResultError("confidence interval is inverted"); + } + require_finite_nonnegative(performance.total_runtime_ms, "total runtime"); + require_finite_nonnegative(performance.compute_runtime_ms, + "compute runtime"); + require_finite_nonnegative(performance.paths_per_second, + "paths per second"); + for (const auto &value : + {performance.rng_ms, performance.reduction_ms, + performance.cpu_runtime_ms, performance.speedup_vs_cpu}) { + if (value) { + require_finite_nonnegative(*value, "optional performance metric"); + } + } +} + +double throughput(std::uint64_t paths, double compute_runtime_ms) { + if (compute_runtime_ms <= 0.0) { + throw NumericResultError("compute runtime is zero"); + } + return static_cast(paths) * 1000.0 / compute_runtime_ms; +} + +std::optional cuda_runtime_version() { + int version = 0; + if (cudaRuntimeGetVersion(&version) != cudaSuccess) { + return std::nullopt; + } + return std::to_string(version / 1000) + "." + + std::to_string((version % 1000) / 10); +} + +pricer::EnvironmentData base_environment() { + return {std::nullopt, std::nullopt, cuda_runtime_version(), + std::string(PRICER_BUILD_TYPE), std::string(PRICER_GIT_COMMIT)}; +} + +BackendRun run_cpu(const pricer::RunConfig &config, std::uint32_t repetitions) { + // measure_cpu 负责重复运行和取中位数;定价器本身只负责产生 RawMoments。 + const auto measured = pricer::measure_cpu( + config.option, config.simulation, repetitions, + reference_price(config.option), pricer::CpuMonteCarloPricer::price, + pricer::ResultAnalyzer::analyze); + pricer::PerformanceData performance{ + measured.total_runtime_ms, + measured.compute_runtime_ms, + std::nullopt, + std::nullopt, + throughput(config.simulation.num_paths, measured.compute_runtime_ms), + config.simulation.block_size, + 0U, + repetitions, + "median", + measured.total_runtime_ms, + std::nullopt}; + validate_result(measured.result, performance); + return {measured.result, std::move(performance), base_environment(), + config.simulation.batch_size}; +} + +pricer::CudaPricingRun +price_gpu_once(const pricer::OptionParams &option, + const pricer::SimulationParams &simulation, int device) { + try { + return pricer::CudaMonteCarloPricer::price(option, simulation, device); + } catch (const pricer::CudaError &) { + throw; + } catch (const std::invalid_argument &error) { + throw CudaRunError(error.what()); + } catch (const std::domain_error &error) { + if (std::string_view(error.what()) == + "CUDA device reports no usable x-grid size") { + throw CudaRunError(error.what()); + } + throw; + } catch (const std::overflow_error &error) { + const std::string_view message(error.what()); + if (message == "CUDA batch byte count overflow" || + message == "CUDA payoff byte count overflow") { + throw CudaRunError(error.what()); + } + throw; + } +} + +BackendRun run_gpu(const pricer::RunConfig &config, int device, + std::uint32_t warmup, std::uint32_t repetitions, + std::optional cpu_total_runtime_ms) { + // GPU 的 warmup 不计入正式结果,用来减少首次 CUDA 初始化对计时的干扰。 + const auto measured = + pricer::measure_gpu(config.option, config.simulation, device, warmup, + repetitions, reference_price(config.option), + price_gpu_once, pricer::ResultAnalyzer::analyze); + // speedup_vs_cpu is an end-to-end metric: both operands include the + // backend work required to obtain a complete PricingResult. + const std::optional speedup = + cpu_total_runtime_ms && measured.total_runtime_ms > 0.0 + ? std::optional(*cpu_total_runtime_ms / + measured.total_runtime_ms) + : std::nullopt; + pricer::PerformanceData performance{ + measured.total_runtime_ms, + measured.compute_runtime_ms, + measured.rng_ms, + measured.reduction_ms, + throughput(config.simulation.num_paths, measured.compute_runtime_ms), + config.simulation.block_size, + measured.grid_size, + repetitions, + "median", + cpu_total_runtime_ms, + speedup}; + auto environment = base_environment(); + environment.gpu_name = measured.device.name; + environment.compute_capability = + std::to_string(measured.device.compute_capability_major) + "." + + std::to_string(measured.device.compute_capability_minor); + validate_result(measured.result, performance); + return {measured.result, std::move(performance), std::move(environment), + measured.actual_batch_size}; +} + +std::string option_type_name(pricer::OptionType type) { + return type == pricer::OptionType::EuropeanCall ? "european_call" + : "asian_call"; +} + +std::string absolute_path_string(const std::filesystem::path &path) { + std::error_code error; + const auto absolute = std::filesystem::absolute(path, error); + if (error) { + throw pricer::OutputError("could not resolve absolute path: " + + error.message()); + } + return absolute.string(); +} + +void write_backend_output(const Arguments &arguments, + const pricer::RunConfig &config, + const std::string ×tamp, + const std::string &backend, BackendRun run) { + auto simulation = config.simulation; + simulation.batch_size = run.actual_batch_size; + if (backend == "cpu") { + simulation.precision = pricer::Precision::Fp64; + simulation.rng = pricer::RngType::Mt19937_64; + } + pricer::OutputRecord record{ + timestamp + "-" + option_type_name(config.option.type) + "-" + backend, + timestamp, + backend, + config.option, + simulation, + std::move(run.result), + std::move(run.performance), + std::move(run.environment)}; + const auto written = + pricer::write_output_transaction(arguments.output_dir, record); + record.run_id = written.run_id; + + const auto &result = record.result; + const auto &performance = record.performance; + std::cout << "INFO backend=" << backend << " price=" << result.price; + if (result.standard_error) { + std::cout << " standard_error=" << *result.standard_error; + } else { + std::cout << " standard_error=null"; + } + if (result.ci_lower && result.ci_upper) { + std::cout << " ci95=[" << *result.ci_lower << ',' << *result.ci_upper + << ']'; + } else { + std::cout << " ci95=null"; + } + std::cout << " total_runtime_ms=" << performance.total_runtime_ms + << " compute_runtime_ms=" << performance.compute_runtime_ms + << " paths_per_second=" << performance.paths_per_second; + if (performance.speedup_vs_cpu) { + std::cout << " speedup_vs_cpu=" << *performance.speedup_vs_cpu; + } + std::cout << '\n' + << "INFO result_json=" << absolute_path_string(written.json_path) + << '\n' + << "INFO performance_csv=" + << absolute_path_string(written.csv_path) << '\n'; +} + +int run_cli(int argc, char **argv) { + if (argc == 2 && std::string_view(argv[1]) == "--help") { + print_help(); + return 0; + } + if (argc == 2 && std::string_view(argv[1]) == "--version") { + std::cout << "pricer_cli " << pricer::version() << '\n'; + return 0; + } + // 主流程只编排:解析命令行 -> 读取并校验 INI -> CPU/GPU 定价 -> 写 + // JSON/CSV。 + const Arguments arguments = parse_arguments(argc, argv); + const bool run_cpu_backend = arguments.backend != "gpu"; + const bool run_gpu_backend = arguments.backend != "cpu"; + const auto config = pricer::load_run_config( + arguments.option_path, arguments.simulation_path, arguments.output_dir, + run_cpu_backend, run_gpu_backend); + std::cout << "INFO option=" << absolute_path_string(arguments.option_path) + << " simulation=" + << absolute_path_string(arguments.simulation_path) + << " backend=" << arguments.backend << '\n'; + + std::optional cpu; + if (run_cpu_backend) { + cpu = run_cpu(config, arguments.repetitions); + } + std::optional gpu; + if (run_gpu_backend) { + const std::optional cpu_total_runtime = + cpu ? cpu->performance.cpu_runtime_ms : std::nullopt; + gpu = run_gpu(config, arguments.device, arguments.warmup, + arguments.repetitions, cpu_total_runtime); + } + + const std::string timestamp = pricer::utc_timestamp(); + if (cpu) { + write_backend_output(arguments, config, timestamp, "cpu", + std::move(*cpu)); + } + if (gpu) { + write_backend_output(arguments, config, timestamp, "gpu", + std::move(*gpu)); + } + return 0; +} + +} // namespace + +int main(int argc, char **argv) { + try { + return run_cli(argc, argv); + } catch (const CliError &error) { + std::cerr << "ERROR [CLI_INVALID] " << error.what() << '\n'; + return 2; + } catch (const pricer::ConfigError &error) { + std::cerr << error.render() << '\n'; + return pricer::exit_code_for(error.code()); + } catch (const pricer::OutputError &error) { + std::cerr << "ERROR [FILE_WRITE] " << error.what() << '\n'; + return 3; + } catch (const pricer::CudaError &error) { + std::cerr << "ERROR [CUDA_RUNTIME] " << error.api() << ": " + << error.cuda_text() << '\n'; + return 4; + } catch (const CudaRunError &error) { + std::cerr << "ERROR [CUDA_RUNTIME] " << error.what() << '\n'; + return 4; + } catch (const NumericResultError &error) { + std::cerr << "ERROR [NUMERIC_RESULT] " << error.what() << '\n'; + return 5; + } catch (const std::domain_error &error) { + std::cerr << "ERROR [NUMERIC_RESULT] " << error.what() << '\n'; + return 5; + } catch (const std::overflow_error &error) { + std::cerr << "ERROR [NUMERIC_RESULT] " << error.what() << '\n'; + return 5; + } catch (const std::exception &error) { + std::cerr << "ERROR [INTERNAL] " << error.what() << '\n'; + return 10; + } catch (...) { + std::cerr << "ERROR [INTERNAL] unknown failure\n"; + return 10; + } +} diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/measurement.cpp" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/measurement.cpp" new file mode 100644 index 00000000..f34e1f27 --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/measurement.cpp" @@ -0,0 +1,120 @@ +// 执行预热和重复测量,并以中位数降低偶发波动的影响。 +#include "pricer/measurement.hpp" + +#include +#include +#include +#include + +#include "pricer/output.hpp" + +namespace pricer { +namespace { + +// 同一固定 seed 的重复正式运行必须产生相同统计量。 +void require_same_moments(const RawMoments &left, const RawMoments &right) { + if (left.count != right.count || left.sum != right.sum || + left.sum_squares != right.sum_squares) { + throw std::runtime_error( + "fixed config and seed did not reproduce identical moments"); + } +} + +} // namespace + +CpuMeasurement +// CPU 没有预热阶段;每次测量同时产出可比较的统计结果。 +measure_cpu(const OptionParams &option, const SimulationParams &simulation, + std::uint32_t repetitions, std::optional reference_price, + const CpuPriceFunction &price, const AnalyzeFunction &analyze) { + if (repetitions == 0U) { + throw std::invalid_argument("CPU measurement requires repetitions"); + } + std::vector totals; + std::vector computes; + std::optional moments; + std::optional result; + for (std::uint32_t repetition = 0U; repetition < repetitions; + ++repetition) { + const auto start = std::chrono::steady_clock::now(); + const auto run = price(option, simulation); + if (moments) { + require_same_moments(*moments, run.moments); + } else { + moments = run.moments; + } + auto analyzed = analyze(run.moments, reference_price); + const auto stop = std::chrono::steady_clock::now(); + if (!result) { + result = std::move(analyzed); + } + totals.push_back( + std::chrono::duration(stop - start).count()); + computes.push_back(run.compute_runtime_ms); + } + return {std::move(*result), median(std::move(totals)), + median(std::move(computes))}; +} + +// GPU 预热不计入正式中位数,以隔离上下文初始化的偶发成本。 +GpuMeasurement measure_gpu(const OptionParams &option, + const SimulationParams &simulation, int device, + std::uint32_t warmup, std::uint32_t repetitions, + std::optional reference_price, + const GpuPriceFunction &price, + const AnalyzeFunction &analyze) { + if (repetitions == 0U) { + throw std::invalid_argument("GPU measurement requires repetitions"); + } + for (std::uint32_t index = 0U; index < warmup; ++index) { + static_cast(price(option, simulation, device)); + } + + SimulationParams formal_simulation = simulation; + std::vector totals; + std::vector computes; + std::vector reductions; + std::vector rngs; + std::optional moments; + std::optional result; + std::optional first; + for (std::uint32_t repetition = 0U; repetition < repetitions; + ++repetition) { + const auto start = std::chrono::steady_clock::now(); + auto run = price(option, formal_simulation, device); + if (!first) { + first = run; + if (formal_simulation.batch_size == 0U) { + formal_simulation.batch_size = run.batch_size; + } + } + if (moments) { + require_same_moments(*moments, run.moments); + } else { + moments = run.moments; + } + auto analyzed = analyze(run.moments, reference_price); + const auto stop = std::chrono::steady_clock::now(); + if (!result) { + result = std::move(analyzed); + } + totals.push_back( + std::chrono::duration(stop - start).count()); + computes.push_back(run.gpu_compute_ms); + reductions.push_back(run.reduction_ms); + if (run.rng_ms) { + rngs.push_back(*run.rng_ms); + } + } + return {std::move(*result), + median(std::move(totals)), + median(std::move(computes)), + rngs.empty() ? std::nullopt + : std::optional(median(std::move(rngs))), + median(std::move(reductions)), + first->batch_size, + first->grid_size, + std::move(first->device)}; +} + +} // namespace pricer diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/output.cpp" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/output.cpp" new file mode 100644 index 00000000..1bc5bde6 --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/output.cpp" @@ -0,0 +1,524 @@ +// 将定价和性能结果安全地写为 JSON 与 CSV 文件。 +#include "pricer/output.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#include +#include +#include +#include +#endif + +namespace pricer { +namespace { + +constexpr const char *k_csv_header = + "schema_version,run_id,timestamp_utc,option_type,backend,precision," + "num_paths,num_steps,batch_size,seed,block_size,grid_size," + "total_runtime_ms,compute_runtime_ms,rng_ms,reduction_ms," + "paths_per_second,cpu_runtime_ms,speedup_vs_cpu,gpu_name," + "cuda_version,git_commit"; + +const char *option_type_name(OptionType type) { + switch (type) { + case OptionType::EuropeanCall: + return "european_call"; + case OptionType::AsianArithmeticCall: + return "asian_call"; + } + throw OutputError("unknown option type"); +} + +const char *precision_name(Precision precision) { + switch (precision) { + case Precision::Fp32: + return "fp32"; + case Precision::Fp64: + return "fp64"; + } + throw OutputError("unknown precision"); +} + +const char *rng_name(RngType rng) { + switch (rng) { + case RngType::CurandPhilox: + return "curand_philox"; + case RngType::Mt19937_64: + return "mt19937_64"; + } + throw OutputError("unknown RNG"); +} + +const char *variance_reduction_name(VarianceReduction reduction) { + if (reduction == VarianceReduction::None) { + return "none"; + } + throw OutputError("unknown variance reduction"); +} + +template +nlohmann::json optional_json(const std::optional &value) { + return value ? nlohmann::json(*value) : nlohmann::json(nullptr); +} + +std::string numeric(double value) { + if (!std::isfinite(value)) { + return {}; + } + std::ostringstream stream; + stream << std::setprecision(17) << value; + return stream.str(); +} + +std::string optional_numeric(const std::optional &value) { + return value ? numeric(*value) : std::string{}; +} + +std::string csv_escape(const std::optional &value) { + if (!value) { + return {}; + } + if (value->find_first_of(",\"\r\n") == std::string::npos) { + return *value; + } + std::string escaped = "\""; + for (const char character : *value) { + escaped += character; + if (character == '"') { + escaped += '"'; + } + } + escaped += '"'; + return escaped; +} + +std::string csv_escape(const std::string &value) { + return csv_escape(std::optional(value)); +} + +void require_output_directory(const std::filesystem::path &directory) { + std::error_code error; + if (std::filesystem::create_directories(directory, error)) { + return; + } + if (error) { + throw OutputError("could not create output directory: " + + directory.string() + ": " + error.message()); + } + const bool is_directory = std::filesystem::is_directory(directory, error); + if (error) { + throw OutputError("could not inspect output directory: " + + directory.string() + ": " + error.message()); + } + if (!is_directory) { + throw OutputError("output path is not a directory: " + + directory.string()); + } +} + +class OutputLock { + public: + explicit OutputLock(const std::filesystem::path &directory) { + require_output_directory(directory); + path_ = directory / ".pricer-output.lock"; +#ifdef _WIN32 + for (std::uint32_t attempt = 0U; attempt < 5000U; ++attempt) { + handle_ = CreateFileW(path_.c_str(), GENERIC_READ | GENERIC_WRITE, + 0, nullptr, OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (handle_ != INVALID_HANDLE_VALUE) { + return; + } + const DWORD error = GetLastError(); + if (error != ERROR_SHARING_VIOLATION && + error != ERROR_LOCK_VIOLATION) { + throw OutputError( + "could not acquire output lock: Windows error " + + std::to_string(error)); + } + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + throw OutputError("timed out acquiring output lock: " + path_.string()); +#else + descriptor_ = open(path_.c_str(), O_CREAT | O_RDWR, 0666); + if (descriptor_ < 0) { + throw OutputError("could not open output lock: " + + std::string(std::strerror(errno))); + } + if (flock(descriptor_, LOCK_EX) != 0) { + const std::string reason = std::strerror(errno); + close(descriptor_); + descriptor_ = -1; + throw OutputError("could not acquire output lock: " + reason); + } +#endif + } + + OutputLock(const OutputLock &) = delete; + OutputLock &operator=(const OutputLock &) = delete; + + ~OutputLock() noexcept { +#ifdef _WIN32 + if (handle_ != INVALID_HANDLE_VALUE) { + CloseHandle(handle_); + } +#else + if (descriptor_ >= 0) { + flock(descriptor_, LOCK_UN); + close(descriptor_); + } +#endif + } + + private: + std::filesystem::path path_; +#ifdef _WIN32 + HANDLE handle_ = INVALID_HANDLE_VALUE; +#else + int descriptor_ = -1; +#endif +}; + +std::uint64_t process_id() { +#ifdef _WIN32 + return static_cast(GetCurrentProcessId()); +#else + return static_cast(getpid()); +#endif +} + +bool path_exists(const std::filesystem::path &path, const char *context) { + std::error_code error; + const bool exists = std::filesystem::exists(path, error); + if (error) { + throw OutputError(std::string(context) + ": " + error.message()); + } + return exists; +} + +struct JsonWrite { + std::filesystem::path path; + std::string run_id; +}; + +struct CsvState { + bool existed; + std::uintmax_t size; + bool needs_newline; +}; + +std::string csv_row(const OutputRecord &record) { + std::ostringstream output; + output << "1.0," << csv_escape(record.run_id) << ',' + << csv_escape(record.timestamp_utc) << ',' + << csv_escape(std::string(option_type_name(record.option.type))) + << ',' << csv_escape(record.backend) << ',' + << csv_escape( + std::string(precision_name(record.simulation.precision))) + << ',' << record.simulation.num_paths << ',' + << record.simulation.num_steps << ',' << record.simulation.batch_size + << ',' << record.simulation.seed << ','; + if (record.backend == "gpu") { + output << record.performance.block_size; + } + output << ','; + if (record.backend == "gpu") { + output << record.performance.grid_size; + } + output << ',' << numeric(record.performance.total_runtime_ms) << ',' + << numeric(record.performance.compute_runtime_ms) << ',' + << optional_numeric(record.performance.rng_ms) << ',' + << optional_numeric(record.performance.reduction_ms) << ',' + << numeric(record.performance.paths_per_second) << ',' + << optional_numeric(record.performance.cpu_runtime_ms) << ',' + << optional_numeric(record.performance.speedup_vs_cpu) << ',' + << csv_escape(record.environment.gpu_name) << ',' + << csv_escape(record.environment.cuda_runtime_version) << ',' + << csv_escape(record.environment.git_commit); + return output.str(); +} + +CsvState inspect_csv_unlocked(const std::filesystem::path &path) { + std::error_code error; + const bool existed = std::filesystem::exists(path, error); + if (error) { + throw OutputError("could not inspect performance CSV: " + + error.message()); + } + if (!existed) { + return {false, 0U, false}; + } + const auto size = std::filesystem::file_size(path, error); + if (error) { + throw OutputError("could not inspect performance CSV size: " + + error.message()); + } + if (size == 0U) { + return {true, 0U, false}; + } + + std::ifstream input(path, std::ios::binary); + if (!input) { + throw OutputError("could not open performance CSV for validation: " + + path.string()); + } + std::string header; + if (!std::getline(input, header)) { + throw OutputError("could not read performance CSV header"); + } + if (!header.empty() && header.back() == '\r') { + header.pop_back(); + } + if (header != k_csv_header) { + throw OutputError("performance CSV header does not match schema 1.0"); + } + input.clear(); + input.seekg(-1, std::ios::end); + char last = '\0'; + input.get(last); + if (!input) { + throw OutputError("could not inspect performance CSV tail"); + } + return {true, size, last != '\n'}; +} + +void append_csv_unlocked(const std::filesystem::path &path, + const OutputRecord &record, const CsvState &state) { + std::ofstream output(path, std::ios::binary | std::ios::app); + if (!output) { + throw OutputError("could not open performance CSV for append: " + + path.string()); + } + if (state.size == 0U) { + output << k_csv_header << '\n'; + } else if (state.needs_newline) { + output << '\n'; + } + output << csv_row(record) << '\n'; + output.flush(); + output.close(); + if (!output) { + throw OutputError("could not append performance CSV: " + path.string()); + } +} + +std::filesystem::path +unique_temporary_path(const std::filesystem::path &final_path) { + static std::atomic next_id{0U}; + for (std::uint32_t attempt = 0U; attempt < 100U; ++attempt) { + const auto candidate = std::filesystem::path( + final_path.string() + "." + std::to_string(process_id()) + "." + + std::to_string(next_id.fetch_add(1U)) + ".tmp"); + if (!path_exists(candidate, "could not inspect temporary JSON path")) { + return candidate; + } + } + throw OutputError("could not reserve a unique temporary JSON path"); +} + +JsonWrite write_json_unlocked(const std::filesystem::path &output_dir, + OutputRecord record) { + const std::string base_run_id = + record.run_id.empty() + ? record.timestamp_utc + "-" + + option_type_name(record.option.type) + "-" + record.backend + : record.run_id; + std::filesystem::path final_path = + output_dir / (base_run_id + "-result.json"); + for (std::uint64_t suffix = 1U; + path_exists(final_path, "could not inspect result JSON path"); + ++suffix) { + final_path = output_dir / (base_run_id + "-" + std::to_string(suffix) + + "-result.json"); + } + std::string final_run_id = final_path.stem().string(); + final_run_id.resize(final_run_id.size() - std::string("-result").size()); + record.run_id = final_run_id; + + const auto temporary_path = unique_temporary_path(final_path); + try { + std::ofstream stream(temporary_path, + std::ios::binary | std::ios::trunc); + if (!stream) { + throw OutputError("could not open temporary JSON: " + + temporary_path.string()); + } + stream << make_result_json(record).dump(2) << '\n'; + stream.flush(); + if (!stream) { + throw OutputError("could not write temporary JSON: " + + temporary_path.string()); + } + stream.close(); + if (!stream) { + throw OutputError("could not close temporary JSON: " + + temporary_path.string()); + } + std::error_code error; + std::filesystem::rename(temporary_path, final_path, error); + if (error) { + throw OutputError("could not publish result JSON: " + + error.message()); + } + } catch (...) { + std::error_code ignored; + std::filesystem::remove(temporary_path, ignored); + throw; + } + return {final_path, final_run_id}; +} + +} // namespace + +nlohmann::json make_result_json(const OutputRecord &record) { + nlohmann::json json; + const nlohmann::json confidence_interval = + record.result.ci_lower && record.result.ci_upper + ? nlohmann::json::array( + {*record.result.ci_lower, *record.result.ci_upper}) + : nlohmann::json(nullptr); + json["schema_version"] = "1.0"; + json["run_id"] = record.run_id; + json["status"] = "success"; + json["timestamp_utc"] = record.timestamp_utc; + json["backend"] = record.backend; + json["option"] = {{"type", option_type_name(record.option.type)}, + {"spot", record.option.spot}, + {"strike", record.option.strike}, + {"risk_free_rate", record.option.risk_free_rate}, + {"volatility", record.option.volatility}, + {"maturity", record.option.maturity}, + {"barrier", optional_json(record.option.barrier)}}; + json["simulation"] = { + {"num_paths", record.simulation.num_paths}, + {"num_steps", record.simulation.num_steps}, + {"seed", record.simulation.seed}, + {"rng", rng_name(record.simulation.rng)}, + {"variance_reduction", + variance_reduction_name(record.simulation.variance_reduction)}, + {"precision", precision_name(record.simulation.precision)}, + {"block_size", record.simulation.block_size}, + {"batch_size", record.simulation.batch_size}}; + json["result"] = { + {"price_estimate", record.result.price}, + {"sample_stddev", optional_json(record.result.sample_stddev)}, + {"standard_error", optional_json(record.result.standard_error)}, + {"confidence_level", 0.95}, + {"confidence_interval", confidence_interval}, + {"reference_price", optional_json(record.result.reference_price)}, + {"absolute_error", optional_json(record.result.absolute_error)}, + {"relative_error", optional_json(record.result.relative_error)}}; + json["performance"] = { + {"total_runtime_ms", record.performance.total_runtime_ms}, + {"compute_runtime_ms", record.performance.compute_runtime_ms}, + {"gpu_compute_ms", + record.backend == "gpu" + ? nlohmann::json(record.performance.compute_runtime_ms) + : nlohmann::json(nullptr)}, + {"rng_ms", optional_json(record.performance.rng_ms)}, + {"reduction_ms", optional_json(record.performance.reduction_ms)}, + {"paths_per_second", record.performance.paths_per_second}, + {"block_size", record.backend == "gpu" + ? nlohmann::json(record.performance.block_size) + : nlohmann::json(nullptr)}, + {"grid_size", record.backend == "gpu" + ? nlohmann::json(record.performance.grid_size) + : nlohmann::json(nullptr)}, + {"repetitions", record.performance.repetitions}, + {"aggregation", record.performance.aggregation}, + {"cpu_runtime_ms", optional_json(record.performance.cpu_runtime_ms)}, + {"speedup_vs_cpu", optional_json(record.performance.speedup_vs_cpu)}}; + json["environment"] = { + {"gpu_name", optional_json(record.environment.gpu_name)}, + {"compute_capability", + optional_json(record.environment.compute_capability)}, + {"cuda_runtime_version", + optional_json(record.environment.cuda_runtime_version)}, + {"build_type", optional_json(record.environment.build_type)}, + {"git_commit", optional_json(record.environment.git_commit)}}; + return json; +} + +std::filesystem::path +write_result_json_atomic(const std::filesystem::path &output_dir, + OutputRecord record) { + OutputLock lock(output_dir); + return write_json_unlocked(output_dir, std::move(record)).path; +} + +void append_performance_csv(const std::filesystem::path &path, + const OutputRecord &record) { + const auto directory = path.parent_path().empty() + ? std::filesystem::path(".") + : path.parent_path(); + OutputLock lock(directory); + append_csv_unlocked(path, record, inspect_csv_unlocked(path)); +} + +WrittenOutput write_output_transaction(const std::filesystem::path &output_dir, + OutputRecord record) { + OutputLock lock(output_dir); + const auto csv_path = output_dir / "performance.csv"; + const CsvState csv_state = inspect_csv_unlocked(csv_path); + const JsonWrite json = write_json_unlocked(output_dir, record); + record.run_id = json.run_id; + try { + append_csv_unlocked(csv_path, record, csv_state); + } catch (const std::exception &error) { + std::error_code json_error; + std::filesystem::remove(json.path, json_error); + std::error_code csv_error; + if (csv_state.existed) { + std::filesystem::resize_file(csv_path, csv_state.size, csv_error); + } else { + std::filesystem::remove(csv_path, csv_error); + } + if (json_error || csv_error) { + throw OutputError(std::string(error.what()) + + "; output rollback failed"); + } + throw; + } + return {json.path, csv_path, json.run_id}; +} + +double median(std::vector measurements) { + if (measurements.empty()) { + throw std::invalid_argument("median requires at least one measurement"); + } + std::sort(measurements.begin(), measurements.end()); + const std::size_t middle = measurements.size() / 2U; + if (measurements.size() % 2U != 0U) { + return measurements[middle]; + } + return (measurements[middle - 1U] + measurements[middle]) / 2.0; +} + +std::string utc_timestamp() { + const std::time_t now = + std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); + std::tm utc{}; +#ifdef _WIN32 + gmtime_s(&utc, &now); +#else + gmtime_r(&now, &utc); +#endif + std::ostringstream stream; + stream << std::put_time(&utc, "%Y%m%dT%H%M%SZ"); + return stream.str(); +} + +} // namespace pricer diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/payoff.cpp" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/payoff.cpp" new file mode 100644 index 00000000..2e62af1e --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/payoff.cpp" @@ -0,0 +1,82 @@ +// 计算 GBM 演化常量以及两类期权的折现收益。 +#include "pricer/payoff.hpp" + +#include +#include +#include +#include + +namespace pricer { +namespace { + +// 所有金融输入先要求为有限值,防止 NaN 在统计阶段扩散。 +void require_finite(double value, const char *name) { + if (!std::isfinite(value)) { + throw std::domain_error(std::string(name) + " must be finite"); + } +} + +void validate_payoff_inputs(double spot_or_sum, double strike, + double discount) { + require_finite(spot_or_sum, "spot or running sum"); + require_finite(strike, "strike"); + require_finite(discount, "discount"); + if (spot_or_sum < 0.0 || strike <= 0.0 || discount <= 0.0) { + throw std::domain_error("payoff inputs are out of range"); + } +} + +double require_finite_result(double value) { + if (!std::isfinite(value)) { + throw std::overflow_error("payoff result is not finite"); + } + return value; +} + +} // namespace + +// 预计算每一步漂移、扩散项和全期限折现因子。 +GbmStepConstants make_gbm_step_constants(double maturity, double risk_free_rate, + double volatility, + std::uint32_t num_steps) { + require_finite(maturity, "maturity"); + require_finite(risk_free_rate, "risk-free rate"); + require_finite(volatility, "volatility"); + if (maturity <= 0.0 || volatility < 0.0 || num_steps == 0U) { + throw std::domain_error("GBM step parameters are out of range"); + } + + // 把总期限 T 切成 num_steps 段;CPU 和 GPU 都使用同一组常量, + // 因而两端模拟的是同一个风险中性 GBM 模型。 + const double dt = maturity / static_cast(num_steps); + const double drift = (risk_free_rate - 0.5 * volatility * volatility) * dt; + const double diffusion = volatility * std::sqrt(dt); + const double discount = std::exp(-risk_free_rate * maturity); + require_finite_result(dt); + require_finite_result(drift); + require_finite_result(diffusion); + require_finite_result(discount); + return {dt, drift, diffusion, discount}; +} + +double european_call_payoff(double terminal_spot, double strike, + double discount) { + validate_payoff_inputs(terminal_spot, strike, discount); + // 这里只计算一条路径的“今天价值”:到期的 max(S_T-K, 0) 先得到, + // 再乘 exp(-rT) 折回今天。之后 Monte Carlo 只需对很多条这样的值取平均。 + return require_finite_result(discount * + std::max(terminal_spot - strike, 0.0)); +} + +double asian_arithmetic_call_payoff(double running_sum, std::uint32_t num_steps, + double strike, double discount) { + validate_payoff_inputs(running_sum, strike, discount); + if (num_steps == 0U) { + throw std::domain_error("Asian arithmetic payoff requires steps"); + } + // running_sum 只累加演化后的 M 个监控点,刻意不把初始价格 S0 算入平均值。 + const double average = running_sum / static_cast(num_steps); + return require_finite_result(discount * std::max(average - strike, 0.0)); +} + +} // namespace pricer diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/statistics.cpp" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/statistics.cpp" new file mode 100644 index 00000000..84d7c92d --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/statistics.cpp" @@ -0,0 +1,115 @@ +// 从路径收益的原始矩计算均值、标准误和置信区间。 +#include "pricer/statistics.hpp" + +#include +#include +#include +#include +#include + +namespace pricer { +namespace { + +// 统计公式假定收益和平方收益均有效且非负。 +void validate_moments(const RawMoments &moments) { + if (!std::isfinite(moments.sum) || !std::isfinite(moments.sum_squares)) { + throw std::domain_error("moments must be finite"); + } + if (moments.sum < 0.0 || moments.sum_squares < 0.0) { + throw std::domain_error("moments must be non-negative"); + } +} + +double require_finite(double value, const char *name) { + if (!std::isfinite(value)) { + throw std::overflow_error(std::string(name) + " is not finite"); + } + return value; +} + +} // namespace + +// 分批计算时,原始矩可以直接相加而无需回放每条路径。 +RawMoments merge_raw_moments(const RawMoments &left, const RawMoments &right) { + // GPU 分批后,每一批只传回两个标量;此函数把它们合成一次完整实验的矩。 + validate_moments(left); + validate_moments(right); + if (right.count > std::numeric_limits::max() - left.count) { + throw std::overflow_error("moment count overflow"); + } + + const double sum = require_finite(left.sum + right.sum, "merged sum"); + const double sum_squares = require_finite( + left.sum_squares + right.sum_squares, "merged sum of squares"); + return {left.count + right.count, sum, sum_squares}; +} + +// 使用样本方差而非总体方差,并为单样本保留不可用统计量。 +PricingResult ResultAnalyzer::analyze(const RawMoments &moments, + std::optional reference_price) { + validate_moments(moments); + if (moments.count == 0U) { + throw std::domain_error("at least one sample is required"); + } + if (reference_price.has_value() && !std::isfinite(*reference_price)) { + throw std::domain_error("reference price must be finite"); + } + + const double count = static_cast(moments.count); + // 折现收益的样本均值就是蒙特卡洛价格估计。 + const double price = require_finite(moments.sum / count, "price"); + PricingResult result{price, std::nullopt, std::nullopt, + std::nullopt, std::nullopt, reference_price, + std::nullopt, std::nullopt, moments}; + + if (moments.count > 1U) { + // E[X^2] - E[X]^2 给出样本方差所需的分子;再由 stddev/sqrt(N) 得到 SE。 + const double expected_square = + require_finite(count * price * price, "variance term"); + double variance_numerator = moments.sum_squares - expected_square; + const double variance_scale = std::max( + {1.0, std::abs(moments.sum_squares), std::abs(expected_square)}); + const double roundoff_tolerance = + 64.0 * std::numeric_limits::epsilon() * variance_scale; + if (variance_numerator < 0.0) { + if (variance_numerator < -roundoff_tolerance) { + throw std::domain_error( + "sample variance is materially negative"); + } + variance_numerator = 0.0; + } + + const double variance = require_finite( + variance_numerator / static_cast(moments.count - 1U), + "sample variance"); + const double sample_stddev = + require_finite(std::sqrt(variance), "sample standard deviation"); + const double standard_error = + require_finite(sample_stddev / std::sqrt(count), "standard error"); + // 1.96 对应正态近似下的双侧 95% 置信区间。 + const double ci_lower = require_finite(price - 1.96 * standard_error, + "lower confidence interval"); + const double ci_upper = require_finite(price + 1.96 * standard_error, + "upper confidence interval"); + if (standard_error < 0.0 || ci_lower > ci_upper) { + throw std::domain_error("invalid confidence interval"); + } + result.sample_stddev = sample_stddev; + result.standard_error = standard_error; + result.ci_lower = ci_lower; + result.ci_upper = ci_upper; + } + + if (reference_price.has_value()) { + const double absolute_error = require_finite( + std::abs(price - *reference_price), "absolute error"); + result.absolute_error = absolute_error; + if (std::abs(*reference_price) > 1e-15) { + result.relative_error = require_finite( + absolute_error / std::abs(*reference_price), "relative error"); + } + } + return result; +} + +} // namespace pricer diff --git "a/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/version.cpp" "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/version.cpp" new file mode 100644 index 00000000..8e0486ca --- /dev/null +++ "b/05_option_pricing/\346\266\202\345\256\266\344\277\212/src/version.cpp" @@ -0,0 +1,8 @@ +// 返回构建产物和 CLI 共用的版本标识。 +#include "pricer/version.hpp" + +namespace pricer { + +const char *version() noexcept { return "0.1.0"; } + +} // namespace pricer