Быстрый memcpy для ESP32-S3

Привет!

ESP32-S3 - замечательный процессор с SIMD инструкциями. Оперирует 16-байтовыми векторами, умеет с комплексными числами работать и Фурье делать. Поэтому его часто используют в домашней аудиотехнике (всякие играйки музыки и set-top boxes)

Ну раз есть SIMD, значит самое время для быстрого memcpy

Ниже - файл, esp32-s3-memcpy.S. Кидаете его прямо в каталог с вашим скетчем и объявляете у себя где-нибудь

extern "C" void esps3_memcpy(void *, void *, size_t);

.. или так (если на Си пишите):

extern void esps3_memcpy(void *, void *, size_t);

Для копирования маленьких блоков, менее 16 байт, лучше использовать встроенный memcpy(). А если длиннее - то ассемблерный вариант дает прирост скорости от 2.4 до 3 раз. Тестировалось на буферах 15кб (чтобы в кэш влезло и cache miss не случались, а то будем измерять среднюю температуру по больнице)

Работает только на ESP32-S3. Не читал даташыт от ESP32-S2 - может быть будет работать и там.

    .align 4
    .text
    .global esps3_memcpy
    .type esps3_memcpy, @function
    .section .iram1

esps3_memcpy:
    .align      4
    entry       sp,     32

    # void esps3_memcpy(store_ptr, load_ptr, length)
    #                       a2        a3        a4

    ee.ld.128.usar.ip q0, a3, 0
    rur.sar_byte a13
    movi a11, 16
    sub a11, a11, a13  # head unaligned bytes 1  (a11, load_ptr)

    ee.ld.128.usar.ip q1, a2, 0
    rur.sar_byte a9
    movi a12, 16
    sub a12, a12, a9  # head unaligned bytes 2 (a12, store_ptr)

# is store buffer 16-byte aligned?
    beqi a12, 16, 11f 

# unaligned. calculate reminder and copy via l32/s32
    min a12, a12, a4
    srli a13, a12, 2  # a13 - words count
    slli a14, a13, 2  
    sub a14, a12, a14 # a14 - bytes count

# esp32 zero-overhead hardware loop to copy the unaligned remainder
# this loop only takes place if remainder is non-zero
# copy 32 bits at a time
    loopgtz a13, 9f
    l32i a5, a3, 0
    addi a3, a3, 4
    s32i a5, a2, 0
    addi a2, a2, 4
9:

# copy 1 byte at a time

    loopgtz a14, 10f
    l8ui a5, a3, 0
    addi a3, a3, 1
    s8i a5, a2, 0
    addi a2, a2, 1

10:

# main machinery

    sub a4, a4, a12
    ee.ld.128.usar.ip q0, a3, 0
    rur.sar_byte a13
11:
    beqz a13, 1f
    srli a5, a4, 4  
    slli a6, a5, 4
    sub a6, a4, a6  #  a6 = length % 16

    srli a7, a5, 1 # len // 32
    slli a8, a7, 1
    sub a8, a5, a8 # odd_flag

    srli a9, a6, 2  #remainder_4b
    slli a10, a9, 2
    sub a10, a6, a10 #remainder_1b

# 32-byte per cycle in a hardware loop
    
    loopgtz a7, 12f
    ee.ld.128.usar.ip q0, a3, 16
    ee.ld.128.usar.ip q1, a3, 16
    ee.ld.128.usar.ip q2, a3, 0
    ee.src.q q0, q0, q1
    ee.src.q q1, q1, q2
    ee.vst.128.ip q0, a2, 16
    ee.vst.128.ip q1, a2, 16
12:

    beqz a8, 3f
    ee.ld.128.usar.ip q0, a3, 16
    ee.ld.128.usar.ip q1, a3, 0
    ee.src.q q0, q0, q1
    ee.vst.128.ip q0, a2, 16
    bnez a8, 3f

1:
    srli a5, a4, 4  # len // 16
    slli a6, a5, 4
    sub a6, a4, a6  # remainder

    srli a7, a5, 1 # len // 32
    slli a8, a7, 1
    sub a8, a5, a8 # odd_flag

    srli a9, a6, 2  #remainder_4b
    slli a10, a9, 2
    sub a10, a6, a10 #remainder_1b

    loopgtz a7, 2f
    ee.vld.128.ip q0, a3, 16
    ee.vld.128.ip q1, a3, 16
    ee.vst.128.ip q0, a2, 16
    ee.vst.128.ip q1, a2, 16
2:
    beqz a8, 3f
    ee.vld.128.ip q0, a3, 16
    ee.vst.128.ip q0, a2, 16
3:
    loopgtz a9, 4f
    l32i a5, a3, 0
    addi a3, a3, 4
    s32i a5, a2, 0
    addi a2, a2, 4
4:
    loopgtz a10, 5f
    l8ui a5, a3, 0
    addi a3, a3, 1
    s8i a5, a2, 0
    addi a2, a2, 1
5:
    retw

На блоках чуть меньше килобайта прирост скорости составляет 2.4 раза. На блоках по 15кб прирост 3 раза.

На здоровье!

1 лайк

Слова порой материальны. Не разбрасывайтесь своим здоровьем, оно не вечно! В крайнем случае её можно сказать своим детям, внукам, но не форуму незнакомых вам людей.

Просто из досужего интереса - за счет чего прирост берется? Или, формулируя от обратного - почему стандартный memcpy() медленнее?

За счёт отсутствия универсальности видимо раз работает только на s3

Прирост идет за счет того, что используются векторные (SIMD) инструкции (ee.vld / ee.vst), которые за одну инструкцию могут копировать (ну или арифметику выполнять) 16 байт за инструкцию. SIMD = Single Command Multiple Data

Стандартный memcpy ничего не знает про эти команды и 16байтовые регистры.

Ну и в основном цикле команды раскиданы в попытке не загружать конвеер CPU:

  1. загружаем 16 байт,
  2. загружаем еще 16 байт (как раз завершится первая загрузка),
  3. сохраняем первое (завершилась вторая загрузка),
  4. сохраняем второе.

Но основной прирост за счет 16 байтового копирования за раз.

Это странно. Вы же наверно не запускаете какой-то “стандартный memcpy”, переписанный из учебника, а пользуетесь пакетом от Espressif? - странно что производитель софта не вставил, например, блок условной компиляции с использованием SMD, для сборки под s3

ключ компилятора -О3, может сотворить чудеса))

1 лайк

Ну, что есть - то есть.

Спойлер

На самом деле там есть глубокая проблема. Связана она с тем, что Espressif использует FreeRTOS, вернее, даже не это, а то, КАК они написали порт под свой процессор.

Шедулер операционной системы (любой), когда переключает задачи, сохраняет т.н. контекст - регистры процессора, в память. Когда происходит переключение на новую задачу, ее контекст загружается в регистры. Таким образом каждая отдельная задача считает, что все имеющиеся регистры кроме нее никто не использует.

Наличие SIMD в ESP32-S3 - это расширение базовой архитектуры, которое добавляет, помимо команд еще и внушительный файл регистров: q0..q7 16байтовые регистры да еще есть 180битовый, а т.п. и .т.д. Если и ЭТИ регистры сохранять в контекст - памяти не напасешся, да и время переключения задач вырастет существенно.

И кто-то в Espressif принял решение - “extended registers - не сохраняем“. Это привело к тому, что если две задачи одновременно используют 16байтовые регистры - будет каша-малаша. Регистры будут общими для всех задач.

В принципе это страшно только если у вас несколько задач одновременно без синхронизации будут вызывать esps3_memcpy(). Если задача, использующая esps3_memcpy() - одна, (или вызовы memcpy разделены барьерами какими-то), то проблем нет.

Ну или допилить порт FreeRTOS от Espressif – там cut/paste программирование, главное найти, где сохраняется и восстанавливается контекст и напастить по образу и подобию.

Не, GCC тоже ничего не знает про эти регистры.

да, конечно.

там хороший, оптимизированный memcpy(), который копирует по 4 байта за цикл

Через dma еще быстрее полетит

Совершенно необязательно. По моему опыту, обычный ногодрыг в СТМ32 быстрее, чем ДМА.
Преимущество ДМА не в скорости, а в асинхронности.

ЗЫ хотя не исключаю, что это я такой криворукий

1 лайк

Что-то не верю я этому.
Помнится, какая была компания вокруг того, что с точки зрения сохранения контекста объединили регистры FPU и MMX. Я даже встречал утверждения, что регистры MMX не поместились на кристалле, а потому их объединили с регистрами FPU.
В общем, одно из двух - либо эти “регистры” есть какая-то софтверная эмуляция, не имеющаяся аппаратно, либо их использование в прикладной задаче категорически запрещено. До тех пор, пока не появится поддержка со стороны ОС.

Это вряд ли.

не, не летит. летит только на больших блоках и то со скоростью обычного memcpy. оно ж не для скорости было сделано, а чтобы с цпу снять задачу копирования

Нет, они аппаратные, за счет этого и скорость и мультимедия.

И использовать их, очевидно, можно. Но когда я чужой код смотрел (исходный код теле-приставки), то у них свои какие-то мини ОС используются (еще проще, чем фриртос, по факту просто шедулер примитивный) где контексты переключаются корректно.

Интересно, конечно. Но, возможно, 16байтовые регистры не вполне такие регистры, иначе прирост скорости был бы ближе к 16(чем по одному байту копировать - тут сразу 16). Хотя, с другой стороны, их ещё загрузить, сначала, надо))
Хотел подкинуть, проверить, но, ни в Arduino IDE , ни в Wokwi компиляция не удалась.(

По четыре копирует стандартный memcpy. Так что в идеальном случае (адрес источника и адрес назначения выровнены на 16 байт, длина буфера кратна 16 байт) будет прирост около 4 раз.

В зависимости от того, выровненные адреса или нет, что стандартный , что этот memcpy будет корячиться, выравнить адреса, а уж потом - копировать пачками по 4 или по 16 байт.

Вот вот…
Вот так проверить хотел, не смог скомпилировать твою функцию.
Random, чтобы оптимизатор не мешал.
Специально всё упростил, чтобы ничего не мешало.
Если будет время, подставь свою функцию, ради интереса.

Спойлер
//#include "esp32-s3-memcpy.S"
const uint8_t Size = 160;
uint8_t buf_dest[Size];
uint8_t buf_src[Size]; 


void setup() {
  uint32_t start;
  uint32_t stop;

  Serial.begin(115200);
  Serial.println("Hello, ESP32-S3!");

  randomSeed(analogRead(0));

  for(uint8_t i = 0; i < Size; i++)
  buf_src[i] = (uint8_t)random(0, 255);

   start = micros();
   memcpy(buf_dest, buf_src, Size);
   stop = micros();

   Serial.print("memcpy, us =  ");
   Serial.println(stop - start);

   for(uint8_t i = 0; i < Size; i++)
   {
       if(buf_dest[i] == buf_src[i])
       {   
          Serial.print(buf_dest[i]);
          Serial.print(" ");
       }
      else
        {
         Serial.print("ERR");
        }

       if(!((i + 1)%10))
        Serial.println();
   }
}

void loop() {
  // put your main code here, to run repeatedly:
  delay(10); // this speeds up the simulation
}
Спойлер

Screenshot_366 - копия

Я немного изменил твой код. Но даже этого оказалось недостаточно. Надо цикл бы добавить, а в цикле сделать 10 memcpy. Цикл на 100 тыщ итераций, хотя бы.

Твой код без цикла, я навтыкал туда 10 memcpy, но этого явно мало.

27 микросекунд 10 стандартных memcpy и 5 микросекунд на 10 специальных esps3_memcpy

extern "C" void esps3_memcpy(void *, void *, int);

#include <Arduino.h>

const int Size = 1024*10;   // 10 к буфер
uint8_t buf_dest[Size];
uint8_t buf_src[Size]; 

void setup() {
  uint64_t tim;
  

  Serial.begin(115200);
  Serial.println("Hello, ESP32-S3!");

  //randomSeed(analogRead(0));

  for(int i = 0; i < Size; i++)
    buf_src[i] = (uint8_t)esp_random();

   tim = esp_timer_get_time();
   esps3_memcpy(buf_dest, buf_src, Size);
   esps3_memcpy(buf_dest, buf_src, Size);
   esps3_memcpy(buf_dest, buf_src, Size);
   esps3_memcpy(buf_dest, buf_src, Size);
   esps3_memcpy(buf_dest, buf_src, Size);
   esps3_memcpy(buf_dest, buf_src, Size);
   esps3_memcpy(buf_dest, buf_src, Size);
   esps3_memcpy(buf_dest, buf_src, Size);
   esps3_memcpy(buf_dest, buf_src, Size);
   esps3_memcpy(buf_dest, buf_src, Size);
   tim = esp_timer_get_time() - tim;

   Serial.printf("memcpy, us = %llu\r\n",tim / 10);

   for(int i = 0; i < Size; i++)
   {
       if(buf_dest[i] != buf_src[i])
         Serial.printf("Data mismatch at offset %d\r\n", i);

   }
}

void loop() {
  // put your main code here, to run repeatedly:
  delay(9999); // this speeds up the simulation
}

Судя по всему, мои потуги надуть оптимизатор провалились - у меня на 10 memcpy такое же время как у тебя на одном :))). Лень дизассемблировать. Можно просто выключить оптимизатор, вот так:

#pragma GCC optimize (“O0”)
....
код функции
...
#pragma GCC optimize (“Os”)
1 лайк

ой, блин. я же на 10 сам разделил там. Все мои слова про оптимизатор и потуги - объявляются недействительными.

Короче: memcpy() : 27 микросекунд , esps3_memcpy(): 5 микросекунд, буфер 10 кбайт.