量化金融库是一种软件包,包含适用于量化投资环境的数学、统计和最近的机器学习模型。它们包含一系列功能,通常为专有功能,用于支持评估、风险管理、组建和优化投资组合。
开发此类库的金融公司必须在新功能的短期启用和长期软件工程考虑之间优先考虑有限的开发者资源。此外,合规、监管和风险管理的约束将对潜在的利润和损失影响的任何代码更改提供更严格的监督,而 C++标准并行性使旧代码更具可持续性,并为 GPU 和 CPU 并行性做好准备。
本文展示了如何通过利用CPU和GPU的并行性,使用ISO C++标准重构一个简单的Black-Scholes模型。同时,它还展示了如何重复使用原始实现中的大部分代码。对于辅助参考或自行测试实施的代码,请访问NVIDIA/accelerated-quant-finance GitHub 库。这种方法不仅节省了开发者的时间,而且提高了关键量化金融库的性能,是一种低风险且简单的策略。
我们展示了您应该使用的几个现代 C++功能,以及如何将现有的 C 或 C++代码现代化,以利用标准并行性。我们还展示了 C++并行算法如何取代串行for
循环来实现代码并行化。我们还展示了span
可以改善对数据的观察,从而提高代码的安全性和简洁性。
借助并行编程应对选择估值挑战
选项是具有非线性回报的金融衍生产品。资产组合选择权的当前价值可能是一个复杂、非线性的多变量函数。在动态市场条件下,资产组合选择权的行为可能是一项计算密集型任务,因为资产组合选择权的价值会随着时间的推移而发生变化。
即使是最简单的选择权案例也是如此,因为不同的资产类别的底层会产生对各种市场数据的复杂的依赖关系。了解选择权组合在各种市场场景下的动态是许多交易活动的关键,包括 alpha 生成、风险管理和市场创造。
我们专注于快速评估许多普通选择权。各种应用程序 (如模拟、回测、策略选择和优化) 都需要高性能评估。在本例中,我们使用 Black-Scholes 模型评估欧洲选择权和选择权。但是,您可以轻松扩展此处讨论的方法,应用于其他更复杂的用例。
本文中不会详细介绍 Black-Scholes 模型的理论细节。我们先从使用 C 语言编写的给定 Black-Scholes 估值函数开始。这个函数BlackScholesBody
计算给定价格、到期日和波动率的选择权或选择权的价值。一个名为BlackScholesCPU
调用此函数以估值大量选择 (交易和投资) 的范围,该范围包括给定的基准现货价格和风险免费率。对于每个选择,我们计算其溢价。
允许货币价格或风险免费率波动,考虑不同的基础资产,或者计算希腊字母,可以进一步扩大我们的选择范围。显然,这个问题具有很大的并行化潜力。
原始基准代码是相当标准的 C 风格代码。该函数接受多个数组作为双精度数据的指针,并包含对所有选项的循环。对于每个选项,Black-Scholes 计算通过调用BlackScholesBody
函数。这个函数的详细信息不重要,但我们选择在这个函数中包装计算,以便在我们的第二个示例中重复使用。如果您编写过任何 C 或 C++代码,这个函数应该很熟悉。我们在循环中添加了 OpenMP,以便基准代码至少在多个 CPU 核心上运行,以便进行公平的性能比较。
void BlackScholesCPU(
double *CallPrices,
double *PutPrices,
double spotPrice,
double *Strikes,
double *Maturities,
double RiskFreeRate,
double *Volatilities,
int optN
)
{
#pragma omp parallel for
for (int opt = 0; opt < optN; opt++)
{
BlackScholesBody(
CallPrices[opt],
spotPrice,
Strikes[opt],
Maturities[opt],
RiskFreeRate,
Volatilities[opt],
CALL);
BlackScholesBody(
PutPrices[opt],
spotPrice,
Strikes[opt],
Maturities[opt],
RiskFreeRate,
Volatilities[opt],
PUT);
}
}
虽然这个代码看起来很熟悉且易于理解,但它有一个重大限制:它是典型的串行代码。尽管我们已经添加了 OpenMP 宏以实现代码线程化,但循环仍然是串行循环,并且并行性是在考虑之后才添加的。这种方法在很多年里都很常见,但最近的编码实践发展让我们能够轻松地设计并行优先算法。以下是一个并行优先实现示例。
void BlackScholesStdPar(
std::span CallPrices,
std::span PutPrices,
double spotPrice,
std::span Strikes,
std::span Maturities,
double RiskFreeRate,
std::span Volatilities)
{
// Obtain the number of options from the CallPrices array
int optN = CallPrices.size();
// This iota will generate the same indices as the original loop
auto options = std::views::iota(0, optN);
// The for_each algorithm replaces the original for loop
std::for_each(std::execution::par_unseq,
options.begin(), // The starting index
options.end(), // The ending condition
[=](int opt) // The lambda function replaces the loop body
{
BlackScholesBody(CallPrices[opt],
spotPrice,
Strikes[opt],
Maturities[opt],
RiskFreeRate,
Volatilities[opt],
CALL);
BlackScholesBody(PutPrices[opt],
spotPrice,
Strikes[opt],
Maturities[opt],
RiskFreeRate,
Volatilities[opt],
PUT);
});
}
我们对代码进行了几项重要更改,但大部分代码都封装在BlackScholesBodyCPU
函数不会发生任何变化。操作没有发生变化,而是通过调用具有并行执行策略的标准算法,而非使用串行循环来应用于数据。
我们希望重点介绍以下几个特性。
首先,我们不再传递 raw 指针,而是传递 C++横向块。横向块是指向内存的视图,并包含某些便利性,例如可以查询大小。不同于容器vector
例如,span
只是显示内存的视图,因此您可以改变如何访问内存,而无需更改容器本身。
其次,原始代码对选项进行了循环。我们使用了 IotaView 来完成相同的操作,每次我们的算法执行时,它都会从 IotaView 接收一个索引。
第三,我们不再使用循环,而是使用for_each
算法。C++标准库提供了各种算法供选择,for_each
提供了原始循环的便捷替代方案,并需要指定几个参数:
- 执行策略:
par_unseq
向编译器保证代码可以并行执行并按任意顺序执行,从而实现并行执行和矢量执行。nvc++ 编译器 可以进一步将此代码卸载到 GPU。请注意,并行执行不是默认行为,但可以通过执行策略进行启用。 - 循环范围的起始和终止值:这些值由
options.begin
和options.end
分辨率分别为 1024×1024 和 2048×204。 - Lambda 函数:此 lambda 执行原始循环体中指定的任务。我们通过值获取 lambda 中使用的数据,以确保它可在 GPU 上显示。
应用到代码中的所有更改都非常简单。它们只是更新代码以在语言中使用现代功能。最初可能会觉得有点不熟悉,但如果您同时查看代码,您会发现几乎没有变化:
void BlackScholesCPU(
double *CallPrices,
double *PutPrices,
double spotPrice,
double *Strikes,
double *Maturities,
double RiskFreeRate,
double *Volatilities,
int optN)
{
#pragma omp parallel for
for (int opt = 0; opt < optN; opt++)
{
BlackScholesBody(
CallPrices[opt],
spotPrice,
Strikes[opt],
Maturities[opt],
RiskFreeRate,
Volatilities[opt],
CALL);
BlackScholesBody(
PutPrices[opt],
spotPrice,
Strikes[opt],
Maturities[opt],
RiskFreeRate,
Volatilities[opt],
PUT);
}
}
void BlackScholesStdPar(
std::span CallPrices,
std::span PutPrices,
double spotPrice,
std::span Strikes,
std::span Maturities,
double RiskFreeRate,
std::span Volatilities)
{
int optN = CallPrices.size();
auto options = std::views::iota(0, optN);
std::for_each(
std::execution::par_unseq,
options.begin(), options.end(),
[=](int opt)
{
BlackScholesBody(CallPrices[opt],
spotPrice,
Strikes[opt],
Maturities[opt],
RiskFreeRate,
Volatilities[opt],
CALL);
BlackScholesBody(PutPrices[opt],
spotPrice,
Strikes[opt],
Maturities[opt],
RiskFreeRate,
Volatilities[opt],
PUT);
});
}
代码中的变化很少,但改造代码可以带来显著的性能提升。重要的是,原始代码是串行的,后来增加了并行性,而新代码从一开始就是并行的。由于并行代码可以始终以串行方式运行,因此基准代码现在可以在CPU上的并行线程中运行,或者将代码卸载到GPU,而无需更改代码。对于图1中显示的性能图表,NVIDIA HPC SDK v23.11用于NVIDIA Grace Hopper 超级芯片。在为CPU构建C++代码时,使用-stdpar=multicore
编译选项;在构建GPU卸载时,使用-stdpar=gpu
编译选项。
本示例的重要启示是,默认情况下,应在应用程序中暴露并行性。代码应从一开始就表达可用的并行性。在同一 CPU 上运行的 ISO C++代码使用相同的编译器,运行效果略优于原始代码,因为编译器能够更好地优化纯 C++代码。构建此代码的速度比构建原始代码快 26 倍。使用 NVIDIA Hopper GPU,我们得益于大量可用的并行性,在重复使用大量现有代码的同时完成了所有这些工作。
凭借如此显著的加速,耗时的投资组合管理工作流程 (如回测和模拟) 变得更加实用。传统上,这些工作流程依赖于简化模型 (如维度归约、Gaussian 分布假设或其他精简表示选择) 来减轻计算负担。现在,这些工作流程可以在更逼真的条件下完成。
采用并行编码,在量化金融领域实现出色性能
总而言之,本文探讨了使用 ISO C++在量化金融领域进行编码时采用并行先行的方法的变革力量。通过重构现有代码以利用现代 C++特性 (如 spans 和并行算法),开发者可以在多核 CPU 和 GPU 上无缝释放并行性潜力。
该示例聚焦于大型选择权益组合的快速估值,展示了通过 C++标准并行实现的显著性能提升。原始 OpenMP 代码与在 CPU 和 GPU 平台上运行的 ISO C++并行代码的比较显示了显著的加速。这为耗时的选择权益组合管理工作流程 (如回测和模拟) 开辟了新的可能性。
关键要点是显而易见的:从一开始就使用具有内部并行性的代码编写代码可以实现更高效且更具可扩展性的解决方案。采用现代编码实践可以通过编译器优化性能,尤其是在构建 NVIDIA Hopper GPU 时可以获得显著的 26 倍加速。
随着开发者进入并行编程领域,我们鼓励他们进行探索和实践。通过 NVIDIA/accelerated-quant-finance 与 NVIDIA HPC SDK 搭配使用,为深入研究标准并行提供了一条切实可行的途径。虽然这些资源并非用于生产,但它们却是宝贵的学习工具,可助力开发者将并行第一原则融入自己的项目中。
量化金融领域的编码未来在于采用并行性作为基础原则。通过这样做,开发者为提升性能、增强可扩展性和导航金融领域技术领域不断发展的技术环境奠定了基础。在编码领域,最佳代码始终以并行性为先。