选择其中一个循环作为外循环有好处吗

Is there an advantage in choosing either loop as an outer loop?

本文关键字:循环 一个 选择      更新时间:2023-10-16

我正在扩展现有的日志库。这是一个有两面性的系统:前端是任务将日志消息写入的地方,后端是应用程序可以插入侦听器的地方,侦听器将这些消息转发到不同的接收器。后端曾经是一个硬连接的侦听器,我现在扩展它以获得灵活性。该代码仅用于嵌入式设备,其中高性能(以每毫秒转发的字节数衡量)是一个非常重要的设计和实现目标。

出于性能原因,消息被缓冲,并且转发是在后台任务中完成的。该任务从队列中获取一块消息,对所有消息进行格式化,然后通过注册的函数将它们传递给侦听器。这些侦听器将筛选消息,并且只将那些通过筛选条件的消息写入其接收器。

考虑到这一点,我最终拥有了N通知功能(侦听器)来向其发送M消息,这是一个相当经典的N*M问题。现在我有两种可能性:我可以循环消息,然后循环将消息传递给每个通知函数的通知函数。

for(m in formatted_messages) 
  for(n in notification_functions)
    n(m);
void n(message)
{
    if( filter(message) )
      write(message);
}

或者我可以循环所有的通知功能,并将我拥有的所有消息一次性传递给它们:

for(n in notification_functions)
    n(formatted_messages);
void n(messages)
{
  for(m in messages)
    if( filter(m) )
      write(m);
}

关于哪个设计更有可能允许每个时间片处理更多的消息,是否有任何的基本考虑因素?(注意这个问题是如何决定监听器的接口的。这不是一个微观优化问题,而是一个关于如何做出不妨碍性能的设计的问题。我只能在很久以后进行测量,然后重新设计监听器接口将是昂贵的。)

我已经做了一些考虑:

  • 这些侦听器需要在某个地方编写消息,这相当昂贵,因此函数调用本身在性能方面可能不太重要
  • 在95%的情况下,只有一个听众

对于哪个设计更有可能允许每个时间片处理更高数量的消息,是否有任何基本考虑因素?

一般来说,这方面的主要考虑因素通常可以归结为两件主要的事情。

  1. 如果其中一个循环在可能具有良好内存局部性的对象上循环(例如在值数组上循环),则将该部分保留在内部循环中可能会将对象保留在CPU缓存中,并提高性能。

  2. 如果你计划尝试并行化操作,那么在外循环中保留"较大"(就计数而言)集合可以有效地并行化外循环,而不会导致线程的过度订阅等。在外级别并行化算法通常更简单、更干净,因此,如果以后有可能的话,在外循环设计具有潜在更大并行"块"工作的循环可以简化这一点。

这些侦听器需要在某个地方编写消息,这相当昂贵,因此函数调用本身在性能方面可能不太重要。

这可能会完全否定将一个循环移出另一个循环的任何好处。

在95%的情况下,只有一个听众。

如果是这种情况,我可能会将侦听器循环放在外部范围,除非您计划并行化此操作。考虑到这将在嵌入式设备上的后台线程中运行,并行化是不可能的,因此将侦听器循环作为外循环应该会减少总指令数(它实际上变成了M个操作上的循环,而不是单个操作上的M个循环)。

循环的顺序可能比侦听器签名的变化优势小得多(注意,无论哪个循环在外部,侦听器都可以维护第一个接口,即两个循环都可以在调用者中)。

第二个接口(即向每个侦听器发送一系列消息)的自然优势在于,您可以对侦听器的实现进行可能的分组。例如,如果向设备写入,侦听器可以将多个消息打包到单个write中,而如果接口接收单个消息,则侦听器缓存(这需要内存和cpu成本),或者每次调用需要执行多个writes

因此,以下几个因素将在其中发挥作用:

缓存中的消息距离有多近,它们占用了多少空间?如果它们相对较小(几KB或更少)并且靠近(例如,在进行大量其他内存分配的系统中,不是一个间隔几秒分配内存的链表)。

如果它们很近,而且很小,那么我相信第二种选择更有效,因为消息将被预取/缓存在一起,其中调用所有n侦听器和筛选器函数(也假设有很多函数,而不是一个、两个或三个)可能会导致前一消息的更多"缓存丢弃"。当然,这也取决于侦听器和筛选器函数的实际复杂程度。他们做多少工作?如果每个函数都做了相当多的工作,那么你按哪个顺序做可能就不那么重要了,因为它只是边际的。

没有任何"根本"原因可以解释为什么一个设计比另一个更好。根据库的使用方式,可能会出现一些非常小的速度差异。我个人更喜欢先迭代监听器,然后再迭代消息。

我猜处理程序的主体通常都很快。您可能希望将侦听器作为外循环进行迭代,以便重复调用相同的代码。像间接呼叫预测这样的东西用这种方式会更好。当然,数据缓存的使用情况会更糟,但希望每个消息缓冲区都足够小,可以轻松地放入L1。

为什么不让听众接受const vector<message> &并让他们自己进行迭代呢?他们可以做任何有益的缓冲,最后只做一次昂贵的写入。