提高读取易失性内存的性能

Improve performance of reading volatile memory

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

内存是两个二维数组:

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

DMA 操作与程序函数相反"matrix":

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 平台

您不能放弃 volatile 限定符1

如果必须定义包含 volatile 元素的数组,那么只有两个选项 "that let the compiler know that the memory has changed" 是保留 volatile 限定符,或者使用未定义 volatile 并复制到适当数组的临时数组函数调用后。选择更快的那个。


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

在我看来,您将缓冲区的一半传递给 myTask,并且每一半都不需要易变。所以我想知道您是否可以通过这样定义缓冲区,然后将指向 half-buffers 之一的指针传递给 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]);
    }
  }
}

我不认识你 platform/mCU/SoC,但通常 DMA 具有在可编程阈值上触发的中断。

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

换句话说:

  • DMA 被编程为在写入缓冲区的最后一个字节时中断
  • 任务阻塞在 semaphore/flag 等待释放标志
  • 当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;
    }
}

没有volatile的问题

假设数据数组中省略了 volatile。然后是C编译器 CPU 不知道它的元素在 program-flow 之外发生了变化。一些 可能发生的事情:

  • 当调用 myTask() 时,整个数组可能会加载到缓存中 第一次。该数组可能永远保留在缓存中并且永远不会 再次从 "main" 内存更新。 multi-core 这个问题比较紧迫 CPUs 如果 myTask() 绑定到单个核心,例如。

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

  • 编译器甚至可以确定没有写入发生 memoryBuffer 并假设数组元素一直为 0 (这将再次触发大量优化)。这可能发生,如果 程序很小,编译器可以看到所有代码 一次(或使用 LTO)。 记住:毕竟编译器对DMA一无所知 外围设备,它正在写入 "unexpectedly and wildly into memory" (从编译器的角度来看)。

如果编译器是 dumb/conservative 并且 CPU 不是很复杂(单核,没有 out-of-order 执行),代码甚至可以在没有 volatile 声明的情况下运行.但它也可能不会...

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 指令处理多个数组元素 一条指令。

如果加载发生在 易失性内存,因为编译器通常非常保守 load/store 围绕易失性内存访问重新排序。 同样,编译器供应商之间的行为也不同(例如 MSVC 与 GCC)。

可能的解决方案 1:围栏

所以你想创建数组 non-volatile 但为 compiler/CPU 添加一个提示 "when you see this line (execute this statement), flush the cache and reload the array from memory"。在 C11 中,您可以在 myTask() 的开头插入一个 atomic_thread_fence。这样的栅栏可以防止 re-ordering 的 loads/stores 穿过它们。

由于我们没有 C11 编译器,因此我们使用内部函数来完成这项任务。 ARMCC 编译器有一个 __dmb() 内在 (data memory barrier). For GCC you may want to look at __sync_synchronize() (doc).

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

我们在代码库中经常使用以下模式(例如,从 通过 DMA 的 SPI 并调用一个函数来分析它):缓冲区被声明为 普通数组(没有 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支持

如果您有一个(嵌入式)OS,您可能会求助于其他模式而不是使用易失性数组。我们使用的 OS 具有内存池和队列功能。后者可以从线程或中断中填充,并且线程可以阻塞 队列直到 non-empty。模式看起来有点像这样:

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);
}

这可能是最简单的实施方法,但前提是您有 OS 如果便携性不是问题。

这里你说缓冲区是non-volatile:

"memoryBuffer is non-volatile inside the scope of myTask"

但是你这里说一定是volatile:

"but may be changed next time i call myTask"

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

但是,我更怀疑实际的性能损失来自通过您的算法重复访问此内存区域,迫使编译器一遍又一遍地读取它。

你应该做的是在本地,non-volatile复制你感兴趣的内存部分:

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
    }
  }
}

您必须对其进行基准测试,但我很确定这会提高性能。

或者,您可以先复制您感兴趣的数组的整个部分,然后对其进行处理,然后再将其写回。这应该对性能有更大的帮助。