问题 提高读取易失性存储器的性能


我有一个函数从一些易失性存储器读取,由DMA更新。 DMA永远不会在与函数相同的内存位置上运行。我的应用程序是性能关键。因此,我意识到执行时间大约提高了。如果我没有将内存声明为volatile,则为20%。在我的函数范围内,内存是非易失性的。 Hovever,我必须确保下次调用该函数时,编译器知道内存可能已经改变。

内存是两个二维数组:

volatile uint16_t memoryBuffer[2][10][20] = {0};

DMA运行在与程序功能相反的“矩阵”上:

void myTask(uint8_t indexOppositeOfDMA)
{
  for(uint8_t n=0; n<10; n++)
  {
    for(uint8_t m=0; m<20; m++)
    {
      //Do some stuff with memory (readings only):
      foo(memoryBuffer[indexOppositeOfDMA][n][m]);
    }
  }
}

是否有正确的方法告诉我的编译器memoryBuffer在myTask()范围内是非易失性的,但是下次调用myTask()时可能会更改,所以我可以将性能提升20%?

平台Cortex-M4


6074
2018-02-09 13:40


起源

好问题恕我直言。 - owacoder
如何才能改善执行时间,只需删除volatile?我想当允许编译器/优化器摆脱那个矩阵时,行为是不同的(不起作用)。 - LPs
@LPs - 如果编译器看到变量没有写入读取之间并且只是优化读取,它可以改进...这不是说程序必须正确吗? :) - StoryTeller
好吧,也许可以是缓存问题。如果是,您可以尝试手动使缓存无效,但我不是这方面的专家。您可以看到汇编程序以了解正在发生的事情。 - LPs
是否 Do some stuff with memory: 暗示代码可能 写?或者就是这样 读? - chux


答案:


没有波动的问题

让我们假设一下 volatile 从数据数组中省略。然后是C编译器 并且CPU不知道它的元素在程序流之外发生了变化。一些 事情 可以 然后发生:

  • 整个数组可能会被加载到缓存中 myTask() 被要求 第一次。该数组可能永远保留在缓存中,永远不会 再次从“主”内存更新。这个问题在多核问题上更加紧迫 CPU如果 myTask() 例如,绑定到单个核心。

  • 如果 myTask() 内联到父函数,编译器可能会决定 将循环外的负载提升到DMA传输点 尚未完成。

  • 编译器甚至可以确定没有发生写入 memoryBuffer 并假设数组元素始终保持为0 (这将再次引发大量优化)。如果这可能发生 程序相当小,编译器可以看到所有代码 立刻(或使用LTO)。 记得:毕竟编译器对DMA没有任何了解 外围并且它正在“意外地和疯狂地写入记忆” (从编译器的角度来看)。

如果编译器是愚蠢/保守的并且CPU不是很复杂(单核,没有无序执行),那么代码甚至可以在没有 volatile 宣言。但它也可能不会......

易变的问题

制造 整个阵列 volatile 往往是一种悲观情绪。出于速度原因你 可能想要展开循环。所以不要从中加载 数组并交替递增索引,如

load memoryBuffer[m]
m += 1;
load memoryBuffer[m]
m += 1;
load memoryBuffer[m]
m += 1;
load memoryBuffer[m]
m += 1;

一次加载多个元素并增加索引可以更快 在更大的步骤,如

load memoryBuffer[m]
load memoryBuffer[m + 1]
load memoryBuffer[m + 2]
load memoryBuffer[m + 3]
m += 4;

如果负载可以融合在一起(例如,执行,则尤其如此) 一个32位负载而不是两个16位负载)。进一步你想要的 编译器使用SIMD指令处理多个数组元素 一条指令。

如果负载发生,通常会阻止这些优化 易失性内存,因为编译器通常非常保守 加载/存储易失性存储器访问的重新排序。 同样,编译器供应商之间的行为也不同(例如,MSVC与GCC)。

可能的解决方案1:围栏

所以你想让数组非易失性但是为编译器/ CPU添加一个提示 “当你看到这一行(执行此语句)时,刷新缓存并从内存重新加载数组”。在C11中你可以插入一个 atomic_thread_fence 在。。。之初 myTask()。这样的围栏阻止了它们之间的装载/存储的重新排序。

由于我们没有C11编译器,因此我们使用内在函数来完成此任务。 ARMCC编译器有一个 __dmb() 内在的(数据记忆障碍)。对于海湾合作委员会,你可能想看看 __sync_synchronize() (DOC)。

可能的解决方案2:原子变量保持缓冲状态

我们在代码库中使用了以下模式(例如,从中读取数据时) SPI通过DMA并调用一个函数来分析它):缓冲区被声明为 普通阵列(没有 volatile并且每个缓冲区都添加了一个原子标志 在DMA传输完成时设置。代码看起来很像 喜欢这个:

typedef struct Buffer
{
    uint16_t data[10][20];
    // Flag indicating if the buffer has been filled. Only use atomic instructions on it!
    int filled;
    // C11: atomic_int filled;
    // C++: std::atomic_bool filled{false};
} Buffer_t;

Buffer_t buffers[2];

Buffer_t* volatile currentDmaBuffer; // using volatile here because I'm lazy

void setupDMA(void)
{
    for (int i = 0; i < 2; ++i)
    {
        int bufferFilled;
        // Atomically load the flag.
        bufferFilled = __sync_fetch_and_or(&buffers[i].filled, 0);
        // C11: bufferFilled = atomic_load(&buffers[i].filled);
        // C++: bufferFilled = buffers[i].filled;

        if (!bufferFilled)
        {
            currentDmaBuffer = &buffers[i];
            ... configure DMA to write to buffers[i].data and start it
        }
    }

    // If you end up here, there is no free buffer available because the
    // data processing takes too long.
}

void DMA_done_IRQHandler(void)
{
    // ... stop DMA if needed

    // Atomically set the flag indicating that the buffer has been filled.
    __sync_fetch_and_or(&currentDmaBuffer->filled, 1);
    // C11: atomic_store(&currentDmaBuffer->filled, 1);
    // C++: currentDmaBuffer->filled = true;

    currentDmaBuffer = 0;
    // ... possibly start another DMA transfer ...
}

void myTask(Buffer_t* buffer)
{
    for (uint8_t n=0; n<10; n++)
        for (uint8_t m=0; m<20; m++)
            foo(buffer->data[n][m]);

    // Reset the flag atomically.
    __sync_fetch_and_and(&buffer->filled, 0);
    // C11: atomic_store(&buffer->filled, 0);
    // C++: buffer->filled = false;
}

void waitForData(void)
{
    // ... see setupDma(void) ...
}

将缓冲区与原子配对的优点是,您可以检测到处理速度太慢意味着您必须缓冲更多, 使传入的数据更慢或处理代码更快或者更快 在你的情况下足够了。

可能的解决方案3:OS支持

如果您有(嵌入式)操作系统,则可能需要使用其他模式而不是使用易失性数组。我们使用的操作系统具有内存池和队列。后者可以从线程或中断填充,线程可以阻塞 队列,直到它是非空的。该模式看起来有点像这样:

MemoryPool pool;              // A pool to acquire DMA buffers.
Queue bufferQueue;            // A queue for pointers to buffers filled by the DMA.
void* volatile currentBuffer; // The buffer currently filled by the DMA.

void setupDMA(void)
{
    currentBuffer = MemoryPool_Allocate(&pool, 20 * 10 * sizeof(uint16_t));
    // ... make the DMA write to currentBuffer
}

void DMA_done_IRQHandler(void)
{
    // ... stop DMA if needed

    Queue_Post(&bufferQueue, currentBuffer);
    currentBuffer = 0;
}

void myTask(void)
{
    void* buffer = Queue_Wait(&bufferQueue);
    [... work with buffer ...]
    MemoryPool_Deallocate(&pool, buffer);
}

这可能是最简单的实现方法,但前提是您有操作系统 如果可移植性不是问题。


6
2018-02-09 15:27



在这里,downvoters应该写一些东西.....紫外线 - LPs
@Mehrwolf - 你的答案非常详细:-)。但是,您通过数据记忆障碍击中了目标。我在Cortex-M4上使用CMSIS库并调用__DMB()。在我的案例中,忽略不稳定声明的真实数字是将核心利用率从17.1%降低到14.3%。在我的应用程序类型中,重要!谢谢 :-) - Dennis Kirkegaard
@DennisKirkegaard很高兴它有用:-) - Mehrwolf


在这里你说缓冲区是非易失性的:

“memoryBuffer在myTask范围内是非易失性的”

但在这里你说它必须是不稳定的:

“但下次我打电话给myTask时可能会改变”

这两句话是矛盾的。显然是内存区域 必须 易失性或编译器无法知道它可能被DMA更新。

但是,我宁愿怀疑实际性能损失来自于通过算法反复访问此内存区域,迫使编译器反复读取它。

你应该做的是获取你感兴趣的内存部分的本地非易失性副本:

void myTask(uint8_t indexOppositeOfDMA)
{
  for(uint8_t n=0; n<10; n++)
  {
    for(uint8_t m=0; m<20; m++)
    {
      volatile uint16_t* data = &memoryBuffer[indexOppositeOfDMA][n][m];
      uint16_t local_copy = *data; // this access is volatile and wont get optimized away

      foo(&local_copy); // optimizations possible here

      // if needed, write back again:
      *data = local_copy; // optional
    }
  }
}

你必须对它进行基准测试,但我很确定这应该会提高性能。

或者,您可以先复制您感兴趣的数组的整个部分,然后再写回来,然后再写回来。这应该有助于提高性能。


2
2018-02-09 15:41



如果您的嫌疑人是对的,整个帖子的问题和答案都是浪费时间......;) - LPs
只有一种方法可以找到。标杆。 - Lundin


你不能抛弃volatile限定符1

如果必须定义包含volatile元素的数组,则只有两个选项“让编译器知道内存已经更改”,是保留volatile限定符,或者使用一个没有volatile定义的临时数组,并将其复制到函数调用后的正确数组。选择哪个更快。


1(引用自:ISO / IEC 9899:201x 6.7.3类型限定符6)
如果是尝试 通过使用左值来引用用volatile限定类型定义的对象 如果使用非volatile限定类型,则行为未定义。


1
2018-02-09 14:52



此外,在指针转换规则(6.3.2.3)中,从限定指针到类型到指针到类型的转换未列为有效的转换形式。 - Lundin
@Lundin嗯,是的。我的答案中的规则使用术语“参考”而非“访问”,因此(取决于什么参考意味着)简单地指向,转换和指定指针,将是未定义的。 - 2501


在我看来,你将缓冲区的一半传递给了 myTask 每一半都不需要挥发。所以我想知道你是否可以通过定义缓冲区来解决你的问题,然后将指针传递给其中一个半缓冲区 myTask。我不确定这是否有效,但也许是这样的......

typedef struct memory_buffer {
    uint16_t buffer[10][20];
} memory_buffer ;

volatile memory_buffer double_buffer[2];

void myTask(memory_buffer *mem_buf)
{
  for(uint8_t n=0; n<10; n++)
  {
    for(uint8_t m=0; m<20; m++)
    {
      //Do some stuff with memory:
      foo(mem_buf->buffer[n][m]);
    }
  }
}

0
2018-02-09 15:14



呼唤 myTask() 将需要抛弃 volatile,如果更高级别的代码“知道”一半,这可能是安全的 double_buffer[] 在函数调用期间是非易失性的。 - chux
这不起作用,C不允许从指针到易失性类型到指针到类型的指针转​​换。并且将缓冲区拆分成两个解决方案。 - Lundin


我不知道你的平台/ mCU / SoC,但通常DMA有可在可编程阈值上触发的中断。

我能想象的是删除volatile关键字并使用interrupt作为任务的信号量。

换一种说法:

  • DMA被编程为在写入缓冲区的最后一个字节时中断
  • 任务是在信号量/标志上阻塞,等待标志被释放
  • 当DMA调用中断例程时,将DMA指向的缓冲区用于下一个读取时间,并更改解锁可以详细说明数据的任务的标志。

就像是:

uint16_t memoryBuffer[2][10][20];

volatile uint8_t PingPong = 0;

void interrupt ( void )
{    
    // Change current DMA pointed buffer

    PingPong ^= 1;    
}

void myTask(void)
{
    static uint8_t lastPingPong = 0;

    if (lastPingPong != PingPong)
    {
        for (uint8_t n = 0; n < 10; n++)
        {
            for (uint8_t m = 0; m < 20; m++)
            {
                //Do some stuff with memory:
                foo(memoryBuffer[PingPong][n][m]);
            }
        }

        lastPingPong = PingPong;
    }
}

0
2018-02-09 15:15



Toggeling PingPong 很容易错过中断。我只是逐步增加它。当然,更好的方法是使用适当的信号量或条件变量。 - Mehrwolf
@Mehrwolf当然系统信号量是最好的选择,但是我写的平台没有指定。顺便说一句,如果任务至少以读取DMA时间的双倍速率被调用,则可以确定任务触发每个乒乓。 - LPs