第8章 C++——追求微秒延迟
在本章中,我们将讨论c++中提供的一些特性和结构。在我们开始之前有一个免责声明,如果涵盖现代c++ (c++ 11/14/17)提供的很多/大部分内容将超出了一章(而且往往是一本书)的范围,所以我们将重点讨论对于开发、维护和改进多线程和超低运行时延迟HFT应用程序很重要的几个方面。在开始挖掘本章之前,我们建议你能够精通c++。我们推荐几本书来达到这个目的,比如Bjarne Stroustrup写的《Programming: Principles and Practice Using c++》。
我们将从研究现代C++内存模型开始,这些模型指定了共享内存(指可供多个线程并发访问的内存区域,通常指全局变量,需注意这里不是指多进程之间的通信方法)交互在多线程环境中的工作方式,接着研究静态分析,这是应用程序开发、测试、和维护的重要方面。然后,我们将深入研究如何优化应用程序的运行时性能,最后将整个部分用于模板,这对顶级HFT生态系统非常重要。
在本章中,我们将讨论以下主题:
- C++内存模型
- 删除运行时决策
- 动态内存分配
- 通过模版减少运行时间
- 静态分析
在本章结束时,你将学会如何优化你的高频交易系统c++代码。最后,我们将通过回顾一个行业用例,来讨论我们用来建立外汇(FX)高频对冲基金的技术。
为了指导您了解所有优化,我们将优化分为3个级别,优化指延迟低于指定时间,指定时间分别是20us,5us,500ns,这个优化级别我们将在标题中进行说明。 首先让我们来讨论C++内存模型。
C++14/17内存模型(5us)
在本节中,我们将探索现代c++(11、14和17)内存模型的定义和规范。我们将研究它是什么,为什么多线程应用程序需要它,以及C++内存模型的重要原理。
什么是内存模型?
内存模型,也称为内存一致性模型,指定与共享内存交互的多线程应用程序所允许的和预期的行为。内存模型是共享内存系统并发语义的基础。如果有两个并发程序,一个向共享内存空间写入,另一个从共享内存空间读取,内存模型定义了一个读操作对于任何读写组合来说所允许返回的值集。
内存模型(c++或其他)的实现必须受到内存模型指定规则的约束,因为如果不能从读写的顺序推断出结果,那么它就不是一个明确的内存模型。考虑内存模型强制执行限制的另一种方法是,它们定义编译器、处理器和体系结构(内存)允许哪些指令重新排序。大多数关于内存模型的研究都试图最大化编译器、处理器和体系结构优化的自由度。
即使优化变得越来越复杂,它们也必须保持开发人员想要做的事情的语义。它们永远不应该打破内存模型的约束。
现在,让我们正式定义几个术语。
- Source code order(源代码顺序):这是程序员在他们选择的编程语言中指定的指令和内存操作的顺序。这是在编译器编译代码之前存在的代码或指令集。
- Program order(程序顺序):这是编译源代码后将在CPU上执行的机器码中的指令和内存操作的顺序。指令和/或内存操作的顺序在这里可以不同,因为正如前面提到的,编译器将尝试优化和重新排序指令,这是优化过程的一部分。
- Execution order(执行顺序):这是在CPU上执行指令和内存引用的实际执行顺序。这与编译程序的顺序不同,因为在这一阶段,CPU和总体架构允许重新排序编译器生成的机器代码中的指令。这里的优化取决于特定CPU的内存模型及其体系结构。
为什么需要内存模型?
让我们来讨论一下为什么我们需要定义良好的内存模型。根本原因在于,我们编写的代码并不是编译过程之后输出的代码,也不是在硬件上运行的代码。现代的编译器和cpu允许乱序执行指令,以最大化性能和资源利用率。
在单线程环境中,这并不重要,但在运行于多核(多处理器)架构上的多线程环境中,不同的线程试图读取和写入共享内存位置会导致竞争条件,并导致在指令重新排序时出现未定义的和意外的行为。正如我们在第4章,高频交易系统基础——从硬件到操作系统,调度和上下文切换是不确定的。它们可以被控制,但是当上下文切换发生在非常特定的位置时,优化可能会导致指令的执行和内存访问的发生以某种顺序进行,将导致结果根据事件的顺序不同而不同。
拥有内存模型可以在应用优化时为优化编译程序提供高度自由。内存模型规定了使用同步原语的同步障碍(互斥锁、锁、同步块、障碍等等),我们在之前的第7章HFT优化-日志记录、性能和网络中看到过。当共享变量发生变化且达到同步障碍时,需要使更改对其他线程可见;也就是说,重新排序不能破坏这个不变量。现在我们将详细描述C++内存模型是如何工作的。
C++11内存模型和它的规则
在我们研究c++ 11内存模型的细节之前,让我们回顾一下前面两节的内容。内存模型的目的是做以下工作:
- 指定线程通过共享内存进行交互的可能结果。
- 检查程序是否具有定义良好的行为。
- 指定编译器代码生成的约束。
C++内存模型对内存访问语义有最低限度的保证。正如预期的那样,编译器处理和优化(重新排序指令和内存访问的优化)以及乱序执行指令和内存访问的 CPU/架构的可接受程度有限。C++ 内存模型本身对内存访问语义的保证非常薄弱——比您预期的要弱,也比实践中通常实现的要弱。实践中的内存模型反映了系统强加的规则,例如 x86_64 的全存储排序 (TSO) 或 ARM 的宽松排序。
对于 C++ 内存模型,在主内存(共享)和每线程内存之间传输数据有三个规则。我们将讨论如下所示的三个规则。我们将在下一节中解决内存排序和顺序一致性问题。
原子性
我们之前在第 6 章 HFT 优化 - 体系结构和操作系统中的无锁数据结构部分已经看到了这一点。在使用全局/静态/共享变量/数据结构时,需要明确哪些操作是不可分割的。
让我们介绍一些c++ 11以后可用来支持原子性的构造,这些构造可以泛化到不同类型的对象(模板),并支持原子加载和存储。我们将快速介绍c++ 11提供的内存排序支持,并将在后面的内存排序部分根据c++内存排序原则仔细研究它。
std::lock_guard
std::lock_guard是一个简单的互斥锁包装器,它使用资源获取即初始化(RAII)原则来拥有作用域块中的互斥锁。一旦互斥锁被创建,并且创建互斥锁的作用域完成,它就会尝试获得互斥锁的所有权,然后调用lock_guard析构函数,从而释放互斥锁。为方便起见,c++ 11提供了std::atomic模板类来支持T类型对象的原子加载和存储。
std::atomic
我们之前提到的支持对泛型对象的原子操作的泛型类 std::atomic 支持以下原子操作(还有更多但我们列出了最重要的):
- load (std::memory_order order),它加载并返回当前值,但以原子方式执行。
- store(T value, std::memory_order order),以原子方式保存当前值。
- exchange(T value, std::memory_order order),它执行与store类似的工作,但执行read-modify-write操作。
对于整数和指针类型,它还提供以下操作:
- fetch_add(T arg, std::memory_order order),它接受一个额外的参数arg,并原子地将值设置为值和arg的算术加法。
- fetch_sub(T arg, std::memory_order order),它类似于fetch_ add,只是它是减法而不是加法。
并且专门针对整型,它提供了以下附加的逻辑操作:
- fetch_and(T arg, std::memory_order order),它类似于fetch_ add,只是它执行按位的AND操作。
- fetch_or(T arg, std::memory_order order), 它类似于fetch_ add,只是它执行按位的OR操作。
- fetch_xor(T arg, std::memory_order order), 它类似于fetch_ add,只是它执行按位的XOR操作。
std::memory_order顺序参数的默认值是std::memory_order_seq_cst(顺序一致性)。这里可以指定其他值,而不是顺序一致性,以定义较弱的内存模型。不同的选项及其影响将在C++内存模型原理的内存排序部分进行讨论。
原子操作的属性
原子操作的属性如下:
- 操作可以由多个线程并发执行,而不会有未定义行为的风险。
- 原子load可以看到变量的初始值,也可以看到通过原子store写入变量的值。
- 在所有线程中,相同对象的原子存储顺序相同。
让我们来看下一条规则。
可见性
我们在上一节中简要地谈到了可见性,我们提到当共享数据上发生读写操作时,一个线程写入变量的影响需要在线程同步障碍边界处对从它读取的线程可见。
让我们讨论关于一个线程对另一个线程所做更改的可见性的规则。在以下条件下,一个线程所做的更改对其他线程可见:
- 写线程释放同步锁,读线程随后获取同步锁。释放锁将刷新所有写入操作,获得锁将加载或重新加载值。
- 对于原子变量,写入它的值会在写入方的下一个内存操作之前立即刷新,但读取方必须在每次访问之前调用load指令。
- 当写入线程终止时,所有写入的变量都会被刷新,因此与该线程终止(join)同步的线程将看到该线程写入的正确值。
以下是关于可见度需要注意的其他事项:
- 当有很长一段代码不与其他依赖线程保持同步时,共享数据成员的值在这些线程中可能完全不同步。
- 循环等待或检查由其他线程写入的值是错误的,除非它们使用原子或同步。
- 不保证或不需要在没有正确同步的情况下出现可见性故障和安全违规,这只是一种可能性。它可能不会在实践中发生,极少发生,或者仅在某些体系结构上或由于某些特定的外部因素而发生。总体而言,几乎不可能 100% 确信不存在基于可见性的错误。
现在我们将讨论指令排序。
排序
由于内存访问由编译器或 CPU 重新排序,因此内存模型需要定义分配操作的效果何时会出现给定线程的乱序:
- 顺序一致性是一种 C++ 机器内存模型,它要求来自所有线程的所有指令看起来就像它们正在按照与每个线程上的程序或源代码顺序一致的顺序执行。
- 内存排序是我们稍后将详细探讨的另一个概念。它描述了内存访问指令的顺序。该术语可用于指代编译时或运行时的内存访问顺序。内存排序允许编译器和 CPU 对内存操作重新排序,因此它们是乱序的,这导致内存层次结构的不同层(寄存器、高速缓存、主内存等)的最佳利用以及最大化使用数据传输架构及其带宽。
下面我们就来了解一下C++内存模型和内存顺序的概念。
C++内存模型原理
在本节中,我们将研究现代C++内存模型范例中关于在多线程和多处理环境中访问和写入共享数据结构的不同选项。
内存顺序概念
在 HFT 中使用线程时,理解内存模型很重要,因为如果我们没有准确使用该模型,我们可能会修改软件的语义。我们首先在下表中介绍一些符号和概念,然后深入探讨不同的控制选项:
- 宽松内存顺序:在默认系统中,内存操作的排序相当松散,CPU 有很大的余地来重新排序。编译器同样可以按照他们选择的任何顺序排列它们输出的指令,只要它不影响程序表面的执行顺序。同一线程在同一内存区域上执行的内存操作不会按照修改顺序重新排序。
- 获取/释放:所有读取存储值的load-acquire操作都与store-release操作同步。在store-release发生之前发生的释放线程中的任何活动,在load-acquire之后发生的获取线程中的所有操作之前。
- 消费:消费是Acquire/Release的轻型变种。如果 X 取决于加载的值,则释放线程在store-release之前的所有操作都发生在消费线程中的操作 X 之前。
在本节中,我们研究了内存排序的一些特性。现在我们将重点讨论在c++中内存排序的定义。
内存排序
表 1 简要介绍了不同的内存排序标签,我们将在后续部分中更详细地探讨这些标签。
std::memory_order | 介绍 | std::memory_oreder_relaxed | 没有多余的内存排序限制 | std::memory_order_acquire | 在此加载之前,当前线程中的读取或写入不能重新排序。 | std::memory_order_release | 在此存储之后,当前线程中的任何读取或写入都不能重新排序。 | std::memory_order_consume | 在此加载之前,不能对当前线程中依赖于当前加载的值的读取或写入进行重新排序。 | std::memory_order_acq_rel | 合并load-acquire和store-release | std::memory_order_seq_cst | 顺序一致性;提供全局读写排序 | 这些内存顺序标签允许四种不同的内存排序模式:Sequential Consistency、Relaxed 和 Release-Acquire,以及类似的 Release-Consume。接下来让我们探讨一下。
Sequential Consistency(顺序一致性)
Sequential Consistency (SC)是一个原则,该原则声明:多线程应用程序的所有线程就内存操作已经发生或将要发生的顺序达成一致。还有一个要求就是顺序要和源程序中的操作顺序一致。在现代c++中实现这一点的技术是将共享变量声明为带有内存排序约束的c++ 11原子类型。任何执行的结果都应该与所有处理器的操作按顺序执行一样。
此外,在每个处理器上执行的操作也遵循程序顺序。多线程和多处理器环境中的 SC 意味着关于内存操作发生的顺序所有线程都在同一页上,并且每次运行程序时都是一致的。在Java中实现这一点的一种方法是将共享变量声明为volatile,而c++ 11的等效方法是将共享变量声明为带有内存排序约束的原子类型。完成后,编译器会通过在幕后引入额外的指令(如memory fences:内存栅栏)来执行这些顺序约束(请查看本章的Fences栅栏部分)。原子操作的默认内存顺序是顺序一致性,即std::memory_order_seq_cst操作。
虽然这种模式很容易理解,但它会导致最大的性能损失,因为它阻止了编译器优化,这些优化可能会尝试对原子操作之后的操作进行重新排序。 宽松排序
宽松排序与SC相反,使用std::memory_order_relax标记激活。这种原子操作模式不会对内存操作施加任何限制。但是,操作本身仍然是原子的。
Release-Acquire排序
在Release-Acquire排序设计中,原子存储或写入操作(也就是store-release)使用std::memory_order_release,原子加载或读取操作(也就是load-acquire)使用std::memory_order_acquire。编译器不允许在store-release操作之后移动store操作,也不允许在load-acquire操作之前移动load操作。当load-acquire操作看到由store-release操作写入的值时,编译器确保store-release之前的所有操作,都发生在load-acquire之后的load操作之前。
Release-Consume排序
Release-Consume排序类似于Release-Acquire排序,但这里原子load使用std::memory_order_consume便成为原子load-consume操作。此模式的行为与Release-Acquire相同,不同之处在于load-consume操作之后的load操作依赖于load-consume操作所load的值,这些操作已被正确排序。
我们已经看到,原子对象具有store和load方法,用于原子地写入和读取共享数据,默认模式是顺序一致性。在底层,编译器在每个存储之后添加额外的指令来创建内存fences(栅栏)。我们还讨论了添加大量内存fences会阻止编译器优化,从而导致代码效率低下的问题。这些围栏对于发布安全也不是必需的,在这种情况下,问题就变成了我们如何编写代码来生成最少的fencing栅栏操作(更有效的代码)?
以下是编译器在对共享数据内存访问等操作时所知道的内容:
- 每个线程中的所有内存操作及其作用,以及任何数据依赖性。
- 哪些内存位置是共享的,哪些变量是可变变量,也就是说,由于另一个线程中的内存操作,那里可以异步更改。
那么,最小化内存fences的解决方案就是简单地告诉编译器可变和共享区域上的哪些操作可以重新排序,哪些不能。独立的内存操作可以按随机顺序执行,不用考虑像前面那样的影响。
Fences(栅栏)
编程中的fences是一种barrier(障碍)指令。它们强制处理器对内存操作执行特定的顺序。这些操作将根据fences进行修改。可以在线程之间使用fences对内存操作进行排序。一个fence可以Release 或 Acquire。如果 Acquire fence出现在 Release fence之前,则store发生在Acquire fence之后的load之前。我们采用其他允许原子操作的同步原语来确保Release fence出现在Acquire fence之前。
与对原子对象的操作一样,atomic_thread_fence 操作有一个 memory_order 参数,它可以采用以下值:
- 如果memory_order是memory_order_relaxed,这没有任何影响。
- 如果memory_order是memory_order_acquire或memory_order_consume,则它是一个Acquire fence。
- 如果memory_order是memory_order_release,则它是一个Release fence。
- 如果memory_order是memory_order_acq_rel,则它既是Acquire and Release fence。
- 如果memory_order是memory_order_seq_cst,那么它就是一个顺序一致的Acquire and Release fence。
我们回顾了fences的操作顺序。现在,我们将通过讨论c++ 20中的变化来结束本节。
c++ 20内存模型的变化
c++ 20在内存模型方面有一些小的变化。在 C++ 11 内存模型形式化后发现了一些问题。旧模型的定义目标是可以使用昂贵的硬件指令在通用架构上实现不同的内存访问机制。具体来说,memory_order_acquire和memory_order_release应该可以在ARM和Power CPU架构上使用轻量级fence指令实现。不幸的是,事实证明它们不能,NVIDIA的gpu也是如此,尽管它们在十年前并不是真正的目标。
所以从根本上说,我们有两个选择:
- 按原样执行标准。这是可能的,但会导致性能下降,这与我们最初使用这些内存模型的目的相违背,并且会降低c++的效率。
- 修复标准以更好地处理新架构,而不会混淆内存模型的概念和思想。
选项2是更明智的选择,最终会被c++标准委员选择作为解决方案。
在关于内存模型的这一节中,我们回顾了不同的模型。由于HFT进程是并发运行的,因此了解内存模型在多线程软件上下文中是如何工作的非常重要。作为实现hft峰值性能的一部分,我们现在需要学习如何通过删除运行时可能发生的决策来减少执行时间。这将是我们下一节的主题。
移除运行时决策(20us)
由于c++是一种编译语言,它可以在编译过程中优化源代码,并在编译时使用尽可能多的代码和数据生成机器代码。在本节中,我们将了解移除运行时决策的动机,考虑一些在运行时解析的c++结构,并了解超低延迟HFT应用程序如何尝试最小化或替代运行时决策。
移除运行时决策的动机
位于关键路径上并且可以在编译时解析(而不是在运行时解析)的代码越多,应用程序性能就越好——这是优化 HFT 应用程序的关键因素。在这里,我们将讨论当应用程序的运行时决策最少且大部分代码可以在编译时解析时,编译器、cpu和内存架构在性能方面所获得的优势。
编译器优化
如果编译器可以在编译时解析源数据和常量/静态数据,就有可能实现大量编译时优化。在编译时解析意味着它在编译时知道每个对象类型是什么,每次调用调用哪个方法/函数/子例程,需要多少内存以及执行每个方法时的位置,等等。编译时解析允许编译器应用大量优化,包括以下内容:
- 内联:这是编译器用被调用函数体替换函数调用的地方。
- 死代码删除:编译器删除不影响程序结果的代码。
- 指令重排序:这允许我们打破依赖关系,更快地运行代码。
- 替换编译时宏:它们与内联非常相似,只是在技术上,宏的使用被编译器优化步骤之前的预处理步骤中的宏的实际代码所替换。
这将导致机器代码的生成速度大大快于编译器由于编译时解析失败而无法优化的情况。
CPU和架构优化
编译器生成的机器码不仅得到了显著优化,而且在 CPU、流水线和架构硬件级别的预取和分支预测优化方面效果更好。
由于 CPU 流水线,现代 CPU 会预取需要在短期内访问和执行的指令和数据。当在编译时就知道数据和指令的话,工作效果会显著提高——可以考虑一下内联方法和非内联方法。如果要访问的对象和/或需要的方法在编译时不知道(因为它们在运行时被解析),这个过程很难正确地进行,并且经常会从缓存或主存中预取不正确的数据和指令。
另一个与预取相关的优化是分支预测优化,其中cpu尝试预测将采用哪个分支(条件切换、函数调用等)。在存在动态解析的情况下,这就更难了,就像c++应用程序使用虚函数、运行时类型识别(RTTI)、等等。这是因为它要么不可能预测将要采取的分支,因为对象的类型可能不知道,和/或方法主体可能不知道,或者在大多数情况下非常难以得到正确的。当分支预测不正确时,就会导致损失,因为预取的数据和代码现在需要从CPU管道、缓存、内存等中移除。然后需要在调用时获取正确的数据和代码。如果您想了解更多关于分支预测的理论,我们建议您阅读《Computer Architecture: A Quantitative Approach》一书。
虚函数
虚函数是c++一个特别重要的特性——动态多态的关键。这是一个很好的特性,可以减少代码重复,为控制和数据流程序设计提供语义,并允许我们拥有可以针对特定对象类型重写和定制的通用接口。这是面向对象编程(OOP)设计中的一个重要原则,但不幸的是,它带来了运行时性能损失。由于虚函数的运行时解析和相关的运行时性能损失,HFT应用程序通常非常谨慎地考虑何时何地使用虚函数,并试图消除不必要的虚函数。本节将更详细地探讨c++虚函数的性能。
如何实现虚函数
让我们从编译器和操作系统的角度讨论如何实现虚函数。当编译器编译源代码时,它知道哪些函数是虚函数及其地址。对于每个创建了至少一个虚函数的对象,都会创建一个表(称为虚表或vtable),其中保存指向该类型的虚函数的指针。具有虚函数类型的对象有一个被称为vptr的指针,指向该对象的虚表。当虚函数被派生类覆盖时,那些被覆盖的虚函数的虚表项指向派生类实现。在运行时,调用虚函数需要比非虚函数多执行几个步骤:运行时访问vptr,找到对应对象类型的虚表,计算出需要调用的函数的地址,并执行虚函数调用。
性能损失
在上一节中,我们描述了虚函数是如何设置的以及它们是如何被调用的。由于运行时需要访问 vtable,因此虚函数调用的开销比非虚函数多一点,但在本节中,我们将探讨虚函数产生的最大性能损失。
编译器优化
使用虚函数时性能损失的一大来源是它阻止了编译器优化。概括地说,虚函数的地址取决于对象的类型,通常直到运行时才知道。这意味着需要调用的虚函数的地址和主体直到运行时也是未知的。因此,编译器没有机会内联函数。这将节省一些调用函数和从函数返回的指令。此外,内联会消除未使用的参数和变量,通常会在调用函数之前消除操作。当在循环中调用具有不同虚函数的多个对象时,情况会变得更糟。在那种情况下,不仅内联调用是不可能的,展开循环也是不可能的,利用硬件来提高性能也是不可能的。稍后我们将在另一节中对此进行讨论,但这也会破坏 CPU 管道和缓存性能。
预取和分支预测
我们在前面提到了硬件如何尝试预取可能很快被访问和执行的数据和代码。它还尝试预测可能采用哪些分支(也称为推测执行)并尝试从可能采用的分支中预取数据和代码。对于具有虚函数和虚函数调用的对象,在运行时解析实际对象类型和虚函数地址之前,它不知道跳转目的地。到这个时候,它已经根据它预测的分支预取指令并开始执行这些指令。如果碰巧正确地预测了分支,那么一切都很好,但如果没有,那么所有从预取完成的工作都必须停止并反转,现在必须获取正确的指令并在获取完成后执行。这使得程序在分支错误预测时变慢,因为不仅必须在正确地址解析后获取指令,而且还必须撤消预取和执行不正确指令的影响。此外,虚函数越短,观察到的速度越慢,因为分支预测错误的开销占总函数调用时间的比例越大。
缓存移除和性能
我们在第 6 章HFT Optimization – Architecture and Operating System的内存层次结构部分讨论了具有不同缓存层所带来的设计和性能优势。我们也提到过,L1 和 L2 缓存都有指令缓存,可以缓存经常使用和最近使用的指令。还有另一个缓存保存分支指令的比较结果——它用于从相同指令的先前执行中预测目标分支,并通过预取指令和推测性地执行它们来加速计算。
当所需的指令和分支结果位于适当的缓存中时,缓存性能最佳,但是虚函数(尤其是对每种对象类型具有不同实现的大型虚函数)在这里是有问题的。如果有一个基类指针的容器,并且每个指针都指向可能不同的对象类型或随机排列(即容器未按类型排序),这会十分糟糕。这很糟糕,因为大多数对虚函数的调用将导致对潜在随机内存位置中的不同函数的调用。
因此,如果函数足够大,每次对虚函数的调用都会导致缓存从前一个函数调用中取出数据和指令,并为新函数加载数据和指令。这是支付的分支预测惩罚的顶部(非常频繁)。虚函数可能会导致大量缓存移除和缓存丢失,并严重影响性能。但情况并非总是如此,因为如果我们通过 vtable 进行函数调用,并且我们在紧密循环中执行此操作,CPU 将通过分支预测和缓存局部性的力量来掩盖访问 vtable 的延迟。分析代码的性能问题出现在哪里总是很重要的。
图 8.2 更好地描述了这种情况。让我们假设以下类结构,其中具有虚函数的单个基类由覆盖虚函数的不同实现派生。
图8.2-虚函数:类结构,具有单个基类和多个派生类
假设有一个基类指针容器,指向可能位于不同内存位置的不同派生类实现。如果代码试图循环遍历这个容器并调用虚函数,就会导致大量缓存移除、缓存未命中和整体糟糕的运行时性能。这是在编译器无法展开循环和分支预测错误惩罚之上的。
图 8.3 显示了 vtable 如何通过为不同的虚拟函数设置不同的内存位置来影响性能。
图 8.3 – 指向随机内存位置的不同派生对象的基指针容器
由于我们看到使用虚函数可能会对性能产生不利影响,因此我们现在将讨论一种删除它们的方法:curiously recurring template pattern (CRTP)。
奇异递归模板模式(Curiously recurring template pattern,CRTP)
我们经常反对带有虚函数的CRTP方法。我们首先应该说明虚函数在运行时发现接口实现,而 CRTP 则不是这样。CRTP 是静态多态性的一个示例与虚函数对立,而虚函数是动态多态性的示例。CRTP 是一个编译时构造,这意味着它没有运行时开销。它与公开接口的基类和实现该接口的派生类一起使用。正如我们在本节中看到的,通过基类引用调用虚函数通常需要通过指向函数的指针进行调用,从而产生间接成本并阻止内联。总之,我们学习了如何消除虚拟功能可能引入的延迟。虚函数的使用需要非常小心。 CRTP 是一种通过选择静态多态性来避免使用虚函数的方法。
我们现在将介绍另一种可能导致运行时速度减慢的延迟类型。
运行时类型识别(Run Time Type Identification,RTTI)
前面关于虚函数的部分概述了在运行时解析对象和函数调用对性能的影响。该部分概述的大多数性能损失适用于在运行时解析的所有对象类型。在 C++ 中,运行时类型识别 (RTTI) 是用于描述在运行时检查对象类型以查找编译时类型未知对象功能的术语。
什么是RTTI?
C++ 的 RTTI 是一种在运行时需要时跟踪和提取有关对象类型信息的机制。这仅对具有至少一个虚函数的类有意义,这意味着基类指针有可能在运行时指向不同类型的派生类对象。因此 RTTI 允许您在运行时从可用的指针或基类类型的引用中动态地找到对象的类型。这是在将异常和异常处理添加到 C++ 时引入到 C++ 中的,因为了解对象的运行时类型对于异常处理至关重要。因此,RTTI 允许应用程序显式检查运行时类型,而不是依赖动态多态性,动态多态性隐式处理运行时类型解析。
C++ 提供了 dynamic_cast 内置运算符,用于在继承层次结构中安全地向下转换基类对象。当向下转换指针时,它在成功时返回转换类型的有效指针,在失败时返回 nullptr。dynamic_cast(base_ptr) 尝试将 base_ptr 的值转换为 Derived 类型。向下转换引用时,它会在成功时返回转换后类型的有效引用,并在失败时引发异常。我们将在本章稍后的“异常影响性能”部分介绍这一点。
另一个 C++ 内置运算符 typeid 用于获取对象的运行时信息并将其作为 std::type_info 对象返回。std::type_info 对象包含有关类型、类型名称、检查两个对象类型之间的相等性等信息。对于多态类型,typeid 运算符提供有关派生类型的附加信息。如果 base_ptr 指向 Derived 类型的对象,则 typeid(base_ptr) == typeid(Derived) 返回 true。
性能消耗
让我们讨论与C++ RTTI机制相关的性能消耗。
- 每个类和对象分配了一些额外的空间,这不是什么大问题,但如果有很多对象,就会成为一个问题,并导致缓存性能下降。
- typeid 调用可能会非常慢,因为它通常涉及获取不经常访问的类型信息。
- dynamic_cast 操作可能非常慢。它涉及获取类型信息和检查转换规则,这可能会导致本身非常昂贵的异常(我们将很快讨论)。
在接下来的章节让我们了解动态内存分配。
动态内存分配(20us)
堆中的分配(或动态分配)在编程中很常见。我们需要动态分配以便在运行时灵活分配。操作系统实现动态内存管理结构、算法和例程。所有动态分配的内存都进入主内存的堆部分。操作系统维护一些内存块链表,主要是用于跟踪连续的空闲/未分配内存块的空闲列表和用于跟踪已分配给应用程序的块的已分配列表。在新的内存分配请求(malloc()/new)上,它遍历空闲列表以找到足够空闲的块,然后更新空闲列表(通过删除该块)并将其添加到已分配的列表中,然后将内存块返回给该程序。在内存释放请求(free()/delete)上,它从分配列表中删除释放块并将其移回空闲列表。
运行时性能损失
让我们回顾一下与动态内存管理相关的性能损失,这使得它不适合在关键/热路径上使用,尤其是对于对延迟非常敏感的 HFT 应用程序。
堆跟踪开销
为动态内存分配/解除分配请求提供服务需要遍历空闲内存块列表,这不如使用已经可用的 CPU 寄存器或将其他变量压入堆栈有效。因此,堆跟踪机制增加了一些开销,并且延迟通常是不确定的,具体取决于空闲列表的内容、内存的碎片程度、请求的内存块大小等。总而言之,由堆内存管理创建的元数据可能会涉及到很多管理,并且执行许多操作只是为了释放该内存块。
堆碎片
在许多不同大小的分配和释放过程中,堆内存可能会变得碎片化,这意味着有许多小内存块之间有空洞,这使得空闲列表很长,并且会变得困难和耗时,并且在最坏的情况是,无法为大于任何空闲块的内存分配请求提供服务,即使不同空闲块之间有大量空闲内存可用。操作系统采用了一些堆碎片整理技术来管理这些潜在问题,但同样会以性能成本为代价。
缓存性能
动态分配的内存块通常可以随机分布在堆内存中。这可能导致显著的缓存性能下降、更高的缓存移除和缓存丢失等等。应用程序开发人员应该意识到这一点,并尝试以缓存友好的方式请求动态内存——通常是请求一大块连续的内存,然后管理该内存中的对象以提高缓存性能。
动态内存分配的替代方案/解决方案
并非 HFT 系统的所有部分都是时间关键的。因此,我们只需要关注时间关键的热路径上的动态分配和释放速度。大多数高性能动态内存分配技术归结为通过预先分配巨大的内存块或在应用程序本身(内存池)中管理它们来将动态内存分配从关键路径上移开。内存池基本上是一种数据结构,应用程序在启动时分配大量内存,然后在关键代码路径中管理这些内存的使用。这里的优势在于,这允许应用程序使用非常专业的分配和释放技术,最大限度地提高应用程序所针对的特定用例的性能。
另一种技术是彻底检查动态内存管理的使用并尽可能减少它们,通常以某些可能降低应用程序灵活性或通用性的假设为代价。我们还可以重新定义 C++ new 和 delete 运算符,尽管这不是推荐的方法——最好使用自定义的 new 和 delete 方法(例如 my_new() 和 my_delete() 方法)并显式调用它们。我们还可以讨论 placement new,它为我们提供了调用 new/delete 的大部分语义优势,但可以控制操作员放置对象的位置。缺点是您必须单独管理内存生命周期。
高效地使用constexpr(5us)
C++中的constexpr被用于让函数在编译期运行——不保证一定运行,但提供了这种可能。对于constexpr函数存在许多限制——它们一定不能使用static或thread_local变量,异常处理或 goto 语句,并且所有变量必须是字面量类型并且必须被初始化——简言之,编译器在编译时解析整个函数体所需的一切。
正如我们提到的,声明一个constexpr 函数并不意味着它必须在编译时运行。这只意味着该函数有可能在编译时运行。如果在常量表达式中使用 constexpr 函数,则必须在编译时执行——例如,如果将函数调用的结果分配给 constexpr 变量,则必须在编译时对其进行计算。
constexpr函数的好处与到目前为止讨论的相同。允许编译器在编译时解析和计算函数意味着没有计算该函数的运行时成本。
影响性能的exceptions(5us)
exceptions是现代 C++ 的错误处理机制,旨在改进 C 中传统的错误代码和基于 if/else 语句的错误处理。在本节中,我们将研究它们带来的优势、缺陷和性能损失,以及为什么这对于 HFT 应用来说不是最佳选择。
为什么用exceptions?
让我们讨论一下使用 C++ exceptions进行错误处理的原因和好处:
- 使用exceptions进行错误处理可以使源代码更简单、更清晰,并且可以更好地处理错误。对于长嵌套的 if-else 语句列表,它是一个更优雅的解决方案,因为这些语句会随着时间的推移而增长并导致面条式代码,需要针对每个场景进行测试,等等。总的来说,要求处理每个错误代码(和相关测试)会导致开发速度变慢。
- 有些代码在没有exceptions的情况下无法优雅或干净地完成。经典的例子是构造函数中的错误——既然它没有返回值,我们如何报告错误?优雅的解决方案是抛出异常,它作为现代 C++ 设计中资源获取即初始化 (RAII) 原则的基础。另一种方法是设置一个错误标志,每次在构造函数返回后创建对象时都需要检查该标志,这很丑陋,并且每次创建任何对象时都需要更多代码来检查。类似的想法甚至适用于常规函数,您必须在其中返回错误代码或设置全局变量。返回错误代码是有效的,但每次我们添加一个新的失败案例时,它都需要更新一堆位置的代码,并导致前面提到的 if-else 意大利面条代码。设置全局变量有其自身的一系列问题——函数返回后必须检查变量,对于不同的失败会采用不同的值,难以维护,并且在多线程应用程序中失败。
- exceptions很难被忽略,跟经常被不小心的应用开发人员忽略的错误返回值不一样。没有正确处理一个异常会导致程序终止。异常在方法边界上自动传播——也就是说,它们可以被捕获并重新抛出调用者堆栈。
我们现在从软件工程的角度知道为什么要使用exceptions。接下来,我们将解释它有什么性能影响。
缺点和性能损失
让我们讨论一些与使用 C++ 异常进行错误处理相关的复杂性、缺点和性能损失:
- 异常处理需要纪律和实践,特别是对于习惯于更传统的错误代码、if-else语句驱动类型的错误处理的开发人员。因此,与任何其他编程结构一样,它需要优秀的开发人员并在应用程序设计期间仔细考虑。
- 就性能而言,C++ 异常的好处是在不引发异常的路径上,没有额外的成本。然而,当抛出异常时,与函数调用相比,它的成本非常高,并且需要数千个 CPU 周期。 对于 HFT,如果应用程序经过精心设计,以便仅针对无论如何都无法安全继续正常运行的最罕见和最严重的错误引发异常,那么额外的性能损失就不是问题。然而,如果异常被轻视并作为算法正常运行的一部分引发,那么这可能会导致重大的性能问题,并且最初认为很少见的事情最终可能会非常频繁地执行,从而导致严重的性能下降。
为了进一步消除影响性能的运行时决策,我们现在将讨论模板,其目标是通过生成多个专用版本的代码来实际替换任何运行时决策。
减少运行时间的模板(5us)
在本节中,我们将继续讨论通过引入另一个重要的 C++ 功能来删除或最小化关键路径或热路径上的运行时决策。我们将讨论什么是模板、使用它们的动机、它们的优缺点以及它们与替代方案的性能对比。
什么是模版?
模版是用来实现通用函数和类的C++机制。泛型编程是指将泛型类型用作算法和类中的参数以与不同数据类型兼容。这消除了代码重复以及重复编写与数据类型无关的相似或共享代码的需要。模板不仅适用于不同的数据类型,而且根据编译时需要的不同类型,这些数据类型的类和方法的源代码在编译时自动生成,就像 C 宏一样。然而,与宏不同的是,编译器可以检查类型而不是像宏那样盲目替换。
有几种不同类型的模板:
- 函数模版:函数模板就像普通的 C++ 函数,除了有一个关键的区别。普通函数仅适用于函数内部定义的数据类型,而函数模板旨在使它们独立于数据类型,因此它们可以适用于任何数据类型。
- 类模版:类模板也类似于常规类,除了它们具有作为模板参数传递的一种或多种泛型类型的成员。这些类模版可以被用于存储和管理任意类型的数据。我们定义了一个可以处理大多数数据类型的通用类模板,而不是每次都为不同的类型创建一个新类。这有助于提高代码的可重用性、运行速度更快、效率更高。
- 可变模板:这是另一个重要的模板类型,适用于函数和类。它支持可变数量的参数,而不是仅支持固定数量参数的非可变参数模板。可变参数模板通常用于创建具有模板元编程功能的列表处理结构。
我们现在将讨论另一种与模板相关的高级技术,即模板特化。
模版特化(5us)
到目前为止,我们一直在讨论单个模板类或函数可以处理所有数据类型的想法。但也可以根据某些特定数据类型进行自定义行为,这称为模板特化。模板特化是我们可以为特定类型自定义函数、类和可变参数模板的机制。当编译器遇到具有特定数据类型的模板实例化时,它会为该类型或类型集创建一个模板实例。如果存在模板特化,则编译器通过将传入的参数与指定的数据类型进行匹配来使用该特化版本。如果它无法将其与模板特化相匹配,则它会使用非特化模板来创建实例。
为什么用模版?
让我们来讨论使用C++模版去降低运行时延迟的背后动机。
泛型编程
使用模板的主要优点显然是泛型编程和生成高效、可重用和可扩展的代码。使用模板的通用编程范例的一个特别好的实现是标准模板库 (STL)。这支持范围广泛的数据容器、算法、迭代器、仿函数等,它们是通用的并且可以对所有数据类型进行操作。
编译时替换
替换发生在编译时,只生成程序中需要的类或函数体——也就是说,只有在应用程序中使用了模板的数据类型才会在编译时生成这个模板类的实例。与运行时解析的对象或函数相比,在编译时知道参数也使模板类的类型安全性显着提高。
开发成本、时间和代码行 (LOC)
由于我们可以一次实现一个适用于所有数据类型的类或函数,因此它减少了开发工作量、时间和源代码的复杂性。它还使调试变得容易,因为代码更少,并且它包含在单个类或函数中。
优于 C 宏和 void 指针
C 使用预处理器宏和 void 指针来支持某种形式的泛型编程。但在每种情况下,模板都是更好的解决方案,因为它们明显更具可读性、类型安全且不易出错。宏也总是内联扩展,但是对于模板,编译器可以选择只在适当的时候内联扩展,这对于防止代码膨胀很有用。由于需要适应单个逻辑代码行,宏也很笨重且难以编写,但模板在其实现中显示为常规函数。
编译时多态
这是应用程序最重要部分之一,至少对于 HFT 应用程序而言(除了这里提到的所有其他内容之外)。我们详细讨论了虚函数和动态多态性如何显著降低性能。模板和它们提供的通用编译时多态性通常用于尽可能消除虚拟继承和动态多态性。通过将代码解析和构造转移到编译时而不是运行时,更重要的是允许编译器、CPU 和架构优化启动,性能得到显著提高。
模版元编程
通过将代码解析和构造转移到编译时而不是运行时,更重要的是允许编译器、CPU 和架构优化启动,性能得到显著提高。模板元编程使我们能够编写在编译时扩展的代码,以生成将在运行时使用的实际机器代码,本质上是使用模板预先计算可在以后引用的结果表。表达式模板是模板的另一种类似的高级用途,用于在编译时计算数学表达式以生成在运行时执行更高效的代码。
模版的坏处
现在让我们看看使用模板的一些劣势和缺点。
从历史上看,许多编译器对模板的支持很差,可能导致代码可移植性降低。此外,还不清楚编译器在检测到模板错误时应该做什么,这会增加使用模板时的开发时间。一些编译器仍然不支持模板嵌套。
模板仅包含头文件,这意味着所有代码都位于头文件中,而没有任何编译库中的代码。进行更改时,需要完全重建项目的所有部分。此外,没有办法隐藏代码实现信息,因为它都暴露在头文件中。
如前所述,模板完全位于头文件中,不能编译成库;它们在应用程序编译和链接过程中被链接。我们从编译库中获得的优势是,当进行更改时,只有受影响的组件需要重新构建。然而,模板并非如此,因此每次进行更改时,都必须重新构建所有模板化代码。随着应用程序复杂性的增加和模板使用量的增加,这可能会导致编译时间显著增加并成为一个问题。但是,这是可管理的并且不算问题。
模板让很多开发人员(包括高级 C++ 程序员)感到困惑,因为它们的使用规则很复杂。模板中的名称解析、模板专业化匹配和模板偏序等问题可能会让人难以理解和正确实施。一般来说,泛型编程是一种不同的编程范式,需要时间、精力和实践来习惯——如果你习惯了 C++ 中的命令式编程(这是 90% 的 C++ 程序员经常使用的),它就不会自然而然地出现。总的来说,模板有很多优势,包括开发和调试速度,但要达到这一点需要一段时间,因为要正确理解模板有相当长的学习曲线。
调试具有大量模板的代码可能很困难。由于编译器用替换的实现替换了模板实例化和调用,因此调试器很难在运行时找到实际代码。这在本质上类似于在运行时很难调试内联方法,因为源代码与调试器看到的内容不完全匹配。错误消息非常冗长,理解起来非常混乱且耗时。即使是大多数现代编译器也会产生大量、无用且令人困惑的错误消息。
模板在源代码级别展开并编译到源代码中。编译器为每个模板类型或实例生成额外的代码。如果我们有很多模板化的类和函数或者很多不同的数据类型来生成实例,编译器生成的代码会变得非常大。这被称为代码膨胀,这也会增加编译时间。过度模板化代码库损害运行时性能更微妙的问题是,由于应用程序本身的大小如此之大,它的缓存性能可能很差,因为缓存逐出、未命中等的可能性更大。
从根本上说,模板的运行时性能尽可能高效和低延迟,因为它从运行时对象解析和函数调用转移到编译时解析。如前所述,这打开了编译器优化机会的世界,例如内联(以及其他许多),并且在执行时,可以更好地与 CPU 和架构优化(例如预取和分支预测)一起工作,从而产生特别出色的性能。 避免在同一个类声明中使用 template 和 virtual 关键字很重要。第一次使用类模板时,它会创建所有成员函数的副本(应用于该新类型)。拥有虚函数意味着vtable 和 RTTI 也会被复制,从而导致额外的代码膨胀(在模板已经导致的之上)。
标准模版库(STL)
让我们探索 C++ STL,它在最近的 C++ 应用程序中变得相当普遍。还有一些变体和库的操作类似于 STL,但改进了一些问题并添加了一些功能。
什么是STL?
STL 是一个使用非常广泛的库,它为适用于所有数据类型的模板提供容器和算法。STL 是模板类的存储库,这些模板类实现了常用的算法和数据结构,并且可以与用户定义的类型以及内置类型很好地配合使用,并且它的算法是容器独立的。它们被实现为编译时多态性,而不是运行时多态性。
常用容器
让我们探索最流行和最常用的 C++ STL 容器及其使用时间:
- vector:这是许多应用程序的默认首选,具有最简单的数据结构(C 数组样式的连续内存布局),并提供随机访问。
- deque:deque 实现了双向链表,在从头或尾插入和删除元素时,性能优于 vector。deque 在内存方面也很高效,通常只根据元素的数量使用所需的内存。访问随机元素较慢,因为它涉及遍历列表并遍历潜在的随机内存位置(缓存性能差)。
- list:list 与 deque 类似,它也是作为链表实现的,并且具有与 deque 类似的优点和缺点。这里的区别在于,与 vector 和 deque 不同,list 在添加或删除元素时不会使引用元素的迭代器失效。
- set, unordered_set, 和 multiset:这些是关联容器,用于跟踪元素是否存在于容器中。关联容器基本上是专门设计用于支持快速轻松地存储和检索由键引用的值的容器。unordered_set 在元素上使用散列摊销在常数时间内执行查找但没有排序。摊销常数时间查找意味着在正常情况下,无论容器大小如何,执行操作都需要常数时间。有序的 set 和 multiset 具有已排序的键。multiset 和 set 是相同的,除了前者允许保存具有相同值的多个元素。
- unordered_map 和 map, unordered_multimap 和 multimap:这些也是上一点中讨论的关联容器,除了它们跟踪键值对。unordered_map 和 map 保存的是单个键值对,区别在于前者键没有排序,后者是键值排序。unordered_multimap 和 multimap 分别类似于 unordered_map 和 map,除了它们允许每个键有多个值。
运行时性能
让我们看看STL在运行时的表现:
- STL 作为一个模板化库,与 C 风格的解决方案或基于动态多态性的解决方案相比,具有特别好的运行时性能。从 STL 中榨取性能的另一种方法是优化用户定义的结构,使其在 HFT 应用程序的上下文中完全按照我们的需要工作。
- 有效地使用 STL 构建低延迟 HFT 应用程序需要开发人员正确理解 STL 的工作原理并仔细设计程序。开发人员经常滥用 STL,并且在不了解所涉及的计算复杂性的情况下,将责任归咎于库。
- STL 的另一个问题是许多对 STL 库函数的调用在内部分配内存,如果不小心构建分配器并将其传递给 STL 容器,可能会导致不确定的性能,特别是对于 HFT 应用程序(如果它们使用默认的动态内存分配器)。
在本节中,我们研究了有助于减少延迟的数据结构,因为它们已经针对性能进行了优化。在下一节中,我们将学习如何通过进行静态分析来提高性能。
静态分析
在本节中,我们将了解静态分析的开发和测试技术。这是一组有助于软件开发/测试/维护生命周期的工具和技术。它一般适用于所有软件应用程序开发过程,但尤其适用于高频交易应用程序,其中快速进行更改非常重要(适应不断变化的市场条件和低效率是盈利的关键),但要非常小心,不能破坏现有的预期功能(bug /错误可能导致重大的金钱损失)。
什么是C++静态分析?
静态代码分析意味着通过检查代码和使用工具自动检测错误来调试软件应用程序,而无需实际执行应用程序或提供输入。这也可以被认为是检查代码并尝试检查代码结构和编码标准的代码审查式调试过程。拥有可以执行此操作的自动化工具和流程意味着我们可以在验证代码时比开发团队更彻底地检查漏洞。用于分析源代码并自动吐出错误和警告的算法和技术在本质上与编译器警告类似,只是向前走了几步以发现运行时动态测试会揭示的问题。静态分析工具已经取得了很大的进步,从基本的语法检查器到可以发现细微错误的东西。
静态分析旨在发现软件开发问题,例如编程错误、编码指南违规、语法违规、缓冲区溢出类型问题和安全漏洞等。
现在来解释为什么我们需要静态分析。
为什么需要静态分析?
静态分析背后的动机是发现之前解释过的错误和问题,而动态分析(单元测试/测试环境/模拟,旨在在程序执行时发现错误)找不到。因此,当系统遇到动态测试期间未遇到的数据和场景时,静态分析可以发现可能导致重大问题的问题,从而引发故障(可能是巨大的故障)。请注意,静态分析只是执行软件质量控制的大量工具和实践中的第一步。
除了静态分析之外,动态分析还依赖于设置足够的测试场景并提供足够的输入和数据以覆盖应用程序的所有用例。一些编码错误可能不会在动态分析期间出现(因为我们在编写或执行单元测试时没有考虑到它们)。这些是动态分析会遗漏的缺陷,希望静态分析能找到它们。
静态分析的类型
根据要查找的错误对静态分析类型进行分类的一种方法如下:
- 控制流分析:这里重点介绍进程、线程、方法、函数、子程序、指令等调用结构中的调用者与被调用者的关系和控制流。
- 数据流分析:这里,重点是输入、中间和输出数据——结构、类型验证以及正确和预期的操作。
- 错误分析:这试图了解不同组件(不属于前两类)中的故障和错误。
- 接口分析:这旨在确保组件适合整个管道——它们全面且正确地实现接口。在高频交易中,这意味着交易策略流程已正确实施,并具有在模拟和实时交易中正确和最佳操作所需的所有接口。
另一种分解静态分析类型的方法如下:
- Formal(正式的):这里的问题是,代码是否正确?
- Cosmetic(化妆品):这里的问题是,代码看起来是否一致?它是否满足编码标准要求?
- Design(设计):这里的问题是,根据既定的公司范围标准,是否正确设计了组件(例如类结构、方法大小和组织)?
- Error checking(错误检查):这是不言自明的,重点是故障、错误和代码违规。
- Predictive(可预测的):这更高级,但目标是预测应用程序在执行时的行为,为动态分析做准备。
在下一节中,我们将介绍静态分析。
静态分析的步骤
静态分析的目标是使其自动化,以便在应用于大型代码库时变得简单、快速和彻底。因此,该过程本身需要足够简单和一定的算法才能实现自动化。从开发人员的角度来看,一旦源代码准备就绪或半准备就绪,静态代码分析器就会运行代码并标记编译问题、编码标准问题、代码或数据流错误、设计警告等。误报很常见,因此(由开发人员)手动分析静态代码分析器的输出,一旦所有真正的问题得到解决,它就会再次通过静态代码分析器运行,然后进入动态分析阶段。
静态分析远非完美——它也会产生误报和遗漏问题——但它是一个很好的正交调试和故障排除工具,可以节省开发人员和代码审查人员的时间,从而产生更高效的工作环境。
静态分析的优缺点
让我们看看静态分析的优点和缺点。之前已经讨论过好处,所以我们简单地将它们形式化并在本节中列出。您也可能猜到一些缺点可能是什么,但我们也将在这里正式讨论它们。
优点
我们列出了静态分析的好处:
- 规范统一的代码:静态分析器工具最初是 linters,因此它们非常擅长在新代码不符合编码指南和设计标准时进行标记。这产生了一个统一的代码库,符合既定的(公司范围或行业范围)编码标准和设计模式。
- 速度:对于整个开发团队而言,手动代码审查非常耗时。自动静态分析可以帮助在代码进入代码审查之前消除很多问题。此外,它会及早发现这些问题,因为错误总是更容易更快地修复。总体而言,这会提高整个团队在整个软件开发生命周期中的开发、审查和维护速度。
- 深度:我们之前已经提到过这一点,构建单元测试或运行动态分析以使其涵盖所有边缘情况和所有代码执行路径很明显是完全不可能的。静态代码分析器在这些情况下做得更好,因为它们可以检查重要或深层的bugs和错误。
- 准确性:使用自动静态分析方法的另一个明显优势是,它在手动代码审查和动态分析无法做到的地方非常准确。准确性有助于彻底性和质量。
- 离线:在大多数真实世界的应用程序(尤其是 HFT 应用程序)中,有许多移动部件,因此在模拟、测试或实验室环境中进行动态分析需要大量设置和资源。对于 HFT,这意味着不同的进程和组件(提要处理程序、订单网关、模拟交易所和记录器),以及网络、IPC 和磁盘资源。这可能是痛苦的、昂贵的和耗时的。另一方面,静态代码分析是在完全没有这些移动部分的情况下离线执行的,因此它简单、实惠且快速。
我们详细描述了静态分析的好处是什么,所以现在我们来谈谈它的弱点。
缺点
静态分析的缺点如下:
int area(int l, int w) {
return l + w;
} 这里的静态分析器可以检测到对于 l 和 w int 值的某种组合,它们的总和将产生溢出,但它不能确定该函数计算面积的错误。
- 编码规则很复杂: 许多编码规则对于静态分析器来说过于复杂,无法静态地执行或检测。它们可能依赖于外部文档,是主观的,取决于公司或应用程序,等等。
- 误报:可能会出现误报,浪费开发人员的时间。
- 静态分析不是免费的或即时的:与编译过程一样,对整个应用程序运行静态分析器也需要时间。代码库越大越复杂,运行时间就越长。
- 静态分析不能取代动态分析:尽管具有实用性,但静态分析器永远无法保证应用程序执行时会发生什么,因此静态分析可以补充但永远不会取代动态分析、单元测试、模拟或测试台。
- 系统和三方库:这些通常无法通过静态分析器分析,因为源可能不容易获得或访问。
我们已经看到了静态分析的优点和缺点,所以我们现在将考虑用于执行这种分析的工具。
静态分析的工具
让我们快速介绍一些可用于 C 和 C++ 的最佳和众所周知的静态代码分析器:
- Perforce的Klocwork:Klocwork是最好的 C++ 静态代码分析器之一。它适用于大型代码库,拥有大量检查器,允许定制检查器,支持差异分析(以帮助分析大型代码库中只有少量代码发生变化时的时候),并与许多 IDE 集成和 CI/CD 工具。
- Cppcheck:Cppcheck 是一个免费、开源、跨平台的 C 静态代码分析器 和 C++。
- CoderGears的CppDepend:CppDepend 是一个商业 C++ 静态代码分析器。它的优势在于分析和可视化代码库架构(依赖项、控制和数据流层)。它具有依赖图功能和监控功能,可以报告构建之间的差异。它还支持规则检查器自定义。
- Parasoft:Parasoft 拥有一套用于 C 和 C++ 的商业测试工具,具有静态代码分析器并支持动态代码分析、单元测试、代码覆盖率和运行时分析。它有大量丰富的静态代码分析技术和规则。它还允许您以有组织的方式管理分析结果,从而为软件开发过程提供一整套工具。
- PVS Studio:这是另一个支持多种编程语言的商业工具,包括 C 和 C++。它检测重要的错误,与流行的 CI 工具集成,并且有详细的文档记录。
- Clang Static Analyzer:Clang C 和 C++ 编译器附带一个静态分析器,可用于使用路径敏感分析来查找错误。
找到适用于 HFT 系统所有方面的单一配方是不可能的。学习如何分析性能和运行静态分析可以帮助您避免我们在使用 C++ 编码时可能犯的最大错误。通过消除我们的代码可能存在的最大问题,我们可以专注于对性能至关重要的事情。
通过结合静态分析和我们谈到的运行时优化,例如使用适当的内存模型和减少函数调用的数量,我们将达到 HFT 系统可接受的性能水平。
用例 - 构建外汇高频交易系统
公司需要一个能够在 20 微秒内发送订单的 HFT 系统。为此,公司可以采用以下方法:
- 在多核架构上选择多进程架构。 (个人理解:这里在我看来采用多线程架构就可以,下一个可以将重要线程都固定到特定的核心上)
- 确保每个进程都固定到特定的核心以减少上下文切换。
- 让进程通过共享内存中的循环缓冲区(无锁数据结构)进行通信。 (个人理解:尽可能采用无锁数据结构,和采用CAS机制避免并发问题)
- 使用 Solarflare OpenOnload 设计网络堆栈以实现网络加速。
- 增加页面大小以减少 TLB 高速缓存未命中的次数。
- 禁用超线程以更好地控制进程的并发执行。
- 使用 CRTP 减少虚拟功能的数量。
- 使用模板化数据结构移除运行时决策。
- 预分配数据结构以避免在关键路径上进行任何分配。
- 发送虚假订单以保持高速缓存并允许订单在最后一刻发出。
- (个人理解:尽可能的使用constexpr)
在任何交易系统中,订单数量远低于接收到的市场数据量。从获取市场数据到发送订单的关键路径很少执行。缓存将被非关键路径数据和指令取代。这就是为什么运行虚拟路径以通过整个系统发送命令以保持数据缓存和指令缓存准备就绪非常重要的原因。这也将使分支预测器保持热。
所有这些优化的主要思想是减少昂贵操作的数量。删除函数调用、使用无锁数据结构和减少上下文切换次数是该策略的一部分。
此外,在运行时做出的任何决定都是代价高昂的。这就是为什么模板函数和内联将成为任何 HFT 系统中通用代码的一部分的原因。成本最高的操作是那些涉及网络通信的操作。使用Solarflare 等端到端内核旁路可以优化交易系统内的网络延迟。通过使用这些优化,该公司可以实现 20 微秒的即时报价。延迟分布对于测量非常重要。我们需要确保 20 微秒是延迟上限。我们永远不应该考虑平均值,因为很难用这个值来评估高延迟。
在高频交易中,一些策略在大量交易发生时非常有利可图。如果交易系统在大多数时间都按预期运行,则无法保证系统始终运行良好。当交易系统接收到大量数据时,如果构建不当,最大延迟可能是平均延迟的 10 倍以上。我们应该记住,如果我们没有在生产中自己进行测量,那么任何优化都不能保证更快。
总结
在本章中,我们介绍了许多现代 C++ 14、17 和 20 的特性,这些特性适用于处理共享内存交互的多线程和超低延迟应用程序。我们还介绍了应用程序开发的静态分析。最后,我们讨论了运行时性能优化技术,这些技术将尽可能多的决策、代码评估和代码分支从运行时转移到编译时。我们还看到了如何为专门从事外汇的小型对冲基金构建高频交易系统。在下一章中,我们将了解 Java 在 HFT 系统等超低延迟系统中的用法。
第11章 高频FPGA和加密
欢迎阅读本书的最后一章。在前面的章节中,我们看到了如何优化传统交易以获得高频交易 (HFT) 系统,该系统的交易延迟为 5 微秒。在下一节中,我们将讨论如何使用高级硬件优化将延迟时间缩短至 500 纳秒。最后,我们将通过探索传统交易和加密货币交易之间的区别来结束本书。
本章的目标是展示我们在过去章节中使用的软件解决方案在实现低于 1 微秒的延迟方面存在局限性。使用特定的硬件,我们将向您展示这是可能的。第二个目标是将我们在本书中解释的优化应用于加密货币。我们将通过将设计扩展到云端来进行详细说明。
在本章中,我们将讨论以下主题:
- 现场可编程门阵列 (FPGA) 硬件如何执行 HFT 以减少延迟
- 使用HTF技术如何交易加密货币
- 如何在云端搭建交易系统
为了指导您了解所有优化,我们将优化分为3个级别,优化指延迟低于指定时间,指定时间分别是20us,5us,500ns,这个优化级别我们将在标题中进行说明。 用FPGA降低延迟(500ns)
在本节中,我们将着眼于现场可编程门阵列 (FPGA),研究 HFT 中激烈的速度竞争的演变,然后讨论在现代 HFT 中使用 FPGA 的动机。我们还将探讨 FPGA 本身的工作原理、基于 FPGA 的交易系统的设计,以及在 HFT 系统中使用 FPGA 的优缺点。
高频交易中速度的激烈竞争演变
正如我们在本书中看到的那样,高频交易受到了广泛关注,变得极其流行,并且还成长为所有金融市场流动性和交易的重要组成部分。我们还看到,高频交易(顾名思义)完全与速度/延迟有关——高频交易系统和算法分析市场数据信息、发送订单请求和执行交易的速度。
总而言之,延迟是指数据包从一个点传输到另一个点的总时间。但是,特别是在交易中,延迟是指从市场参与者收到市场更新到他们可以将订单发送到交易所所花费的时间(以纳秒/微秒/毫秒为单位)。改进技术是在价格变动之前以最佳价格赢得交易的关键,或者在某些其他市场参与者以成本价对订单进行交易之前,或者在该价格的订单被取消之前。这对所有市场参与者都是如此——手动交易者、做市商、统计套利交易者或高频交易者。
对于高频交易,由于速度是关键因素,参与者的系统在处理和交易执行方面变得越快,他们获得的利润就越高。因此,该领域存在无休止的激烈竞争,竞争对手不断投入大量资金,分配给更强大、更快速的交易解决方案,以接近在几纳秒内交易证券/衍生品/股票/金融工具的能力。除了通过提高高频交易系统的速度来增加利润之外,因未能跟上持续的技术创新而落后的公司将无法竞争,因此可能会倒闭。
为了跟上步伐,投资银行、对冲基金和高频交易公司花费大量资金购买更快的软件、最靠近交易所的托管设置以及延迟最低的网络——我们在第 7 章高频交易优化——日志记录中看到了这些,性能和网络。参与者在优化硬件本身时购买最快的服务器、处理器、内存和网卡。尽管如此,这还不够——现在,他们需要投资于基于硬件加速的解决方案。硬件加速的方法是将交易系统的计算/CPU 密集型部分卸载到自定义处理器、图形处理单元 (GPU) 或 FPGA。有许多解决方案可以扩展硬件计算性能。但是,最后,就 HFT 系统和算法而言,FPGA 正在推动技术革命。 FPGA 具有令人兴奋的特定特性,使它们能够以比最高度优化的软件解决方案更快的速度 (1000 倍) 执行相对简单的交易策略/算法。
FPGA的介绍
在本节中,我们将介绍 FPGA,然后研究 FPGA 组件的详细信息及其特性。FPGA 是一种硬件(在本例中为芯片),可以针对任何需要的目的进行编程(尽管并不容易)。它还可以根据需要重新编程,并且如前所述,具有超低延迟、高性能和能效的优势。
了解 FPGA 的适用范围;您可以考虑处理器/芯片的性能范围。我们的一端有中央处理器 (CPU),它提供
通用且灵活的指令集。可用的指令可以以许多不同的方式组合以执行几乎任何任务,使它们成为通用的。但是这种灵活性会导致执行这些任务时性能缓慢/不佳,因为它不是专门的;也就是说,必须执行几条指令才能完成任何任务。 另一方面,我们有专用集成电路 (ASIC),它们速度极快,因为它们是为特定/单一任务定制构建的,但一旦构建就无法更改。他们还花费了大量的时间和金钱来开发。例如,芯片用于挖掘比特币。
在此性能范围内,FPGA 介于 CPU 和 ASIC 之间。
什么是FPGA?
FPGA 只不过是一个包含数千个、有时数百万个核心逻辑块 (CLB) 的芯片——CLB 是 Xilinx 的术语。可以与笔记本电脑和智能手机等中的微处理器进行比较,它们由数百万个称为查找表 (LUT) 的逻辑块组成。这些包含布尔值
逻辑运算,例如 AND、OR、NAND 和 XOR,也称为门。此外,LUT 通过充当减少为单个输出的 n 输入函数来工作。这意味着 Xilinx 的 UltraScale CLB 具有六输入 LUT 架构(尽管这可以重新配置为两个五输入 LUT,每个 LUT 具有独特的输出)。因此,您可以在 LUT 中实现一系列布尔运算(即,一个六输入函数可以简化为一个 LUT)。
在 FPGA 中,可以配置和组合 CLB 来处理任务,但与 CPU 相比,它们不会因额外的硬件而陷入困境以减慢速度。 FPGA 不擅长控制流繁重的操作,但非常适合数据流应用程序。CPU 遇到的问题是 FPGA 的运行方式本质上是并行的(您同时并行评估许多 n 输入函数以获得相同的结果)。不过,就性能而言,它们并不是无限的。如果您的逻辑功能变得太复杂并且您的数据总线变得太宽(因此,为了降低时钟速率,您可以关闭设计的合成),则会有很大的损失。
FPGA 不运行代码,而是实现逻辑电路。 FPGA 让您可以直接在硬件中实现特定于应用程序的功能,速度比 ASIC 慢,但可能比在 CPU 上执行单个代码路径来做同样的事情更快。FPGA 可用于执行极其具体的任务并以极快的速度执行它们,还可以同时并行执行任务。交易策略等算法直接在 FPGA 上实现。
1985年发明第一台FPGA;芯片容量很小,很难在上面实现逻辑。现代 FPGA 具有数百万个门数,可以适应非常复杂和大规模的设计。主要供应商包括制造 Xilinx 的 AMD 、设计 Altera 的 Intel 和 Achronix 等。
FPGA的特点
在本节中,我们将讨论 FPGA 的重要特性,这将帮助您理解并在下一节中明确说明为什么高频交易公司从使用 FPGA 中获益匪浅。
我们已经在上一节中讨论过,FPGA 包含与可配置开关和灵活结构相连的逻辑块/CLB/LUT。这使得它们相对容易编程(和重新编程)并且能够支持复杂的交易算法。
现代 FPGA 配备了数百万个 CLB,可产生巨大的容量。这些算法不仅可能极其复杂,而且还能够极大地扩展。但是,容量是以物理特性为代价的。信号需要在芯片上传播。从概念上讲,处理器芯片就是 CPU 处理器所在的硅半导体材料。随着硅芯片变大,信号传播的时间也变大。容量很大但不是无限的。相比之下,CPU 有些特点例如物理特性是你的敌人;实施 FPGA 需要一些电气工程知识和逻辑设计才能成功。
与 CPU 不同,FPGA 没有固定的处理器架构——这意味着没有操作系统 (OS) 开销和中断等。在 FPGA 上,处理路径是并行的,因此不同的功能/操作不会竞争资源,而是可以并行运行。在现代 FPGA 上,单个芯片可以有 10 多个代码路径同时以不同的速率运行。
FPGA 上的这种并行架构是它能够以最大容量和速度执行买卖交易的关键。这里需要注意的是,算法或数学计算需要分解为一组任务。只有这样,它们才能在不同的周期内并行处理。
并行性还使 FPGA 非常灵活,能够提供高水平的服务。 FPGA 稳定且独立,使整个 HFT 基础设施运行顺畅,并使基础设施能够适应非 FPGA 硬件和软件的变化。
正如第 4 章 HFT 系统基础——从硬件到操作系统中所解释的,在 CPU 上执行指令时,可能存在不确定性因素(乱序处理器)。此外,操作系统和事件驱动的中断会导致许多控制路径,从而导致大量随机性。 CPU 非常适合随时间演变/变化的通用任务,但不适合保证性能指标。
借助 FPGA,我们可以实施硬件算法,从而实现高度的确定性。因此,当市场活动突然爆发并且网络数据过载时,FPGA 可以非常快速地处理和提供市场数据,并且方差很小。 FPGA 每次都会经历相同的状态序列,提供可重复和可预测的延迟/性能。
我们讨论了 FPGA 的特性;我们现在将讨论 HFT 系统如何利用 FPGA。
深入研究 FPGA 交易系统
FPGA 交易系统是利用低延迟、高频和算法交易策略的系统。这些系统需要在几纳秒内执行许多不同的任务。以下小节描述了此类系统需要执行的一些任务。
FPGA 的首要任务是分析来自多个证券交易所的市场数据。这涉及构建网络协议栈和寻址组件,例如以太网层、互联网协议 (IP) 层、用户数据报协议 (UDP) 层和市场数据协议。
由于网络输入/输出 (I/O) 在 FPGA 设置的内核和操作系统层处理,因此在 FPGA 上构建网络堆栈通常是优化交易系统的第一步。这意味着处理以太网层、IP 层、UDP 层,以及在某种程度上处理传输控制协议 (TCP) 层。还有机会使用混合堆栈——有一个在主机上运行的 TCP 堆栈来处理奇怪的情况,而 FPGA 可以接收 TCP 市场数据并对其进行解码,直到这些奇怪的状态之一发生(这种情况很少见)。直接在 FPGA 中使用 TCP 是可行的,但很困难,而且通常是一种不优雅的解决方案。
出于冗余和公平的原因,大多数交易机构通过多个渠道发布市场数据。使用市场数据的 FPGA 馈送处理程序需要处理 A/B 馈送仲裁(在 HFT 中,很常见的是有两个通信通道,A 和 B,用于接收市场数据时的冗余)并处理冗余馈送。相反,交易机构或市场数据提供商也需要使用类似的 FPGA 技术来处理和分发大量的市场数据更新。
FPGA 馈送处理程序需要处理不同的市场数据协议——FIX Adapted for STreaming (FAST) 协议是一个非常常见的例子。甚至市场数据更新语义也可能因交易所而异,而 FPGA 市场数据解析器和规范器需要以最佳方式处理这些问题。
在处理从交易所收到的市场数据后,下一步就是为算法计算不同的交易信号以寻找交易机会。这些交易信号会发现价格和/或订单中的错误定价,以查看哪些地方存在一些有利可图的交易可能性。
在根据新的市场更新更新交易信号/模型后,我们使用输出通过尽快向市场发送订单来利用稍纵即逝的交易机会。算法必须具有极高的性能才能击败其他订单,也就是说,在其他订单抢先获得相同机会之前到达交易所。
FPGA 交易系统需要易于定制,并且能够在处理实时更新时进行优化和测试。另一个考虑因素是将交易算法推向尽可能靠近网络接口卡 (NIC) 的位置,并最大限度地减少系统延迟。在第一部分中,我们介绍了 FPGA、它们的特性以及如何在 HFT 系统中使用它们。我们现在将讨论使用 FPGA 构建的交易系统的优势。
FPGA交易系统的优势
本节将研究高频交易公司从使用 FPGA 中获得的优势。在第 1 章高频交易系统的基础知识中,我们解释了使用高频交易系统的优势。 FPGA 有助于使 HFT 系统更快。本节将总结在交易系统中使用 FPGA 的主要优势。
使用 FPGA 的 HFT 算法可以通过先于其他人执行订单的价格来击败其他参与者。 FPGA 算法可以在其他参与者有机会做出反应之前发现机会并采取行动,以最佳价格执行会立即增加利润。FPGA 的可重新编程特性意味着您可以快速更改算法的行为和交易参数并在竞争中保持领先地位。 FPGA 交易系统提供的可扩展性导致能够同时在许多工具上执行多项交易,从而带来更高的收入。基于 FPGA 的交易系统还允许高频交易公司创建更安全的交易环境、节省开支,从而进一步增加收入。
在过去几年中,交易操作和高频交易格外受到越来越严格的监管和风险管理。因此,高频交易公司被迫寻找新的解决方案来监控交易活动并检测/处理潜在损失。传统的 CPU 驱动系统通常具有查看/计算投资组合风险而不是实时的限制,因此无法提供所需的安全级别。 FPGA 允许对投资组合进行近实时的风险评估,并允许公司满足监管机构要求的严格风险/监管要求。
依靠 FPGA 意味着 HFT 企业可以减少与计算机系统维护相关的费用。一个设计良好的 FPGA 可以取代许多通用 CPU,并通过节省能源成本、办公室租金和系统冷却费用来帮助降低公司开支。可重新编程还意味着可以在安装后修改设备以满足不断变化的要求,从而节省更多与维护相关的成本。
FPGA交易系统的劣势
我们已经讨论了拥有 FPGA 给高频交易公司带来的优势;现在,本节将介绍 FPGA 系统有哪些缺点。
FPGA芯片价格昂贵;它们有时可能比传统服务器贵得多。在运行许多对性能不敏感的进程时,传统服务器也是一种更具成本效益的解决方案。无论如何,FPGA 往往托管在普通服务器内,因此 FPGA 不会取代传统服务器。因此,根据应用的不同,如果系统没有经过深思熟虑和设计,FPGA 芯片可能会导致许多硬件成本。
FPGA 芯片比传统 CPU 更难编写算法。 Verilog 通常比 C、C++、Java 等传统语言更难开发、调试和排除算法故障。与其他语言相比,FPGA 可用的工具和 API 也少得多且非常有限。由于芯片上有限的日志记录能力和复杂的决策路径,调试 FPGA 算法也比调试传统算法困难得多。
此外,传统软件开发语言有很多有能力的开发人员,但由于可用性较低且成本较高,因此很难找到 FPGA 开发人员。正在不断努力使开发过程本身更容易和更快。新工具不断涌现,例如将算法转换为门级设计的高级综合工具。它们有局限性,但可以支持快速上市或至少原型制作。现在市场上有很多这样的工具,而一些流行的例子是 Simulink(由 Matlab 提供)、ISE、Vivado、Vitis、ChipScope(由 Xilinx 提供)和 Altera Quartus(由 Intel 提供;Altera 是一家公司被英特尔收购)。
通常,FPGA 驱动的 HFT 系统和交易算法可降低因软件组件故障而导致的风险。但设计或测试不当的 FPGA 可能会产生相反的影响并增加风险。这可能来自 FPGA 系统中的错误,这些错误仅在特定市场条件和/或交易参数下触发,从而导致不正确的 FPGA 行为。FPGA 系统的另一个因素是它们的反应和订单执行速度极快,这意味着在传统风险系统检测到问题并将其关闭之前,疯狂的 FPGA 系统问题可能会造成重大损失。在所有这些情况下,公司都会遭受巨大的交易损失、监管和合规行动/罚款,甚至可能因巨额损失而破产。
正如什么是 FPGA?部分,FPGA 最适合设计算法,最大限度地提高 FPGA 的并行架构和确定性。此外,为 FPGA 构建和调试算法是一个昂贵、耗时且复杂的过程。这两个因素都意味着限制为 FPGA 构建交易策略/算法以最大化其优势的复杂程度。因此,在 FPGA 上实现具有大量复杂统计信号、机器学习组件和执行行为的非常困难的算法是不现实/不切实际的。
关于 FPGA 的结语
自 2000 年以来,高频交易领域对速度的激烈竞争一直是一个持续现象,而且不太可能很快结束。高频交易公司将需要继续采用新兴技术创新以保持其在市场中的优势,否则可能会导致损失和灭绝。
我们在本节中讨论的 FPGA 是这些最近出现的技术的一个例子。并行架构和确定性特性使 FPGA 能够在处理市场数据和订单执行时提供尽可能低的延迟。使用 FPGA 将 HFT 引擎加速至纳秒级可带来许多商业利益,例如增加贸易量和提高利润。
在第 6 章 HFT 优化 —— 架构和操作系统和第 7 章 HFT 优化 —— 日志记录、性能和网络中,我们宣布了优化 HFT 硬件和软件的主要指南。我们在所有其他章节中看到了如何将这些优化具体应用到 C++、Java 和 Python 中。 FPGA 完成了本书使交易系统更快的探索。我们现在将在性能方面讨论相反的方面:加密货币的高频交易。
用加密货币探索高频交易
待续。。。 |
|