Acerca de las cachés en los microcontroladores ARM

imagen¡Hola!



En el artículo anterior , usamos un caché de procesador para acelerar los gráficos en un microcontrolador en Embox . En este caso, usamos el modo de "escritura directa". Luego escribimos sobre algunas de las ventajas y desventajas asociadas con el modo de "escritura directa", pero esto fue solo una descripción general. En este artículo, como prometí, quiero echar un vistazo más de cerca a los tipos de cachés en los microcontroladores ARM, así como compararlos. Por supuesto, todo esto se considerará desde el punto de vista de un programador, y no planeamos entrar en los detalles del controlador de memoria en este artículo.



Empezaré por donde lo dejé en el artículo anterior, es decir, la diferencia entre los modos "escribir de nuevo" y "escribir a través", ya que estos dos modos se utilizan con mayor frecuencia. En breve:



  • "Respóndeme". Los datos de escritura van solo a la caché. La escritura real en la memoria se aplaza hasta que la caché se llena y se requiere espacio para nuevos datos.
  • "Escriba por medio de". La escritura se produce "simultáneamente" en el caché y la memoria.


Escriba por medio de



Se considera que las ventajas de la escritura simultánea son la facilidad de uso, lo que potencialmente reduce los errores. De hecho, en este modo, la memoria siempre está en el estado correcto y no requiere procedimientos de actualización adicionales.



Por supuesto, parece que esto debería tener un gran impacto en el rendimiento, pero el propio STM en este documento dice que no lo es:

Write-through: triggers a write to the memory as soon as the contents on the cache line are written to. This is safer for the data coherency, but it requires more bus accesses. In practice, the write to the memory is done in the background and has a little effect unless the same cache set is being accessed repeatedly and very quickly. It is always a tradeoff.
Es decir, inicialmente asumimos que, dado que la escritura se realiza en la memoria, el rendimiento en las operaciones de escritura será aproximadamente el mismo que sin caché, y la ganancia principal se produce debido a las lecturas repetidas. Sin embargo, STM refuta esto, dice que los datos en la memoria quedan "en segundo plano", por lo que el rendimiento de escritura es casi el mismo que en el modo de "escritura diferida". Esto, en particular, puede depender de los búferes internos del controlador de memoria (FMC).



Desventajas del modo "escritura directa":



  • El acceso secuencial y rápido a la misma memoria puede degradar el rendimiento. En el modo "write-back", los accesos frecuentes secuenciales a la misma memoria serán, por el contrario, una ventaja.
  • Como en el caso de la "escritura diferida", aún necesita hacer una invalidación de caché después del final de las operaciones de DMA.
  • Error "Corrupción de datos en una secuencia de almacenamiento y carga de escritura simultánea" en algunas versiones de Cortex-M7. Se señaló a nosotros por uno de los desarrolladores LVGL.


Respóndeme



Como se mencionó anteriormente, en este modo (a diferencia de "escritura directa") los datos generalmente no ingresan a la memoria escribiendo, sino que solo ingresan a la caché. Al igual que la escritura simultánea, esta estrategia tiene dos subopciones: 1) asignación de escritura, 2) asignación de no escritura. Hablaremos más sobre estas opciones.



Escribir Asignar



Como regla general, la "asignación de lectura" siempre se usa en las cachés, es decir, en una falta de caché para lectura, los datos se obtienen de la memoria y se colocan en la caché. Del mismo modo, un error de escritura puede hacer que los datos se carguen en la caché ("asignación de escritura") o que no se carguen ("asignación de no escritura").



Normalmente, en la práctica, se utilizan las combinaciones "escritura diferida asignar" o "escritura simultánea sin asignación de escritura". Más adelante en las pruebas, intentaremos verificar con un poco más de detalle en qué situaciones usar "escribir asignar" y en qué "no escribir asignar".



MPU



Antes de pasar a la parte práctica, debemos averiguar cómo configurar los parámetros de la región de memoria. Para seleccionar el modo de caché (o deshabilitarlo) para una región específica de memoria en la arquitectura ARMv7-M, se usa MPU (Unidad de protección de memoria).



El controlador MPU admite la configuración de regiones de memoria. Específicamente en la arquitectura ARMV7-M, puede haber hasta 16 regiones. Para estas regiones, puede establecer de forma independiente: dirección de inicio, tamaño, derechos de acceso (lectura / escritura / ejecución, etc.), atributos: TEX, almacenable en caché, almacenable en búfer, compartible, así como otros parámetros. Con este mecanismo, en particular, puede lograr cualquier tipo de almacenamiento en caché para una región específica. Por ejemplo, podemos deshacernos de la necesidad de llamar a cache_clean / cache_invalidate simplemente asignando una región de memoria para todas las operaciones DMA y marcando esa memoria como no almacenable en caché.



Un punto importante a tener en cuenta al trabajar con MPU:

La dirección base, el tamaño y los atributos de una región son todos configurables, con la regla general de que todas las regiones están alineadas de forma natural. Esto se puede indicar como:

RegionBaseAddress [(N-1): 0] = 0, donde N es log2 (SizeofRegion_in_bytes)
En otras palabras, la dirección inicial de la región de memoria debe alinearse con su propio tamaño. Si, por ejemplo, tiene una región de 16 Kb, debe alinearla en 16 Kb. Si la región de memoria es de 64 Kb, alinee a 64 Kb. Y así. Si esto no se hace, la MPU puede "recortar" automáticamente la región al tamaño correspondiente a su dirección de inicio (probado en la práctica).



Por cierto, hay varios errores en STM32Cube. Por ejemplo:



  MPU_InitStruct.BaseAddress = 0x20010000;
  MPU_InitStruct.Size = MPU_REGION_SIZE_256KB;


Puede ver que la dirección de inicio está alineada con 64 KB. Y queremos que el tamaño de la región sea de 256 KB. En este caso, deberá crear 3 regiones: la primera 64 Kb, la segunda 128 Kb y la tercera 64 Kb.



Solo necesita especificar regiones con propiedades diferentes a las estándar. El hecho es que los atributos de todas las memorias cuando la caché del procesador está habilitada se describen en la arquitectura ARM. Existe un conjunto estándar de propiedades (por ejemplo, esta es la razón por la que STM32F7 SRAM tiene un modo de "escritura-escritura-asignación" por defecto), por lo que si necesita un modo no estándar para algunas de las memorias, deberá establecer sus propiedades a través de MPU. En este caso, dentro de la región, puede configurar una subregión con sus propias propiedades, resaltando dentro de esta región otra con alta prioridad con las propiedades requeridas.



TCM



Como se desprende de la documentación (sección 2.3 SRAM integrado), los primeros 64 KB de SRAM en STM32F7 no se pueden almacenar en caché. En la propia arquitectura ARMv7-M, SRAM se encuentra en 0x20000000. TCM también se refiere a SRAM, pero está ubicado en un bus diferente en relación con el resto de las memorias (SRAM1 y SRAM2), y está ubicado "más cerca" del procesador. Debido a esto, esta memoria es muy rápida, de hecho, tiene la misma velocidad que la caché. Y debido a esto, el almacenamiento en caché no es necesario y esta región no se puede almacenar en caché. De hecho, TCM es otro tipo de caché.



Caché de instrucciones



Cabe señalar que todo lo comentado anteriormente se refiere a la caché de datos (D-Cache). Pero además del caché de datos, ARMv7-M también proporciona un caché de instrucciones: caché de instrucciones (I-Cache). I-Cache le permite transferir algunas de las instrucciones ejecutables (y posteriores) a la caché, lo que puede acelerar significativamente el programa. Especialmente en los casos en los que el código está en una memoria más lenta que FLASH, por ejemplo, QSPI.



Para reducir la imprevisibilidad en las pruebas con el caché a continuación, deshabilitaremos intencionalmente I-Cache y pensaremos exclusivamente en los datos.



Al mismo tiempo, quiero señalar que activar I-Cache es bastante simple y no requiere ninguna acción adicional de la MPU, a diferencia de D-Cache.



Ensayos sintéticos



Después de discutir la parte teórica, pasemos a las pruebas para comprender mejor la diferencia y el alcance de aplicabilidad de un modelo en particular. Como dije anteriormente, desactive I-Cache y solo trabaje con D-Cache. También compilo intencionalmente con -O0 para que los bucles en las pruebas no estén optimizados. Probamos a través de una memoria SDRAM externa. Con la ayuda de MPU, marqué la región de 64 KB y expondremos los atributos que necesitamos para esta región.



Dado que las pruebas con cachés son muy caprichosas y están influenciadas por todo y por todos en el sistema, hagamos que el código sea lineal y continuo. Para hacer esto, desactive las interrupciones. Además, mediremos el tiempo no con temporizadores, sino con DWT (Data Watchpoint and Trace unit), que tiene un contador de ciclos de procesador de 32 bits. Sobre esta base (en Internet) la gente hace retrasos de microsegundos en los conductores. El contador se desborda rápidamente a la frecuencia del sistema de 216 MHz, pero puede medir hasta 20 segundos. Solo tendremos esto en cuenta y haremos pruebas en este intervalo de tiempo, poniendo a cero el contador del reloj antes de comenzar.



Puede ver los códigos de prueba completos aquí . Todas las pruebas se realizaron en la placa 32F769IDISCOVERY .



Memoria no caché VS. respóndeme



Así que comencemos con algunas pruebas muy simples.



Simplemente escribimos constantemente en la memoria.



    dst = (uint8_t *) DATA_ADDR;

    for (i = 0; i < ITERS * 8; i++) {
        for (j = 0; j < DATA_LEN; j++) {
            *dst = VALUE;
            dst++;
        }
        dst -= DATA_LEN;
    }


También escribimos secuencialmente en la memoria, pero no un byte a la vez, sino que ampliamos un poco los bucles.



    for (i = 0; i < ITERS * BLOCKS * 8; i++) {
        for (j = 0; j < BLOCK_LEN; j++) {
            *dst = VALUE;
            *dst = VALUE;
            *dst = VALUE;
            *dst = VALUE;
            dst++;
        }
        dst -= BLOCK_LEN;
    }


También escribimos secuencialmente en la memoria, pero ahora también agregaremos lectura.



    for (i = 0; i < ITERS * BLOCKS * 8; i++) {
        dst = (uint8_t *) DATA_ADDR;

        for (j = 0; j < BLOCK_LEN; j++) {
            val = VALUE;
            *dst = val;
            val = *dst;
            dst++;
        }
    }


Si ejecuta estas tres pruebas, darán exactamente el mismo resultado, sin importar el modo que elija:



mode: nc, iters=100, data len=65536, addr=0x60100000
Test1 (Sequential write):
  0s 728ms
Test2 (Sequential write with 4 writes per one iteration):
  7s 43ms
Test3 (Sequential read/write):
  1s 216ms


Y esto es razonable, SDRAM no es tan lento, especialmente si se consideran los búferes internos del FMC a través del cual está conectado. Sin embargo, esperaba una ligera variación en los números, pero resultó que no estaba en estas pruebas. Bueno, pensemos más.



Intentemos "estropear" la vida de SDRAM mezclando lecturas y escrituras. Para hacer esto, expandamos los bucles y agreguemos algo tan común en la práctica como el incremento de un elemento de matriz:



    for (i = 0; i < ITERS * BLOCKS; i++) {
        for (j = 0; j < BLOCK_LEN; j++) {
            // 16 lines
            arr[i]++;
            arr[i]++;
	***
            arr[i]++;
        }
    }


Resultado:



  :   4s 743ms
Write-back:                     :   4s 187ms


Ya mejor: con el caché resultó ser medio segundo más rápido. Intentemos complicar aún más la prueba agregando acceso por índices "dispersos". Por ejemplo, con un índice:



    for (i = 0; i < ITERS * BLOCKS; i++) {
        for (j = 0; j < BLOCK_LEN; j++) {
            arr[i + 0 ]++;
            ***
            arr[i + 3 ]++;
            arr[i + 4 ]++;
            arr[i + 100]++;
            arr[i + 6 ]++;
            arr[i + 7 ]++;
            ***
            arr[i + 15]++;
        }
    }


Resultado:



  :   11s 371ms
Write-back:                     :   4s 551ms


¡Ahora la diferencia con el caché se ha vuelto más que notable! Y para colmo, presentamos un segundo índice de este tipo:



    for (i = 0; i < ITERS * BLOCKS; i++) {
        for (j = 0; j < BLOCK_LEN; j++) {
            arr[i + 0 ]++;
            ***
            arr[i + 4 ]++;
            arr[i + 100]++;
            arr[i + 6 ]++;
            ***
            arr[i + 9 ]++;
            arr[i + 200]++;
            arr[i + 11]++;
            arr[i + 12]++;
            ***
            arr[i + 15]++;
        }
    }


Resultado:



  :   12s 62ms
Write-back:                     :   4s 551ms


Vemos cómo el tiempo para la memoria no almacenada en caché ha crecido casi un segundo, mientras que para la memoria caché sigue siendo el mismo.



Escribir asignar VS. no escribir asignar



Ahora tratemos con el modo de "asignación de escritura". Es incluso más difícil ver la diferencia aquí, ya que Si en la situación entre la memoria no almacenada en caché y la "escritura diferida" se vuelven claramente visibles ya a partir de la cuarta prueba, las diferencias entre "asignación de escritura" y "asignación sin escritura" aún no han sido reveladas por las pruebas. Pensemos: ¿cuándo será más rápido "escribir asignar"? Por ejemplo, cuando tiene muchas escrituras en ubicaciones de memoria secuenciales y hay pocas lecturas de esas ubicaciones de memoria. En este caso, en el modo "sin asignación de escritura", recibiremos fallas constantes y los elementos incorrectos se cargarán en la memoria caché mediante la lectura. Simulemos esta situación:



    for (i = 0; i < ITERS * BLOCKS; i++) {
        for (j = 0; j < BLOCK_LEN; j++) {
            arr[j + 0 ]  = VALUE;
            ***
            arr[j + 7 ]  = VALUE;
            arr[j + 8 ]  = arr[i % 1024 + (j % 256) * 128];
            arr[j + 9 ]  = VALUE;
            ***
            arr[j + 15 ]  = VALUE;
        }
    }


Aquí, 15 de los 16 registros se establecen en la constante VALUE, mientras que la lectura se realiza desde diferentes elementos (y no relacionados con la escritura) arr [i% 1024 + (j% 256) * 128]. Resulta que con la estrategia de asignación de no escritura, solo estos elementos se cargarán en la caché. La razón por la que se utiliza dicha indexación (i% 1024 + (j% 256) * 128) es la "degradación de la velocidad" de FMC / SDRAM. Dado que los accesos a la memoria en direcciones significativamente diferentes (no secuenciales) pueden afectar significativamente la velocidad del trabajo.



Resultado:



Write-back                                           :   4s 720ms
Write-back no write allocate:               :   4s 888ms


Finalmente, obtuvimos una diferencia, aunque no tan notable, pero ya visible. Es decir, se confirmó nuestra hipótesis.



Y finalmente, el caso más difícil, en mi opinión. Queremos entender cuándo "sin asignación de escritura" es mejor que "asignación de escritura". La primera es mejor si "a menudo" nos referimos a direcciones con las que no trabajaremos en un futuro próximo. Estos datos no necesitan almacenarse en caché.



En la siguiente prueba, en el caso de "escribir asignar", los datos se completarán en lectura y escritura. Hice una matriz de 64 KB "arr2", por lo que la caché se vaciará para intercambiar nuevos datos. En el caso de "no escribir asignar", hice una matriz "arr" de 4096 bytes, y solo entrará en la caché, lo que significa que los datos de la caché no se descargarán en la memoria. Debido a esto, intentaremos obtener al menos una pequeña ganancia.



    arr = (uint8_t *) DATA_ADDR;
    arr2 = arr;

    for (i = 0; i < ITERS * BLOCKS; i++) {
        for (j = 0; j < BLOCK_LEN; j++) {
            arr2[i * BLOCK_LEN            ] = arr[j + 0 ];
            arr2[i * BLOCK_LEN + j*32 + 1 ] = arr[j + 1 ];
            arr2[i * BLOCK_LEN + j*64 + 2 ] = arr[j + 2 ];
            arr2[i * BLOCK_LEN + j*128 + 3] = arr[j + 3 ];
            arr2[i * BLOCK_LEN + j*32 + 4 ] = arr[j + 4 ];
            ***
            arr2[i * BLOCK_LEN + j*32 + 15] = arr[j + 15 ];
        }
    }


Resultado:



Write-back                                           :   7s 601ms
Write-back no write allocate:               :   7s 599ms


Puede verse que el modo de "escritura diferida" "asignación de escritura" es un poco más rápido. Pero lo principal es que es más rápido.



No obtuve una mejor demostración, pero estoy seguro de que hay situaciones prácticas en las que la diferencia es más tangible. ¡Los lectores pueden sugerir sus propias opciones!



Ejemplos practicos



Pasemos de los ejemplos sintéticos a los reales.



silbido



Uno de los más simples es el ping. Es fácil comenzar y la hora se puede ver directamente en el host. Embox fue construido con la optimización -O2. Daré inmediatamente los resultados:



    :  ~0.246 c
Write-back                        :  ~0.140 c


Opencv



Otro ejemplo de un problema real en el que queríamos probar el subsistema de caché es OpenCV en STM32F7 . En ese artículo, se demostró que era bastante posible lanzarlo, pero el rendimiento era bastante bajo. Para la demostración, usaremos un ejemplo estándar que extrae bordes basados ​​en el filtro Canny. Midamos el tiempo de ejecución con y sin cachés (tanto D-cache como I-cache).



   gettimeofday(&tv_start, NULL);

    cedge.create(image.size(), image.type());
    cvtColor(image, gray, COLOR_BGR2GRAY);

    blur(gray, edge, Size(3,3));
    Canny(edge, edge, edgeThresh, edgeThresh*3, 3);
    cedge = Scalar::all(0);

    image.copyTo(cedge, edge);

    gettimeofday(&tv_cur, NULL);
    timersub(&tv_cur, &tv_start, &tv_cur);


Sin caché:



> edges fruits.png 20 
Processing time 0s 926ms
Framebuffer: 800x480 32bpp
Image: 512x269; Threshold=20


Con caché:



> edges fruits.png 20 
Processing time 0s 134ms
Framebuffer: 800x480 32bpp
Image: 512x269; Threshold=20


Es decir, la aceleración de 926 ms y 134 ms es casi 7 veces.



De hecho, a menudo nos preguntan sobre OpenCV en STM32, en particular, cuál es el rendimiento. Resulta que FPS ciertamente no es alto, pero 5 cuadros por segundo es bastante realista de obtener.



¿No se puede almacenar en caché o en memoria caché, pero con caché invalidada?



DMA se usa ampliamente en dispositivos reales, naturalmente, las dificultades están asociadas con él, porque necesita sincronizar la memoria incluso para el modo de "escritura directa". Existe un deseo natural de simplemente asignar una parte de la memoria que no se almacenará en caché y usarla cuando se trabaja con DMA. Un poco distraído. En Linux, esto se realiza mediante una función a través de dma_coherent_alloc () . Y sí, este es un método muy efectivo, por ejemplo, cuando se trabaja con paquetes de red en el sistema operativo, los datos del usuario pasan por una gran etapa de procesamiento antes de llegar al controlador, y en el controlador los datos preparados con todos los encabezados se copian en búferes que usan memoria no almacenada en caché.



¿Hay casos en los que limpiar / invalidar es preferible en un controlador con DMA? Sí hay. Por ejemplo, memoria de video, que nos solicitóeche un vistazo más de cerca a cómo funciona cache (). En el modo de almacenamiento en búfer doble, el sistema tiene dos búferes, en los que se extrae a su vez y luego se los entrega al controlador de video. Si hace que dicha memoria no se pueda almacenar en caché, habrá una caída en el rendimiento. Por lo tanto, es mejor hacer una limpieza antes de enviar el búfer al controlador de video.



Conclusión



Descubrimos un poco sobre los diferentes tipos de cachés en ARMv7m: escritura diferida, escritura simultánea, así como las configuraciones de “asignación de escritura” y “asignación de no escritura”. Construimos pruebas sintéticas en las que intentamos averiguar cuándo un modo es mejor que el otro, y también consideramos ejemplos prácticos con ping y OpenCV. En Embox, solo estamos trabajando en este tema, por lo que el subsistema correspondiente aún se está elaborando. Sin embargo, las ventajas de usar cachés son definitivamente notables.



Todos los ejemplos se pueden ver y reproducir construyendo Embox desde el repositorio abierto.



PD



Si está interesado en el tema de la programación de sistemas y OSDev, ¡la conferencia del Día del SO se llevará a cabo mañana ! Este año está en línea, ¡así que no te pierdas los que lo deseen! Embox actuará mañana a las 12.00 horas



All Articles