这篇文档罗列了一些常见的需要注意的性能相关的点。这篇文档的目标群体主要为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_alloc::Next<Traits>()
避免在全局的std::atomic<T>
上进行频繁的fetch_add()
导致性能损耗。不要企图用引用计数解决性能方面的问题(比如双缓冲保持缓冲区存活等等)。引用计数不是性能问题的解决方案,引用计数本身就是性能问题。
在可行的情况下,我们通常使用侵入式的引用计数。
我们的侵入式引用计数相对于std::shared_ptr
,性能方面有如下不同:
Deleter
的内存开销(以及为对Deleter
做type 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
)。
取决于链接器(bfd、gold、...)的优化能力,不同程度优化后的TLS变量的访问可能有不同的逻辑(及性能)。
简单来说,ELF环境有如下几种访问模型:
dlopen
等方式加载的动态库。__tls_get_addr
完成。initial-exec
,但不会优化为local-exec
,这可能和指令长度有关(链接器优化TLS访问时通常需要填充一或多字节的nop来保持指令长度不变,不同长度的指令可能会对链接器的成本模型有不同程度的影响)。__tls_get_addr
。__tls_get_addr
。dlopen
加载的动态库,支持启动时加载的动态库。__tls_get_addr
。local-exec
。tls_get_addr
成本。部分性能对比可以参考How fast is thread local variable access on Linux。这篇答案主要对比了普通变量、local-exec
、global-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变量。
在我们的性能测试中,在Xeon Gold 6133上、简单的继承层次下(视继承层次的复杂度,这一开销可能不同。dynamic_cast
通常会随着继承层次复杂度增加而增大耗时):
dynamic_cast
开销大约~19ns。我们的测试环境中benchmark框架空跑大约耗时2ns,因此base/casting_benchmark.cc
中的数据均比上述数据大~2ns。
综上,C++内置的RTTI性能表现一般。对于性能敏感,或者在正常请求逻辑中处于常态的RTTI需求,我们推荐使用我们自制的RTTI。
关于我们自制版的RTTI的使用,可参考rtti.md。
Вы можете оставить комментарий после Вход в систему
Неприемлемый контент может быть отображен здесь и не будет показан на странице. Вы можете проверить и изменить его с помощью соответствующей функции редактирования.
Если вы подтверждаете, что содержание не содержит непристойной лексики/перенаправления на рекламу/насилия/вульгарной порнографии/нарушений/пиратства/ложного/незначительного или незаконного контента, связанного с национальными законами и предписаниями, вы можете нажать «Отправить» для подачи апелляции, и мы обработаем ее как можно скорее.
Опубликовать ( 0 )