1 В избранное 0 Ответвления 0

OSCHINA-MIRROR/mirrors_Tencent-flare

Присоединиться к Gitlife
Откройте для себя и примите участие в публичных проектах с открытым исходным кодом с участием более 10 миллионов разработчиков. Приватные репозитории также полностью бесплатны :)
Присоединиться бесплатно
Клонировать/Скачать
performance-guide.md 14 КБ
Копировать Редактировать Web IDE Исходные данные Просмотреть построчно История
Luo Bo Отправлено 26.05.2021 09:11 9cc00d3

性能指引

这篇文档罗列了一些常见的需要注意的性能相关的点。这篇文档的目标群体主要为flare的开发人员。

一些独立列出的问题

  • 调度组:我们通过分组的设计提高系统的伸缩性。这一设计单独描述于这篇文档

    简单来说,引入被多线程共享的数据时,需要优先考虑能否按照调度组进行分组共享。

  • 断言/日志:应当首先考虑使用我们提供的版本,而不是glog相同功能的宏。

  • 对象池:对于构造析构成本高或频繁在线程之间迁移等通用目的的内存分配器不能很好的解决的场景,我们通过对象池进行优化。我们的对象池单独描述于这篇文档

    对于框架而言,大多数情况下“NUMA节点内共享”是一个比较适合的对象池实现。

  • 时间戳:取决于具体场景的需要,我们可能需要视情况选择不同的时间戳。具体的决策逻辑参见这篇文档

  • Hazard pointer:对于读多写少的场景,可以考虑使用Hazard pointer

原子变量

原子变量本身不是伸缩性瓶颈。

不含内存屏障(对于C++,即std::memory_order_relaxed方式进行的)读或写(但RMW除外)的成本通常与普通变量无异。

取决于具体ISA,原子变量的RMW成本可能出现在以下两方面:

  • 其暗含的内存屏障。取决于ISA,对于x86-64而言,原子变量的RMW均暗含完整的内存屏障。这会导致CPI升高,但是其本身并不是一个伸缩性的问题。比如每个线程自增一个不同的原子变量,在不存在false sharing的前提下这是可以线性伸缩的。

    特别的,在x86-64下,暗含内存屏障的原子操作往往快于mfence但是不能作用于non-temporal的内存操作)。尽管我们非常不建议在C++中使用非std::atomic<T>系的方式添加内存屏障,但是必要的情况下这可以用于优化需要手动增加内存屏障的特殊场景。Linux内核中也有类似优化

  • 核间通信(通常体现为缓存连贯性消息)成本。对于共享的原子变量被多核同时或先后使用,会导致各个核之间频繁通信,也是原子操作的伸缩性问题的主要所在。

核间通信的成本取决于物理拓扑,相同物理CPU节点内的通信成本通常明显低于不同物理CPU节点间的通信成本。比如对于多路Xeon,后者通常意味着需要通过QPI/UPI传输消息。

因此,对于原子变量,应当尽可能控制其共享范围。最好的情况是单个线程内访问(但是为什么不使用thread_local呢?),其次是同调度组内(暗含“同物理CPU节点”内)共享,全局共享的原子变量应当尽量避免。

对于大多数flare中的数据结构(如IO时使用的非连续缓冲区等),其(也即其内部包含的原子变量的)使用、共享范围通常被控制在一个调度内。具体可参考“调度组”一节。

关于共享状态的范围的讨论通常同样适用于其他类型的共享数据。

相关信息

  • 用于多线程分配(可能不连续)的ID时可以使用id_alloc::Next<Traits>()避免在全局的std::atomic<T>上进行频繁的fetch_add()导致性能损耗。

共享指针、引用计数

不要企图用引用计数解决性能方面的问题(比如双缓冲保持缓冲区存活等等)。引用计数不是性能问题的解决方案,引用计数本身就是性能问题。

在可行的情况下,我们通常使用侵入式的引用计数

我们的侵入式引用计数相对于std::shared_ptr,性能方面有如下不同:

  • 没有额外的内存分配:
    • 我们不支持自定义释放函数,因此不涉及到保存Deleter的内存开销(以及为对Deletertype erasure而引入的额外开销)
    • 因为引用计数是侵入到类本身的,因此不需要额外的“控制块”。(通常认为std::make_shared也可以做到这一点。)
  • 不要求析构函数为虚:对于通过继承RefCounted<T>实现引用计数的类,我们不支持间接继承RefCounted<U>(而是要求必须直接继承RefCounted<T>)的情况。这样我们可以明确知道对象类型,进而不需要T本身析构函数为虚。避免了虚函数调用及相应的虚函数指针。
    • 但是如果析构函数确实定义成虚函数的话,其子类就不需要(但可以)单独定义RefTraits<U>了,我们会默认用其父类的RefTraits<T>

另外需要指出的是,与通常的直觉不同,std::atomic<T>虽然“慢”,参见上文“原子变量”的讨论,如果不用于多线程之间同时修改引用计数,这并不会涉及到伸缩性问题。具体到我们的引用计数的场景,RefCounted<T>的实现中,初始化是没有原子RMW操作的成本的,如果不发生并发修改引用计数,那么从构造到析构只有析构时有一次不会对伸缩性造成影响的原子操作。

但是除非真的有共享的必要,通常我们依然建议优先使用std::unique_ptr管理动态分配的对象。

不要盲目的使用std::mutex等可能导致休眠的锁。

对于可能对系统整体性能造成影响的场景,如对象池中的共享缓存,通常我们推荐在完成必要的状态分配之后(即保证临界区大小足够小且耗时稳定),通过Spinlock完成对共享数据的操作。

这儿我们假定Flare的运行环境通常不会满载。这意味着锁持有者通常不会被调度出去导致竞争方无意义的盲等(对于线上服务而言这通常是成立的)。

我们的Spinlock通过ttas实现,其中fast-path(无竞争环境)会被内联,slow-path会导致函数调用。在特定负载(fiber、以及我们的对象池)中,我们的Spinlock实现实测性能略优于pthread_spin_lock。(pthread_spin_lock同样通过ttas实现,性能优势主要应当来源于内联等。)

取决于实现,std::mutex内部可能也会实现为短期的自旋+休眠,但是其在锁冲突较高且负载较高的情况下可能导致锁所有者被抢占导致其他线程阻塞,因此其性能稳定性不可控,特定负载下可能导致明显抖动。

如果无法避免且代码处在性能敏感的路径上(作为反例,IO的写出我们完全避免了加锁),我们推荐在进行对比之后选择合适的锁。

这儿我们只针对Flare中明确的检验了临界区内的代码生成结果的情况,对于实际业务代码,使用Spinlock通常都是一个错误。

链表

对于性能敏感的场景(尤其是多线程场景),使用链表时优先考虑我们的侵入式的链表internal::DoublyLinkedList

我们的侵入式链表内部不会出现内存分配释放,因此其性能表现稳定。对于多线程场景,这有助于控制临界区大小。

由于其临界区大小稳定,因此需要加锁时,通常推荐配合Spinlock使用(而不是std::mutex)。

线程局部存储

取决于链接器(bfdgold、...)的优化能力,不同程度优化后的TLS变量的访问可能有不同的逻辑(及性能)

简单来说,ELF环境有如下几种访问模型

  • Global Dynamic
    • 最通用的访问模型,支持dlopen等方式加载的动态库。
    • TLS访问需要通过调用__tls_get_addr完成。
    • 我们的测试(x86-64)中这一模型一般会被优化至initial-exec,但不会优化为local-exec,这可能和指令长度有关(链接器优化TLS访问时通常需要填充一或多字节的nop来保持指令长度不变,不同长度的指令可能会对链接器的成本模型有不同程度的影响)。
    • 时空(CPU开销及指令长度)性能较为一般。
  • Local Dynamic
    • 同样需要__tls_get_addr
    • 同一目标文件中不同的TLS变量通过偏移计算(包括访问目标文件中第一个变量也需要)而不需要每次都调用__tls_get_addr
    • 这一模型对连续访问同一目标文件中多个TLS时相对Global Dynamic模型有少量性能提升,但由于每次都需要计算偏移,访问不同的目标文件时可能有性能退化。
    • flare中通常不考虑此种模型(除非链接器自动优化成此种模型。)。
  • Initial Exec
    • 不支持dlopen加载的动态库,支持启动时加载的动态库。
    • 也因为上述限制,在创建第一个线程之前(或之时)就可以明确所有的Initial Exec(及下文所述的Local Exec)的TLS变量,直接将之分配在栈上,可直接通过相对栈底的偏移访问,无需__tls_get_addr
    • 由于动态库中的符号可能涉及到Symbol interposition,因此这一访问模式在获取TLS相对于栈底的偏移时,需要访问GOT
    • 我们的测试(x86-64)环境中,链接器通常会将这一模型优化至local-exec
    • 对于性能敏感的TLS变量,flare通常会使用这一模型
  • Local Exec
    • 只能用于可执行文件本身。
    • tls_get_addr成本。
    • 无访问GOT成本。
    • 四种模型中时空性能最好。

部分性能对比可以参考How fast is thread local variable access on Linux。这篇答案主要对比了普通变量、local-execglobal-dynamic之间的性能区别。取决于链接器是否提供了关闭优化的能力,简单的在同一个文件中使用四种不同的gnu::tls_model修饰做对比可能不能得到实际的对比结果(可参考反汇编结果判断是否被优化)。

考虑到local-exec的应用范围过窄,因此一般不适宜直接将flare中的TLS(业务代码可视情况使用)标记为local-exec。同时,因为链接器通常可以将initial-exec优化至local-exec(但产出的指令长度可能不同,参见上文),我们通常会将使用频繁的TLS标记为initial-exec(如通过C++属性[[gnu::tls_model("initial-exec")]]修饰TLS),以得到更好的性能。

尽管Flare并不支持通过dlopen加载,但是对于特殊情况下需要避免使用initial-exec的场景,可以在编译时定义FLARE_USE_SLOW_TLS_MODEL宏。

如对//flare/example/rpc:server(静态链接的二进制)中的fiber::fiber::detail::SetUpMasterFiberEntity()中下述代码:

master_fiber = &master_fiber_impl;
current_fiber = master_fiber;

反汇编结果则如:

(不使用gnu::tls_model修饰,被自动优化至initial-exec。)

... <+105>: mov %fs:0x0,%rax
... <+114>: lea -0x138(%rax),%rax
... <+121>: mov %r12,(%rax)
... <+124>: mov %fs:0x0,%rax
... <+133>: lea -0x140(%rax),%rax
... <+140>: mov %r12,(%rax)

可以注意到上述指令中,访问TLS时需要访问GOT。

(使用gnu::tls_model("initial-exec")修饰,被优化为local-exec。)

... <+105>: mov $0xfffffffffffffec8,%rax
... <+112>: mov %r12,%fs:(%rax)
... <+116>: mov $0xfffffffffffffec0,%rax
... <+123>: mov %r12,%fs:(%rax)

可以注意到上述指令并未涉及到对GOT的访问,而是直接通过fs段访问了相关的TLS变量。

类型转换

我们参考LLVM的实现,实现了一套类似的RTTI机制。

在我们的性能测试中,在Xeon Gold 6133上、简单的继承层次下(视继承层次的复杂度,这一开销可能不同。dynamic_cast通常会随着继承层次复杂度增加而增大耗时):

我们的测试环境中benchmark框架空跑大约耗时2ns,因此base/casting_benchmark.cc中的数据均比上述数据大~2ns。

综上,C++内置的RTTI性能表现一般。对于性能敏感,或者在正常请求逻辑中处于常态的RTTI需求,我们推荐使用我们自制的RTTI

关于我们自制版的RTTI的使用,可参考rtti.md


返回目录

Опубликовать ( 0 )

Вы можете оставить комментарий после Вход в систему

1
https://api.gitlife.ru/oschina-mirror/mirrors_Tencent-flare.git
git@api.gitlife.ru:oschina-mirror/mirrors_Tencent-flare.git
oschina-mirror
mirrors_Tencent-flare
mirrors_Tencent-flare
master